Add user verification and verification state violation badges (#4392)

* Move `observeRoomMemberIdentityStateChange` and associated classes to `libs:matrixui` module so they can be reused

* Add `EncryptionService.getUserIdentity` method to retrieve not only if the user is verified or not, but in which state they are

* Fix `IdentityChangePresenter` after the previous changes

* Fix `withFakeLifecycleOwner` and add `testWithLifecycleOwner` helper

* Display verified badge in DM top app bar when possible

* Display a verification violation warning icon next to the 'People' item in room details screen

* Display either a verified badge or a verification violation warning icon next to the room members in the room member list screen

* Display either a verified badge or a verification violation warning and withdraw verification button in the room member profile.

Generic user profiles won't display verification state anymore since we can't easily track changes in it.

* Add preview for room member details screen with verification violation identity state

* Add verified and violation badge to the `Profile` list item in room details screen

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-12 12:22:53 +01:00 committed by GitHub
parent b0e6b50c79
commit fd50ce4daf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 889 additions and 364 deletions

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LifecycleResumeEffect
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -33,7 +34,6 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
@ -61,6 +61,8 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
@ -76,10 +78,10 @@ import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -110,6 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
private val timelineController: TimelineController,
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
) : Presenter<MessagesState> {
@AssistedFactory
interface Factory {
@ -156,9 +159,6 @@ class MessagesPresenter @AssistedInject constructor(
var hasDismissedInviteDialog by rememberSaveable {
mutableStateOf(false)
}
val roomMemberIdentityStateChanges by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room)
}
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline.
@ -183,6 +183,22 @@ class MessagesPresenter @AssistedInject constructor(
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
}
var dmUserVerificationState by remember { mutableStateOf<IdentityState?>(null) }
val membersState by room.membersStateFlow.collectAsState()
val dmRoomMember by room.getDirectRoomMember(membersState)
// TODO use `RoomInfo.isEncrypted` as a key here once it's available
LifecycleResumeEffect(dmRoomMember) {
if (room.isEncrypted) {
val dmRoomMemberId = dmRoomMember?.userId
localCoroutineScope.launch {
dmRoomMemberId?.let { dmUserVerificationState = encryptionService.getUserIdentity(it).getOrNull() }
}
}
onPauseOrDispose {}
}
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> {
@ -215,7 +231,6 @@ class MessagesPresenter @AssistedInject constructor(
roomAvatar = roomAvatar,
heroes = heroes,
composerState = composerState,
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
@ -234,6 +249,7 @@ class MessagesPresenter @AssistedInject constructor(
appName = buildMeta.applicationName,
roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
eventSink = { handleEvents(it) }
)
}

View file

@ -10,7 +10,6 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
@ -24,6 +23,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -34,7 +34,6 @@ data class MessagesState(
val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState,
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
@ -52,5 +51,6 @@ data class MessagesState(
val roomCallState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val eventSink: (MessagesEvents) -> Unit
)

View file

@ -11,7 +11,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@ -40,9 +39,9 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@ -82,6 +81,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.Verified)
)
}
@ -94,7 +94,6 @@ fun aMessagesState(
isFullScreen = false,
mode = MessageComposerMode.Normal,
),
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
@ -112,6 +111,7 @@ fun aMessagesState(
enableVoiceMessages: Boolean = true,
roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@ -120,7 +120,6 @@ fun aMessagesState(
heroes = persistentListOf(),
userEventPermissions = userEventPermissions,
composerState = composerState,
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
@ -138,6 +137,7 @@ fun aMessagesState(
roomCallState = roomCallState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
eventSink = eventSink,
)

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
@ -50,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -88,6 +90,7 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -188,6 +191,7 @@ fun MessagesView(
roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes,
roomCallState = state.roomCallState,
isDmUserVerified = state.dmUserVerificationState?.let { it == IdentityState.Verified },
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
@ -427,7 +431,7 @@ private fun MessagesViewComposerBottomSheetContents(
onLinkClick = onLinkClick,
)
}
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
}
if (verificationViolation != null) {
@ -454,6 +458,7 @@ private fun MessagesViewTopBar(
roomAvatar: AvatarData?,
heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState,
isDmUserVerified: Boolean?,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onBackClick: () -> Unit,
@ -463,22 +468,36 @@ private fun MessagesViewTopBar(
BackButton(onClick = onBackClick)
},
title = {
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
heroes = heroes,
modifier = titleModifier
)
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
roomAvatar = roomAvatar,
heroes = heroes,
modifier = titleModifier
)
} else {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
modifier = titleModifier
)
}
if (isDmUserVerified == true) {
Icon(
modifier = Modifier.requiredWidthIn(min = 24.dp),
imageVector = CompoundIcons.Verified(),
tint = ElementTheme.colors.iconSuccessPrimary,
contentDescription = null
)
}
}
},
actions = {

View file

@ -7,15 +7,10 @@
package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableList
data class IdentityChangeState(
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val eventSink: (IdentityChangeEvent) -> Unit,
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)

View file

@ -11,11 +11,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.observeRoomMemberIdentityStateChange
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

View file

@ -12,6 +12,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import kotlinx.collections.immutable.toImmutableList
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> {

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings
@Composable

View file

@ -1,17 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
data class IdentityRoomMember(
val userId: UserId,
val displayNameOrDefault: String,
val avatarData: AvatarData,
)

View file

@ -7,9 +7,7 @@
package io.element.android.features.messages.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.ActionListEvents
@ -49,6 +47,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -66,7 +65,9 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -80,6 +81,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.FakeLifecycleOwner
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
@ -87,8 +89,8 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@ -107,9 +109,7 @@ class MessagesPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
@ -137,9 +137,7 @@ class MessagesPresenterTest {
)
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
runCurrent()
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
cancelAndIgnoreRemainingEvents()
@ -166,9 +164,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
@ -202,9 +198,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
@ -225,9 +219,7 @@ class MessagesPresenterTest {
onForwardEventClickLambda = onForwardEventClickLambda,
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -240,9 +232,7 @@ class MessagesPresenterTest {
val clipboardHelper = FakeClipboardHelper()
val event = aMessageEvent()
val presenter = createMessagesPresenter(clipboardHelper = clipboardHelper)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyText, event))
skipItems(2)
@ -267,9 +257,7 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper,
matrixRoom = matrixRoom,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyLink, event))
skipItems(2)
@ -283,9 +271,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
awaitItem()
@ -303,9 +289,7 @@ class MessagesPresenterTest {
@Test
fun `present - handle action reply to an event with no id does nothing`() = runTest {
val presenter = createMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
skipItems(1)
@ -318,9 +302,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemImageContent(
@ -360,9 +342,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent(
@ -403,9 +383,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val mediaMessage = aMessageEvent(
content = TimelineItemFileContent(
@ -439,9 +417,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
awaitItem()
@ -463,9 +439,7 @@ class MessagesPresenterTest {
onEditPollClickLambda = onEditPollClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent())))
awaitItem()
@ -477,9 +451,7 @@ class MessagesPresenterTest {
fun `present - handle action end poll`() = runTest {
val timelineEventSink = EventsRecorder<TimelineEvents>()
val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
delay(1)
@ -509,9 +481,7 @@ class MessagesPresenterTest {
matrixRoom = matrixRoom,
coroutineDispatchers = coroutineDispatchers,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val messageEvent = aMessageEvent()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Redact, messageEvent))
@ -529,9 +499,7 @@ class MessagesPresenterTest {
onReportContentClickLambda = onReportContentClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -542,9 +510,7 @@ class MessagesPresenterTest {
@Test
fun `present - handle dismiss action`() = runTest {
val presenter = createMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.Dismiss)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -558,9 +524,7 @@ class MessagesPresenterTest {
onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
@ -582,9 +546,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
// Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse()
@ -615,9 +577,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
(initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true
@ -640,9 +600,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
(initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true
@ -673,9 +631,7 @@ class MessagesPresenterTest {
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1)
@ -710,9 +666,7 @@ class MessagesPresenterTest {
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1)
@ -739,9 +693,7 @@ class MessagesPresenterTest {
)
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1)
@ -773,15 +725,15 @@ class MessagesPresenterTest {
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
val loadingState = consumeItemsUntilPredicate { state ->
state.inviteProgress.isLoading()
}.last()
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val failureState = consumeItemsUntilPredicate { state ->
state.inviteProgress.isFailure()
}.last()
@ -806,9 +758,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
skipItems(1)
val state = awaitItem()
assertThat(state.userEventPermissions.canSendMessage).isTrue()
@ -832,9 +782,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
// Default value
assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue()
assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse()
@ -852,9 +800,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last()
assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.userEventPermissions.canRedactOther).isFalse()
@ -873,9 +819,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last()
assertThat(initialState.userEventPermissions.canRedactOwn).isFalse()
assertThat(initialState.userEventPermissions.canRedactOther).isTrue()
@ -889,9 +833,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val poll = aMessageEvent(
content = aTimelineItemPollContent()
@ -925,9 +867,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
@ -965,9 +905,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.testWithLifecycleOwner {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
@ -1000,7 +938,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
presenter.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
@ -1030,7 +968,7 @@ class MessagesPresenterTest {
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
)
)
presenter.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
@ -1057,7 +995,7 @@ class MessagesPresenterTest {
caption = null,
)
)
presenter.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
awaitItem()
@ -1087,7 +1025,7 @@ class MessagesPresenterTest {
caption = null,
)
)
presenter.test {
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
awaitItem()
@ -1126,7 +1064,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
matrixRoom = room,
)
presenter.test {
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent))
@ -1140,7 +1078,7 @@ class MessagesPresenterTest {
content = aTimelineItemTextContent()
)
val presenter = createMessagesPresenter()
presenter.test {
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent))
@ -1148,6 +1086,39 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
val room = FakeMatrixRoom(
sessionId = A_SESSION_ID,
isEncrypted = true,
isDirect = true,
activeMemberCount = 2L,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
givenRoomInfo(aRoomInfo(id = roomId, name = "", isDirect = true))
}
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })
val presenter = createMessagesPresenter(matrixRoom = room, encryptionService = encryptionService)
val lifecycleOwner = FakeLifecycleOwner()
presenter.testWithLifecycleOwner(lifecycleOwner) {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.dmUserVerificationState).isNull()
ensureAllEventsConsumed()
lifecycleOwner.givenState(Lifecycle.State.RESUMED)
assertThat(awaitItem().dmUserVerificationState).isEqualTo(IdentityState.Verified)
}
}
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(
@ -1172,6 +1143,7 @@ class MessagesPresenterTest {
textEditorState = aTextEditorStateMarkdown(initialText = "", initialFocus = false)
)
},
encryptionService: FakeEncryptionService = FakeEncryptionService(),
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
return MessagesPresenter(
@ -1197,6 +1169,7 @@ class MessagesPresenterTest {
htmlConverterProvider = FakeHtmlConverterProvider(),
timelineController = TimelineController(matrixRoom),
permalinkParser = permalinkParser,
encryptionService = encryptionService,
analyticsService = analyticsService,
)
}

View file

@ -15,6 +15,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -44,6 +45,7 @@ import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@ -154,6 +156,12 @@ class RoomDetailsPresenter @Inject constructor(
}
}
val hasMemberVerificationViolations by produceState(false) {
room.roomMemberIdentityStateChange()
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
.launchIn(this)
}
return RoomDetailsState(
roomId = room.roomId,
roomName = roomName,
@ -180,6 +188,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
eventSink = ::handleEvents,
)
}

View file

@ -45,6 +45,7 @@ data class RoomDetailsState(
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val canShowSecurityAndPrivacy: Boolean,
val hasMemberVerificationViolations: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
) {
val roomBadges = buildList {

View file

@ -14,6 +14,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomAlias
@ -50,6 +51,9 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState(pinnedMessagesCount = 3),
aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true),
aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true),
aRoomDetailsState(hasMemberVerificationViolations = true),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
// Add other state here
)
}
@ -110,6 +114,7 @@ fun aRoomDetailsState(
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
canShowSecurityAndPrivacy: Boolean = true,
hasMemberVerificationViolations: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@ -137,6 +142,7 @@ fun aRoomDetailsState(
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
eventSink = eventSink
)
@ -152,6 +158,7 @@ fun aDmRoomDetailsState(
isDmMemberIgnored: Boolean = false,
roomName: String = "Daniel",
isEncrypted: Boolean = true,
dmRoomMemberVerificationState: UserProfileVerificationState = UserProfileVerificationState.UNKNOWN,
) = aRoomDetailsState(
roomName = roomName,
isPublic = false,
@ -162,5 +169,6 @@ fun aDmRoomDetailsState(
),
roomMemberDetailsState = aUserProfileState(
isBlocked = AsyncData.Success(isDmMemberIgnored),
verificationState = dmRoomMemberVerificationState,
)
)

View file

@ -39,6 +39,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@ -189,7 +190,10 @@ fun RoomDetailsView(
}
state.roomMemberDetailsState?.let { dmMemberDetails ->
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
ProfileItem(
verificationState = dmMemberDetails.verificationState,
onClick = { onProfileClick(dmMemberDetails.userId) }
)
}
}
@ -197,6 +201,7 @@ fun RoomDetailsView(
PreferenceCategory {
MembersItem(
memberCount = state.memberCount,
hasVerificationViolations = state.hasMemberVerificationViolations,
openRoomMemberList = openRoomMemberList,
)
if (state.canShowKnockRequests) {
@ -555,11 +560,23 @@ private fun FavoriteItem(
@Composable
private fun ProfileItem(
verificationState: UserProfileVerificationState,
onClick: () -> Unit,
) {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) },
trailingContent = when (verificationState) {
UserProfileVerificationState.VERIFIED -> ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.Verified()),
tintColor = ElementTheme.colors.iconSuccessPrimary,
)
UserProfileVerificationState.VERIFICATION_VIOLATION -> ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()),
tintColor = ElementTheme.colors.iconCriticalPrimary,
)
else -> null
},
onClick = onClick,
)
}
@ -567,12 +584,20 @@ private fun ProfileItem(
@Composable
private fun MembersItem(
memberCount: Long,
hasVerificationViolations: Boolean,
openRoomMemberList: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
trailingContent = if (hasVerificationViolations) {
ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()),
tintColor = ElementTheme.colors.textCriticalPrimary,
)
} else {
ListItemContent.Text(memberCount.toString())
},
onClick = openRoomMemberList,
)
}

View file

@ -14,6 +14,7 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
@Module
@ -23,6 +24,7 @@ object RoomMemberModule {
fun provideRoomMemberDetailsPresenterFactory(
room: MatrixRoom,
userProfilePresenterFactory: UserProfilePresenterFactory,
encryptionService: EncryptionService,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@ -30,6 +32,7 @@ object RoomMemberModule {
roomMemberId = roomMemberId,
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
)
}
}

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -24,13 +25,23 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
class RoomMemberListPresenter @AssistedInject constructor(
@ -38,6 +49,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersModerationPresenter: Presenter<RoomMembersModerationState>,
private val encryptionService: EncryptionService,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> {
@AssistedFactory
@ -60,12 +72,20 @@ class RoomMemberListPresenter @AssistedInject constructor(
val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) {
room.roomMemberIdentityStateChange()
.onEach { identities ->
value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toPersistentMap()
}
.launchIn(this)
}
// Ensure we load the latest data when entering this screen
LaunchedEffect(Unit) {
room.updateMembers()
}
LaunchedEffect(membersState) {
LaunchedEffect(membersState, roomMemberIdentityStates) {
if (membersState is MatrixRoomMembersState.Unknown) {
return@LaunchedEffect
}
@ -84,11 +104,17 @@ class RoomMemberListPresenter @AssistedInject constructor(
return@withContext
}
val result = RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
.sortedBy { it.userId.value }
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
)
roomMembers = if (membersState is MatrixRoomMembersState.Pending) {
AsyncData.Loading(result)
@ -108,11 +134,17 @@ class RoomMemberListPresenter @AssistedInject constructor(
SearchBarResultState.NoResultsFound()
} else {
val result = RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList())
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList())
.sortedBy { it.userId.value }
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
)
SearchBarResultState.Results(
if (membersState is MatrixRoomMembersState.Pending) {
@ -149,4 +181,13 @@ class RoomMemberListPresenter @AssistedInject constructor(
eventSink = { handleEvents(it) },
)
}
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
return if (!room.isEncrypted) {
RoomMemberWithIdentityState(this, null)
} else {
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId).getOrNull()
RoomMemberWithIdentityState(this, identityState)
}
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
@ -24,7 +25,12 @@ data class RoomMemberListState(
)
data class RoomMembers(
val invited: ImmutableList<RoomMember>,
val joined: ImmutableList<RoomMember>,
val banned: ImmutableList<RoomMember>,
val invited: ImmutableList<RoomMemberWithIdentityState>,
val joined: ImmutableList<RoomMemberWithIdentityState>,
val banned: ImmutableList<RoomMemberWithIdentityState>,
)
data class RoomMemberWithIdentityState(
val roomMember: RoomMember,
val identityState: IdentityState?,
)

View file

@ -13,6 +13,7 @@ import io.element.android.features.roomdetails.impl.members.moderation.aRoomMemb
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
@ -23,8 +24,21 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
banned = persistentListOf(),
)
)
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(
anAlice().withIdentity(identityState = IdentityState.Verified),
aBob().withIdentity(identityState = IdentityState.PinViolation),
aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
),
banned = persistentListOf(),
)
)
@ -40,8 +54,8 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchResults = SearchBarResultState.Results(
AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor()),
joined = persistentListOf(anAlice()),
invited = persistentListOf(aVictor().withIdentity()),
joined = persistentListOf(anAlice().withIdentity()),
banned = persistentListOf(),
)
)
@ -67,9 +81,9 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
@ -81,9 +95,9 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
@ -159,3 +173,5 @@ fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Rol
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -41,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.libraries.architecture.AsyncData
@ -49,6 +51,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
@ -57,7 +60,9 @@ import io.element.android.libraries.designsystem.theme.components.SegmentedButto
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
@ -268,7 +273,7 @@ private fun LazyListScope.failureItem(failure: Throwable) {
private fun LazyListScope.roomMemberListSection(
headerText: @Composable (() -> String)?,
members: ImmutableList<RoomMember>?,
members: ImmutableList<RoomMemberWithIdentityState>?,
onMemberSelected: (RoomMember) -> Unit,
) {
headerText?.let {
@ -284,34 +289,61 @@ private fun LazyListScope.roomMemberListSection(
items(members.orEmpty()) { matrixUser ->
RoomMemberListItem(
modifier = Modifier.fillMaxWidth(),
roomMember = matrixUser,
onClick = { onMemberSelected(matrixUser) }
roomMemberWithIdentity = matrixUser,
onClick = { onMemberSelected(matrixUser.roomMember) }
)
}
}
@Composable
private fun RoomMemberListItem(
roomMember: RoomMember,
roomMemberWithIdentity: RoomMemberWithIdentityState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val roleText = when (roomMember.role) {
val roleText = when (roomMemberWithIdentity.roomMember.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator)
RoomMember.Role.USER -> null
}
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = roomMember.toMatrixUser(),
matrixUser = roomMemberWithIdentity.roomMember.toMatrixUser(),
avatarSize = AvatarSize.UserListItem,
trailingContent = roleText?.let {
@Composable {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
trailingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (roomMemberWithIdentity.identityState) {
IdentityState.Verified -> {
Icon(
imageVector = CompoundIcons.Verified(),
contentDescription = stringResource(CommonStrings.common_verified),
tint = ElementTheme.colors.iconSuccessPrimary
)
}
IdentityState.VerificationViolation -> {
Icon(
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = stringResource(
CommonStrings.crypto_identity_change_profile_pin_violation,
roomMemberWithIdentity.roomMember.getBestName()
),
tint = ElementTheme.colors.iconCriticalPrimary
)
}
else -> Unit
}
roleText?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
)

View file

@ -37,7 +37,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class RoomMemberDetailsInput(
val roomMemberId: UserId
val roomMemberId: UserId,
) : NodeInputs
private val inputs = inputs<RoomMemberDetailsInput>()

View file

@ -11,14 +11,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* Presenter for room member details screen.
@ -27,6 +38,7 @@ import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId,
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
userProfilePresenterFactory: UserProfilePresenterFactory,
) : Presenter<UserProfileState> {
interface Factory {
@ -37,6 +49,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Composable
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
val roomMember by room.getRoomMemberAsState(roomMemberId)
LaunchedEffect(Unit) {
// Update room member info when opening this screen
@ -60,9 +74,53 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
val userProfileState = userProfilePresenter.present()
val identityStateChanges by produceState<IdentityStateChange?>(initialValue = null) {
if (room.isEncrypted) {
// Fetch the initial identity state manually
val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull()
value = identityState?.let { IdentityStateChange(roomMemberId, it) }
// Subscribe to the identity changes
room.roomMemberIdentityStateChange()
.map { it.find { it.identityRoomMember.userId == roomMemberId } }
.map { roomMemberIdentityStateChange ->
// If we didn't receive any info, manually fetch it
roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull()
}
.filterNotNull()
.collect { value = IdentityStateChange(roomMemberId, it) }
}
}
val verificationState = remember(identityStateChanges) {
when (identityStateChanges?.identityState) {
IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION
IdentityState.Verified -> UserProfileVerificationState.VERIFIED
IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED
else -> UserProfileVerificationState.UNKNOWN
}
}
fun eventSink(event: UserProfileEvents) {
when (event) {
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
encryptionService.withdrawVerification(roomMemberId)
}
else -> Unit
}
}
return userProfileState.copy(
userName = roomUserName ?: userProfileState.userName,
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
verificationState = verificationState,
eventSink = { event ->
if (event is UserProfileEvents.WithdrawVerification) {
eventSink(UserProfileEvents.WithdrawVerification)
} else {
userProfileState.eventSink(event)
}
}
)
}
}

View file

@ -8,10 +8,6 @@
package io.element.android.features.roomdetails.impl
import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
@ -39,6 +35,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -52,7 +49,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.withFakeLifecycleOwner
import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@ -83,6 +80,7 @@ class RoomDetailsPresenterTest {
)
),
isPinnedMessagesFeatureEnabled: Boolean = true,
encryptionService: FakeEncryptionService = FakeEncryptionService(),
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@ -93,6 +91,7 @@ class RoomDetailsPresenterTest {
userProfilePresenterFactory = {
Presenter { aUserProfileState() }
},
encryptionService = encryptionService,
)
}
}
@ -110,14 +109,6 @@ class RoomDetailsPresenterTest {
)
}
private suspend fun RoomDetailsPresenter.test(validate: suspend TurbineTestContext<RoomDetailsState>.() -> Unit) {
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
present()
}
}.test(validate = validate)
}
@Test
fun `present - initial state is created from room if roomInfo is null`() = runTest {
val room = aMatrixRoom(
@ -126,7 +117,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId)
@ -157,7 +148,7 @@ class RoomDetailsPresenterTest {
givenRoomInfo(roomInfo)
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
val updatedState = awaitItem()
assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
@ -177,7 +168,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
val initialState = awaitItem()
assertThat(initialState.roomName).isEqualTo(room.displayName)
@ -207,7 +198,7 @@ class RoomDetailsPresenterTest {
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
val initialState = awaitItem()
assertThat(initialState.roomType).isEqualTo(
RoomDetailsType.Dm(
@ -227,7 +218,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Initially false
assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true
@ -245,7 +236,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
@ -260,7 +251,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
@ -281,7 +272,7 @@ class RoomDetailsPresenterTest {
canUserJoinCallResult = { Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
@ -320,7 +311,7 @@ class RoomDetailsPresenterTest {
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes, but editing is still disallowed because it's a DM
@ -364,7 +355,7 @@ class RoomDetailsPresenterTest {
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
// There's no topic, so we hide the entire UI for DMs
@ -391,7 +382,7 @@ class RoomDetailsPresenterTest {
canUserJoinCallResult = { Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
@ -418,7 +409,7 @@ class RoomDetailsPresenterTest {
canUserJoinCallResult = { Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse()
@ -444,7 +435,7 @@ class RoomDetailsPresenterTest {
canUserJoinCallResult = { Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
@ -472,7 +463,7 @@ class RoomDetailsPresenterTest {
givenRoomInfo(aRoomInfo(topic = null))
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
// Ignore the initial state
skipItems(1)
@ -496,7 +487,7 @@ class RoomDetailsPresenterTest {
leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventRecorder),
dispatchers = testCoroutineDispatchers()
)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(room.roomId))
cancelAndIgnoreRemainingEvents()
@ -516,7 +507,7 @@ class RoomDetailsPresenterTest {
room = room,
notificationSettingsService = notificationSettingsService,
)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
notificationSettingsService.setRoomNotificationMode(
room.roomId,
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@ -545,7 +536,7 @@ class RoomDetailsPresenterTest {
room = room,
notificationSettingsService = notificationSettingsService
)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
@ -573,7 +564,7 @@ class RoomDetailsPresenterTest {
room = room,
notificationSettingsService = notificationSettingsService
)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
@ -597,7 +588,7 @@ class RoomDetailsPresenterTest {
val analyticsService = FakeAnalyticsService()
val presenter =
createRoomDetailsPresenter(room = room, analyticsService = analyticsService)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEvent.SetFavorite(true))
setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
@ -623,7 +614,7 @@ class RoomDetailsPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room = room)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
room.givenRoomInfo(aRoomInfo(isFavorite = true))
consumeItemsUntilPredicate { it.isFavorite }.last().let { state ->
assertThat(state.isFavorite).isTrue()
@ -652,19 +643,14 @@ class RoomDetailsPresenterTest {
room = room,
featureFlagService = featureFlagService,
)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
with(awaitItem()) {
assertThat(canShowKnockRequests).isFalse()
}
assertThat(awaitItem().canShowKnockRequests).isFalse()
featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true)
with(awaitItem()) {
assertThat(canShowKnockRequests).isTrue()
}
assertThat(awaitItem().canShowKnockRequests).isTrue()
room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Private))
with(awaitItem()) {
assertThat(canShowKnockRequests).isFalse()
}
assertThat(awaitItem().canShowKnockRequests).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@ -677,7 +663,7 @@ class RoomDetailsPresenterTest {
)
val featureFlagService = FakeFeatureFlagService()
val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService)
presenter.test {
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
with(awaitItem()) {
assertThat(canShowSecurityAndPrivacy).isFalse()

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.EventsRecorder
@ -59,7 +60,8 @@ class RoomMemberListPresenterTest {
skipItems(1)
val loadedMembersState = awaitItem()
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited).isEqualTo(listOf(aVictor(), aWalter()))
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited)
.isEqualTo(listOf(RoomMemberWithIdentityState(aVictor(), null), RoomMemberWithIdentityState(aWalter(), null)))
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
}
}
@ -129,7 +131,7 @@ class RoomMemberListPresenterTest {
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem()
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().displayName)
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().roomMember.displayName)
.isEqualTo("Alice")
}
}
@ -259,11 +261,13 @@ private fun TestScope.createPresenter(
),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
roomMembersModerationStateLambda: () -> RoomMembersModerationState = { aRoomMembersModerationState() },
encryptedService: FakeEncryptionService = FakeEncryptionService(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator {}
) = RoomMemberListPresenter(
room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
roomMembersModerationPresenter = { roomMembersModerationStateLambda() },
navigator = navigator
encryptionService = encryptedService,
navigator = navigator,
)

View file

@ -13,14 +13,24 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -45,9 +55,7 @@ class RoomMemberDetailsPresenterTest {
val presenter = createRoomMemberDetailsPresenter(
room = room,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
@ -156,6 +164,180 @@ class RoomMemberDetailsPresenterTest {
}
}
@Test
fun `present - when user's identity is verified, the value in the state is VERIFIED`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(IdentityState.Verified) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED }
}
}
@Test
fun `present - when user's identity is unknown, the value in the state is UNKNOWN`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(null) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
ensureAllEventsConsumed()
}
}
@Test
fun `present - when user's identity is pinned, the value in the state is UNVERIFIED`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(IdentityState.Pinned) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
}
}
@Test
fun `present - when user's identity is pin violation, the value in the state is UNVERIFIED`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(IdentityState.PinViolation) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
}
}
@Test
fun `present - when user's identity has a verification violation, the value in the state is VERIFICATION_VIOLATION`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
}
}
@Test
fun `present - user identity updates in real time if the room is encrypted`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(null) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
room.emitSyncUpdate()
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned)))
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Verified)))
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED }
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.VerificationViolation)))
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
}
}
@Test
fun `present - user identity can't update in real time if the room is not encrypted`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = false,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(null) },
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
room.emitSyncUpdate()
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned)))
// No new events emitted
ensureAllEventsConsumed()
}
}
@Test
fun `present - handles WithdrawVerification action`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
userDisplayNameResult = { Result.success("A custom name") },
userAvatarUrlResult = { Result.success("A custom avatar") },
)
val withdrawVerificationResult = lambdaRecorder<UserId, Result<Unit>> { Result.success(Unit) }
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) },
withdrawVerificationResult = withdrawVerificationResult,
)
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
presenter.test {
// Initial state, then the verification state is updated
val initialState = awaitItem()
assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
initialState.eventSink(UserProfileEvents.WithdrawVerification)
withdrawVerificationResult.assertions().isCalledOnce()
}
}
private fun createRoomMemberDetailsPresenter(
room: MatrixRoom,
userProfilePresenterFactory: UserProfilePresenterFactory = UserProfilePresenterFactory {
@ -166,11 +348,13 @@ class RoomMemberDetailsPresenterTest {
)
}
},
encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }),
): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(
roomMemberId = UserId("@alice:server.org"),
room = room,
userProfilePresenterFactory = userProfilePresenterFactory
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
)
}
}

View file

@ -14,4 +14,5 @@ sealed interface UserProfileEvents {
data class UnblockUser(val needsConfirmation: Boolean = false) : UserProfileEvents
data object ClearBlockUserError : UserProfileEvents
data object ClearConfirmationDialog : UserProfileEvents
data object WithdrawVerification : UserProfileEvents
}

View file

@ -16,7 +16,7 @@ data class UserProfileState(
val userId: UserId,
val userName: String?,
val avatarUrl: String?,
val isVerified: AsyncData<Boolean>,
val verificationState: UserProfileVerificationState,
val isBlocked: AsyncData<Boolean>,
val startDmActionState: AsyncAction<RoomId>,
val displayConfirmationDialog: ConfirmationDialog?,
@ -30,3 +30,10 @@ data class UserProfileState(
Unblock
}
}
enum class UserProfileVerificationState {
UNKNOWN,
VERIFIED,
UNVERIFIED,
VERIFICATION_VIOLATION,
}

View file

@ -66,6 +66,8 @@ class UserProfileFlowNode @AssistedInject constructor(
data class VerifyUser(val userId: UserId) : NavTarget
}
private val inputs = inputs<UserProfileEntryPoint.Params>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
@ -86,7 +88,7 @@ class UserProfileFlowNode @AssistedInject constructor(
backstack.push(NavTarget.VerifyUser(userId))
}
}
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
val params = UserProfileNode.UserProfileInputs(userId = inputs.userId)
createNode<UserProfileNode>(buildContext, listOf(callback, params))
}
is NavTarget.AvatarPreview -> {

View file

@ -42,7 +42,7 @@ class UserProfileNode @AssistedInject constructor(
private val inputs = inputs<UserProfileInputs>()
private val callback = inputs<UserProfileNodeHelper.Callback>()
private val presenter = presenterFactory.create(inputs.userId)
private val presenter = presenterFactory.create(userId = inputs.userId)
private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId)
init {

View file

@ -24,10 +24,10 @@ import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -75,7 +75,6 @@ class UserProfilePresenter @AssistedInject constructor(
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val dmRoomId by getDmRoomId()
val canCall by getCanCall(dmRoomId)
LaunchedEffect(Unit) {
@ -87,12 +86,6 @@ class UserProfilePresenter @AssistedInject constructor(
}
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
LaunchedEffect(Unit) {
suspend {
client.encryptionService().isUserVerified(userId).getOrThrow()
}.runCatchingUpdatingState(isVerified)
}
fun handleEvents(event: UserProfileEvents) {
when (event) {
is UserProfileEvents.BlockUser -> {
@ -127,6 +120,8 @@ class UserProfilePresenter @AssistedInject constructor(
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
// Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed
UserProfileEvents.WithdrawVerification -> Unit
}
}
@ -135,7 +130,7 @@ class UserProfilePresenter @AssistedInject constructor(
userName = userProfile?.displayName,
avatarUrl = userProfile?.avatarUrl,
isBlocked = isBlocked.value,
isVerified = isVerified.value,
verificationState = UserProfileVerificationState.UNKNOWN,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = isCurrentUser,

View file

@ -18,12 +18,14 @@ import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -69,7 +71,7 @@ class UserProfilePresenterTest {
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
assertThat(initialState.isVerified.dataOrNull()).isFalse()
assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.canCall).isFalse()
}
@ -361,36 +363,25 @@ class UserProfilePresenterTest {
}
}
@Test
fun `present - when user is verified, the value in the state is true`() = runTest {
val client = createFakeMatrixClient(isUserVerified = true)
val presenter = createUserProfilePresenter(
client = client,
)
presenter.test {
assertThat(awaitItem().isVerified.isUninitialized()).isTrue()
assertThat(awaitItem().isVerified.isLoading()).isTrue()
assertThat(awaitItem().isVerified.dataOrNull()).isTrue()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(2)
skipItems(1)
return awaitItem()
}
private fun createFakeMatrixClient(
isUserVerified: Boolean = false,
isUserVerified: Boolean = true,
userIdentityState: IdentityState? = null,
ignoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
) = FakeMatrixClient(
encryptionService = FakeEncryptionService(
isUserVerifiedResult = { Result.success(isUserVerified) }
isUserVerifiedResult = { Result.success(isUserVerified) },
getUserIdentityResult = { Result.success(userIdentityState) }
),
ignoreUserResult = ignoreUserResult,
unIgnoreUserResult = unIgnoreUserResult,
ignoredUsersFlow = ignoredUsersFlow
ignoredUsersFlow = ignoredUsersFlow,
)
private fun createUserProfilePresenter(
@ -401,7 +392,7 @@ class UserProfilePresenterTest {
return UserProfilePresenter(
userId = userId,
client = client,
startDMAction = startDMAction
startDMAction = startDMAction,
)
}
}

View file

@ -22,7 +22,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -30,6 +30,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.testtags.TestTags
@ -42,8 +44,9 @@ fun UserProfileHeaderSection(
avatarUrl: String?,
userId: UserId,
userName: String?,
isUserVerified: AsyncData<Boolean>,
verificationState: UserProfileVerificationState,
openAvatarPreview: (url: String) -> Unit,
withdrawVerificationClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
@ -74,16 +77,37 @@ fun UserProfileHeaderSection(
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
if (isUserVerified.dataOrNull() == true) {
MatrixBadgeRowMolecule(
data = listOf(
MatrixBadgeAtom.MatrixBadgeData(
text = stringResource(CommonStrings.common_verified),
icon = CompoundIcons.Verified(),
type = MatrixBadgeAtom.Type.Positive,
)
).toImmutableList(),
)
when (verificationState) {
UserProfileVerificationState.UNKNOWN, UserProfileVerificationState.UNVERIFIED -> Unit
UserProfileVerificationState.VERIFIED -> {
MatrixBadgeRowMolecule(
data = listOf(
MatrixBadgeAtom.MatrixBadgeData(
text = stringResource(CommonStrings.common_verified),
icon = CompoundIcons.Verified(),
type = MatrixBadgeAtom.Type.Positive,
)
).toImmutableList(),
)
}
UserProfileVerificationState.VERIFICATION_VIOLATION -> {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(CommonStrings.crypto_identity_change_profile_pin_violation, userName ?: userId.value),
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
size = ButtonSize.MediumLowPadding,
text = stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action),
onClick = withdrawVerificationClick,
)
}
}
Spacer(Modifier.height(40.dp))
}
@ -96,7 +120,21 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
avatarUrl = null,
userId = UserId("@alice:example.com"),
userName = "Alice",
isUserVerified = AsyncData.Success(true),
verificationState = UserProfileVerificationState.VERIFIED,
openAvatarPreview = {},
withdrawVerificationClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = ElementPreview {
UserProfileHeaderSection(
avatarUrl = null,
userId = UserId("@alice:example.com"),
userName = "Alice",
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
openAvatarPreview = {},
withdrawVerificationClick = {},
)
}

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
@ -22,13 +23,14 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
get() = sequenceOf(
aUserProfileState(),
aUserProfileState(userName = null),
aUserProfileState(isBlocked = AsyncData.Success(true), isVerified = AsyncData.Success(true)),
aUserProfileState(isBlocked = AsyncData.Success(true), verificationState = UserProfileVerificationState.VERIFIED),
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block),
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
aUserProfileState(isBlocked = AsyncData.Loading(true), isVerified = AsyncData.Loading()),
aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN),
aUserProfileState(startDmActionState = AsyncAction.Loading),
aUserProfileState(canCall = true),
aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())),
aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
)
}
@ -37,7 +39,7 @@ fun aUserProfileState(
userName: String? = "Daniel",
avatarUrl: String? = null,
isBlocked: AsyncData<Boolean> = AsyncData.Success(false),
isVerified: AsyncData<Boolean> = AsyncData.Success(false),
verificationState: UserProfileVerificationState = UserProfileVerificationState.UNVERIFIED,
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
isCurrentUser: Boolean = false,
@ -49,7 +51,7 @@ fun aUserProfileState(
userName = userName,
avatarUrl = avatarUrl,
isBlocked = isBlocked,
isVerified = isVerified,
verificationState = verificationState,
startDmActionState = startDmActionState,
displayConfirmationDialog = displayConfirmationDialog,
isCurrentUser = isCurrentUser,

View file

@ -24,6 +24,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -70,10 +71,11 @@ fun UserProfileView(
avatarUrl = state.avatarUrl,
userId = state.userId,
userName = state.userName,
isUserVerified = state.isVerified,
verificationState = state.verificationState,
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
},
withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) },
)
UserProfileMainActionsSection(
isCurrentUser = state.isCurrentUser,
@ -122,7 +124,7 @@ private fun VerifyUserSection(
state: UserProfileState,
onVerifyClick: () -> Unit,
) {
if (state.isVerified.dataOrNull() == false) {
if (state.verificationState == UserProfileVerificationState.UNVERIFIED) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.features.userprofile.shared.aUserProfileState
@ -200,7 +201,7 @@ class UserProfileViewTest {
fun `on verify user clicked - the right callback is called`() = runTest {
ensureCalledOnceWithParam(A_USER_ID) { callback ->
rule.setUserProfileView(
state = aUserProfileState(userId = A_USER_ID, isVerified = AsyncData.Success(false)),
state = aUserProfileState(userId = A_USER_ID, verificationState = UserProfileVerificationState.UNVERIFIED),
onVerifyClick = callback,
)
rule.clickOn(CommonStrings.common_verify_user)

View file

@ -187,6 +187,9 @@ class IncomingVerificationPresenterTest {
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
// The screen is dismissed
skipItems(2)
advanceUntilIdle()
onFinishLambda.assertions().isCalledOnce()
}
}

View file

@ -10,10 +10,12 @@ package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@ -77,8 +79,9 @@ sealed interface ListItemContent {
/**
* Default Icon content for [ListItem]. Sets the Icon component to a predefined size.
* @param iconSource The icon to display, using [IconSource.getPainter].
* @param tintColor The tint color for the icon, if any. Defaults to `null`.
*/
data class Icon(val iconSource: IconSource) : ListItemContent
data class Icon(val iconSource: IconSource, val tintColor: Color? = null) : ListItemContent
/**
* Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow.
@ -119,7 +122,8 @@ sealed interface ListItemContent {
IconComponent(
modifier = Modifier.size(maxCompactSize),
painter = iconSource.getPainter(),
contentDescription = iconSource.contentDescription
contentDescription = iconSource.contentDescription,
tint = tintColor ?: LocalContentColor.current,
)
}
is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.encryption
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -75,6 +76,11 @@ interface EncryptionService {
* user trust from verified to TOFU verified.
*/
suspend fun withdrawVerification(userId: UserId): Result<Unit>
/**
* Get the identity state of a user, if known.
*/
suspend fun getUserIdentity(userId: UserId): Result<IdentityState?>
}
/**

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.CoroutineScope
@ -202,18 +203,29 @@ internal class RustEncryptionService(
}
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = runCatching {
getUserIdentity(userId).isVerified()
getUserIdentityInternal(userId).isVerified()
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).pin()
getUserIdentityInternal(userId).pin()
}
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).withdrawVerification()
getUserIdentityInternal(userId).withdrawVerification()
}
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = runCatching {
val identity = getUserIdentityInternal(userId)
val isVerified = identity.isVerified()
when {
identity.hasVerificationViolation() -> IdentityState.VerificationViolation
isVerified -> IdentityState.Verified
!isVerified -> IdentityState.Pinned
else -> null
}
}
suspend fun getUserIdentityInternal(userId: UserId): UserIdentity {
return service.userIdentity(
userId = userId.value,
// requestFromHomeserverIfNeeded = true,

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@ -25,6 +26,7 @@ class FakeEncryptionService(
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() }
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@ -133,6 +135,10 @@ class FakeEncryptionService(
isUserVerifiedResult(userId)
}
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = simulateLongTask {
return getUserIdentityResult(userId)
}
companion object {
const val FAKE_RECOVERY_KEY = "fake"
}

View file

@ -5,21 +5,22 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.messagecomposer
package io.element.android.libraries.matrix.ui.room
import androidx.compose.runtime.ProduceStateScope
import io.element.android.features.messages.impl.crypto.identity.IdentityRoomMember
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@ -28,30 +29,33 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@OptIn(ExperimentalCoroutinesApi::class)
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.syncUpdateFlow
fun MatrixRoom.roomMemberIdentityStateChange(): Flow<ImmutableList<RoomMemberIdentityStateChange>> {
return syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.find { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}.toPersistentList()
}.distinctUntilChanged()
}
}
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.roomMemberIdentityStateChange()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
.launchIn(this)
}
@ -72,3 +76,14 @@ private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityR
size = AvatarSize.ComposerAlert,
),
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)
data class IdentityRoomMember(
val userId: UserId,
val displayNameOrDefault: String,
val avatarData: AvatarData,
)

View file

@ -129,6 +129,7 @@ class KonsistPreviewTest {
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview",
"UserAvatarColorsPreview",
"UserProfileHeaderSectionWithVerificationViolationPreview",
"VoiceItemViewPlayPreview",
)
.assertTrue(

View file

@ -9,31 +9,64 @@ package io.element.android.tests.testutils
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.Stable
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import io.element.android.libraries.architecture.Presenter
/**
* Composable that provides a fake [LifecycleOwner] to the composition.
*
* **WARNING: DO NOT USE OUTSIDE TESTS.**
*/
@OptIn(InternalComposeApi::class)
@Stable
@Composable
fun <T> withFakeLifecycleOwner(lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), block: @Composable () -> T): T {
var state: T? by remember { mutableStateOf(null) }
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
state = block()
}
return state!!
fun <T> withFakeLifecycleOwner(
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
block: @Composable () -> T
): T {
currentComposer.startProvider(LocalLifecycleOwner provides lifecycleOwner)
val state = block()
currentComposer.endProvider()
return state
}
/**
* Test a [Presenter] with a fake [LifecycleOwner].
*
* **WARNING: DO NOT USE OUTSIDE TESTS.**
*/
suspend fun <T> Presenter<T>.testWithLifecycleOwner(
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
block: suspend TurbineTestContext<T>.() -> Unit
) {
moleculeFlow(RecompositionMode.Immediate) {
val ret = withFakeLifecycleOwner(lifecycleOwner) {
present()
}
ret
}.test<T>(validate = block)
}
@SuppressLint("VisibleForTests")
class FakeLifecycleOwner : LifecycleOwner {
class FakeLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner {
override val lifecycle: Lifecycle = LifecycleRegistry.createUnsafe(this)
init {
initialState?.let { givenState(it) }
}
fun givenState(state: Lifecycle.State) {
(lifecycle as LifecycleRegistry).currentState = state
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d2c2a4712004673371795fd640ed468956839772f0aba9934702ac72c8d608a
size 68313
oid sha256:98b454bc6221fd8c49ebea5ad56f61068a2c4cde74b3d0f1c0525087c19628f5
size 65685

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6e5c2c07576f39ce1ac227625fce1751f260aa663726087ca11f51407988469a
size 70630
oid sha256:e388d6d9cfddaf48d83ee50b09c17a44673830a0e18ec97335acbf454c4ceb32
size 68924

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:250111eb95059839f71392dbfe683e7141825bb901b880ab402a412eb0a2edc0
size 60477

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70f7e53cdeebda6fcbf0b9e542323eff9333f01acb822607b1bfeb784a5a61c2
size 59960

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
size 11704
oid sha256:a532a73c1c1d7c72503ca75f3f9863a3e4e073a0cc780ec05483e5c3001bd405
size 45665

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c
size 12660
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
size 11704

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
size 11704
oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c
size 12660

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
size 7571
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
size 11704

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
size 6528
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
size 7571

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a
size 24543
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
size 6528

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6
size 11013
oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a
size 24543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2
size 18091
oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6
size 11013

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2
size 18091

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
size 11051
oid sha256:af97cc1cda5eab600630181788cbbd7ce6f4db3c18d6f6b5c44519f1a4e0b1e0
size 45622

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536
size 11938
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
size 11051

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
size 11051
oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536
size 11938

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0
size 7501
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
size 11051

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de
size 6299
oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0
size 7501

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56
size 24486
oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de
size 6299

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a
size 10637
oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56
size 24486

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62
size 17091
oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a
size 10637

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62
size 17091

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e3400d75df1c13bde15394e1ab69df7657257bce86b7ac16953142ea7c61389
size 41775

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ba55e0d3271f81d8bcbd6f0a956f50c8e4ed082b23f565357c0c732f2bd7395
size 38805

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:659e2f942ff1ba3aee9e63ec329aac2386e755c04324c3865dae892f67f5ade5
size 38762

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:398dd93176f5bb6058ccd8a3d4257bec7d14ebd2043d11c6b9f0de0108bf9b68
size 42708

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a67e601d851d95f73fe7f6165b91703e87f6c1f71fc6c1d9c614d46ee35623ee
size 39515

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:533db6c347770157ef484b53218885a04f12f732775a94da8b915d700d356256
size 39399

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d23d7fef77a749e30f90e2222d7c0c5dc191aca7c3b94ebf05ed449dfe7c4b5b
size 24276

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b5727b159ca6d4acc82c72befac497a6179b60696184e53f60fc0ec1cd6799b
size 24107

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ab40b06cc3b9e9b1926085160d64919dad9be61dcf504ee8778357db952fca0
size 33062

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f34cf73456894888268d0ee3d75881a11a3efb4c09534d6885f832e20081f57
size 31830