From fd50ce4daf38664631e39010ee88ee1ff8ef0a67 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 12 Mar 2025 12:22:53 +0100 Subject: [PATCH] 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 --- .../messages/impl/MessagesPresenter.kt | 28 ++- .../features/messages/impl/MessagesState.kt | 4 +- .../messages/impl/MessagesStateProvider.kt | 8 +- .../features/messages/impl/MessagesView.kt | 53 +++-- .../crypto/identity/IdentityChangeState.kt | 7 +- .../identity/IdentityChangeStatePresenter.kt | 2 +- .../identity/IdentityChangeStateProvider.kt | 2 + .../identity/IdentityChangeStateView.kt | 1 + .../crypto/identity/IdentityRoomMember.kt | 17 -- .../messages/impl/MessagesPresenterTest.kt | 191 ++++++++--------- .../identity/IdentityChangeStateViewTest.kt | 2 + .../roomdetails/impl/RoomDetailsPresenter.kt | 9 + .../roomdetails/impl/RoomDetailsState.kt | 1 + .../impl/RoomDetailsStateProvider.kt | 8 + .../roomdetails/impl/RoomDetailsView.kt | 29 ++- .../roomdetails/impl/di/RoomMemberModule.kt | 3 + .../impl/members/RoomMemberListPresenter.kt | 51 ++++- .../impl/members/RoomMemberListState.kt | 12 +- .../members/RoomMemberListStateProvider.kt | 36 +++- .../impl/members/RoomMemberListView.kt | 58 ++++-- .../members/details/RoomMemberDetailsNode.kt | 2 +- .../details/RoomMemberDetailsPresenter.kt | 58 ++++++ .../impl/RoomDetailsPresenterTest.kt | 74 +++---- .../members/RoomMemberListPresenterTest.kt | 10 +- .../details/RoomMemberDetailsPresenterTest.kt | 192 +++++++++++++++++- .../userprofile/api/UserProfileEvents.kt | 1 + .../userprofile/api/UserProfileState.kt | 9 +- .../userprofile/impl/UserProfileFlowNode.kt | 4 +- .../userprofile/impl/root/UserProfileNode.kt | 2 +- .../impl/root/UserProfilePresenter.kt | 13 +- .../impl/UserProfilePresenterTest.kt | 29 +-- .../shared/UserProfileHeaderSection.kt | 64 ++++-- .../shared/UserProfileStateProvider.kt | 10 +- .../userprofile/shared/UserProfileView.kt | 6 +- .../userprofile/UserProfileViewTest.kt | 3 +- .../IncomingVerificationPresenterTest.kt | 3 + .../components/list/ListItemContent.kt | 8 +- .../api/encryption/EncryptionService.kt | 6 + .../impl/encryption/RustEncryptionService.kt | 20 +- .../test/encryption/FakeEncryptionService.kt | 6 + .../ObserveRoomMemberIdentityStateChange.kt | 43 ++-- .../tests/konsist/KonsistPreviewTest.kt | 1 + .../tests/testutils/WithFakeLifecycleOwner.kt | 53 ++++- ...essagesViewWithIdentityChange_Day_2_en.png | 4 +- ...sagesViewWithIdentityChange_Night_2_en.png | 4 +- ...s.messages.impl_MessagesView_Day_12_en.png | 3 + ...messages.impl_MessagesView_Night_12_en.png | 3 + ...pl.members_RoomMemberListView_Day_1_en.png | 4 +- ...pl.members_RoomMemberListView_Day_2_en.png | 4 +- ...pl.members_RoomMemberListView_Day_3_en.png | 4 +- ...pl.members_RoomMemberListView_Day_4_en.png | 4 +- ...pl.members_RoomMemberListView_Day_5_en.png | 4 +- ...pl.members_RoomMemberListView_Day_6_en.png | 4 +- ...pl.members_RoomMemberListView_Day_7_en.png | 4 +- ...pl.members_RoomMemberListView_Day_8_en.png | 4 +- ...pl.members_RoomMemberListView_Day_9_en.png | 3 + ....members_RoomMemberListView_Night_1_en.png | 4 +- ....members_RoomMemberListView_Night_2_en.png | 4 +- ....members_RoomMemberListView_Night_3_en.png | 4 +- ....members_RoomMemberListView_Night_4_en.png | 4 +- ....members_RoomMemberListView_Night_5_en.png | 4 +- ....members_RoomMemberListView_Night_6_en.png | 4 +- ....members_RoomMemberListView_Night_7_en.png | 4 +- ....members_RoomMemberListView_Night_8_en.png | 4 +- ....members_RoomMemberListView_Night_9_en.png | 3 + ...roomdetails.impl_RoomDetailsDark_16_en.png | 3 + ...roomdetails.impl_RoomDetailsDark_17_en.png | 3 + ...roomdetails.impl_RoomDetailsDark_18_en.png | 3 + ...res.roomdetails.impl_RoomDetails_16_en.png | 3 + ...res.roomdetails.impl_RoomDetails_17_en.png | 3 + ...res.roomdetails.impl_RoomDetails_18_en.png | 3 + ...tionWithVerificationViolation_Day_0_en.png | 3 + ...onWithVerificationViolation_Night_0_en.png | 3 + ...rofile.shared_UserProfileView_Day_9_en.png | 3 + ...file.shared_UserProfileView_Night_9_en.png | 3 + 75 files changed, 889 insertions(+), 364 deletions(-) delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt => libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt (69%) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_16_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_17_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_18_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_16_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_17_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_18_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_9_en.png diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6b1f50c41d..879ad45eb9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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 { @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(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) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 61f092eaf9..4e6fe3cc94 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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, val userEventPermissions: UserEventPermissions, val composerState: MessageComposerState, - val roomMemberIdentityStateChanges: ImmutableList, 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 ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index a73a4c1565..5086823875 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -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 { 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 = 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, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index a7d312b816..8e812275a5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, 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 = { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt index 627c304bad..0ff989fe70 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt @@ -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, val eventSink: (IdentityChangeEvent) -> Unit, ) - -data class RoomMemberIdentityStateChange( - val identityRoomMember: IdentityRoomMember, - val identityState: IdentityState, -) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt index 4aba5dc440..4e37f34559 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt index f64f505f0f..858231ebd5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt @@ -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 { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt index 8362515b6a..464cb59592 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt deleted file mode 100644 index 536a1072a7..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt +++ /dev/null @@ -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, -) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 20b57fa343..a0ddc6bbc9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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() 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, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt index ca07cc93cd..4f67049f98 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt @@ -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 diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 1bf44730e2..631158f3bb 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -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, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 8a9401d5f0..5502d4e29a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -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 { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 597f9517b9..b2db46115c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -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 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, ) ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 10abb2e86a..6e1ddef204 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -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, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt index e6473eb4c3..4001eb7edb 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt @@ -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, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 4e9ceb5bf3..89f82319c5 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -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, + private val encryptionService: EncryptionService, @Assisted private val navigator: RoomMemberListNavigator, ) : Presenter { @AssistedFactory @@ -60,12 +72,20 @@ class RoomMemberListPresenter @AssistedInject constructor( val roomModerationState = roomMembersModerationPresenter.present() + val roomMemberIdentityStates by produceState(persistentMapOf()) { + 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): RoomMemberWithIdentityState { + return if (!room.isEncrypted) { + RoomMemberWithIdentityState(this, null) + } else { + val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId).getOrNull() + RoomMemberWithIdentityState(this, identityState) + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index c552dda52e..b5fa6c37a9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -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, - val joined: ImmutableList, - val banned: ImmutableList, + val invited: ImmutableList, + val joined: ImmutableList, + val banned: ImmutableList, +) + +data class RoomMemberWithIdentityState( + val roomMember: RoomMember, + val identityState: IdentityState?, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index f917000154..6fec01c675 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -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 String)?, - members: ImmutableList?, + members: ImmutableList?, 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, + ) + } } } ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index 28698b9451..6a1af42694 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -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() diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index b2ec81b0c0..4e293395bc 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -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 { 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(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) + } + } ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 1eec78ea9c..610843b7c6 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -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.() -> 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() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt index 0200b8116d..91a6ba6683 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt @@ -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, ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt index 95e3b47ce3..a2c8253106 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt @@ -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> { 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, ) } } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt index 77917e3106..b7b7ba2561 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt @@ -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 } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt index 5d3ae9c132..f32033b0a7 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -16,7 +16,7 @@ data class UserProfileState( val userId: UserId, val userName: String?, val avatarUrl: String?, - val isVerified: AsyncData, + val verificationState: UserProfileVerificationState, val isBlocked: AsyncData, val startDmActionState: AsyncAction, val displayConfirmationDialog: ConfirmationDialog?, @@ -30,3 +30,10 @@ data class UserProfileState( Unblock } } + +enum class UserProfileVerificationState { + UNKNOWN, + VERIFIED, + UNVERIFIED, + VERIFICATION_VIOLATION, +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt index c09b582e6b..9f40fa4f6d 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -66,6 +66,8 @@ class UserProfileFlowNode @AssistedInject constructor( data class VerifyUser(val userId: UserId) : NavTarget } + private val inputs = inputs() + 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().userId) + val params = UserProfileNode.UserProfileInputs(userId = inputs.userId) createNode(buildContext, listOf(callback, params)) } is NavTarget.AvatarPreview -> { diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt index af2e08212c..f10fd48a04 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt @@ -42,7 +42,7 @@ class UserProfileNode @AssistedInject constructor( private val inputs = inputs() private val callback = inputs() - private val presenter = presenterFactory.create(inputs.userId) + private val presenter = presenterFactory.create(userId = inputs.userId) private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId) init { diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index a3378fee31..4216287a70 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -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(null) } val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } - val isVerified: MutableState> = 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(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, diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 98140fdd00..5a843bad81 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -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 ReceiveTurbine.awaitFirstItem(): T { - skipItems(2) + skipItems(1) return awaitItem() } private fun createFakeMatrixClient( - isUserVerified: Boolean = false, + isUserVerified: Boolean = true, + userIdentityState: IdentityState? = null, ignoreUserResult: (UserId) -> Result = { Result.success(Unit) }, unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) }, ignoredUsersFlow: StateFlow> = 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, ) } } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt index c8755aac56..3df57b9eb0 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -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, + 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 = {}, ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index a53f53c48c..be9fad9c94 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -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 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 = AsyncData.Success(false), - isVerified: AsyncData = AsyncData.Success(false), + verificationState: UserProfileVerificationState = UserProfileVerificationState.UNVERIFIED, startDmActionState: AsyncAction = 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, diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 68ef207184..0c544cf659 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -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())), diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt index 9a14a20221..0e614e53d7 100644 --- a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt @@ -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) diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt index 6e866b13af..0f453be2bc 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -187,6 +187,9 @@ class IncomingVerificationPresenterTest { fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel) // The screen is dismissed skipItems(2) + + advanceUntilIdle() + onFinishLambda.assertions().isCalledOnce() } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt index 651bcd5f5c..54d1ea738e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt @@ -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) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index a1a4eb9b1c..1635cce375 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -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 + + /** + * Get the identity state of a user, if known. + */ + suspend fun getUserIdentity(userId: UserId): Result } /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index 7864c8c94f..cfc01692d9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -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 = runCatching { - getUserIdentity(userId).isVerified() + getUserIdentityInternal(userId).isVerified() } override suspend fun pinUserIdentity(userId: UserId): Result = runCatching { - getUserIdentity(userId).pin() + getUserIdentityInternal(userId).pin() } override suspend fun withdrawVerification(userId: UserId): Result = runCatching { - getUserIdentity(userId).withdrawVerification() + getUserIdentityInternal(userId).withdrawVerification() } - private suspend fun getUserIdentity(userId: UserId): UserIdentity { + override suspend fun getUserIdentity(userId: UserId): Result = 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, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 7d56ab3d7e..25e8a535b0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -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 = { lambdaError() }, private val isUserVerifiedResult: (UserId) -> Result = { lambdaError() }, private val withdrawVerificationResult: (UserId) -> Result = { lambdaError() }, + private val getUserIdentityResult: (UserId) -> Result = { lambdaError() } ) : EncryptionService { private var disableRecoveryFailure: Exception? = null override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) @@ -133,6 +135,10 @@ class FakeEncryptionService( isUserVerifiedResult(userId) } + override suspend fun getUserIdentity(userId: UserId): Result = simulateLongTask { + return getUserIdentityResult(userId) + } + companion object { const val FAKE_RECOVERY_KEY = "fake" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt similarity index 69% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt index 48230840b1..2549bba2d9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt @@ -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>.observeRoomMemberIdentityStateChange(room: MatrixRoom) { - room.syncUpdateFlow +fun MatrixRoom.roomMemberIdentityStateChange(): Flow> { + 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>.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, +) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 4fdd4f8f90..4426d73844 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -129,6 +129,7 @@ class KonsistPreviewTest { "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", "UserAvatarColorsPreview", + "UserProfileHeaderSectionWithVerificationViolationPreview", "VoiceItemViewPlayPreview", ) .assertTrue( diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WithFakeLifecycleOwner.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WithFakeLifecycleOwner.kt index caafe14dc2..16d4cfb2fd 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WithFakeLifecycleOwner.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WithFakeLifecycleOwner.kt @@ -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 withFakeLifecycleOwner(lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), block: @Composable () -> T): T { - var state: T? by remember { mutableStateOf(null) } - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { - state = block() - } - return state!! +fun 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 Presenter.testWithLifecycleOwner( + lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), + block: suspend TurbineTestContext.() -> Unit +) { + moleculeFlow(RecompositionMode.Immediate) { + val ret = withFakeLifecycleOwner(lifecycleOwner) { + present() + } + ret + }.test(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 } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png index 6598a69e5c..d8be159c65 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d2c2a4712004673371795fd640ed468956839772f0aba9934702ac72c8d608a -size 68313 +oid sha256:98b454bc6221fd8c49ebea5ad56f61068a2c4cde74b3d0f1c0525087c19628f5 +size 65685 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png index 7e8d6d00f7..86091e0911 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e5c2c07576f39ce1ac227625fce1751f260aa663726087ca11f51407988469a -size 70630 +oid sha256:e388d6d9cfddaf48d83ee50b09c17a44673830a0e18ec97335acbf454c4ceb32 +size 68924 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png new file mode 100644 index 0000000000..6c713d6e9f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:250111eb95059839f71392dbfe683e7141825bb901b880ab402a412eb0a2edc0 +size 60477 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png new file mode 100644 index 0000000000..2f7ac62092 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70f7e53cdeebda6fcbf0b9e542323eff9333f01acb822607b1bfeb784a5a61c2 +size 59960 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png index 9627b16e6c..280fedca98 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4 -size 11704 +oid sha256:a532a73c1c1d7c72503ca75f3f9863a3e4e073a0cc780ec05483e5c3001bd405 +size 45665 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png index 507709b1b4..9627b16e6c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c -size 12660 +oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4 +size 11704 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_3_en.png index 9627b16e6c..507709b1b4 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4 -size 11704 +oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c +size 12660 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png index 92d9db2f11..9627b16e6c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c -size 7571 +oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4 +size 11704 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_5_en.png index fdb10220a2..92d9db2f11 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748 -size 6528 +oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c +size 7571 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_6_en.png index 942808435a..fdb10220a2 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a -size 24543 +oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748 +size 6528 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_7_en.png index 3c8281a03c..942808435a 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6 -size 11013 +oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a +size 24543 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png index abe89bd9a2..3c8281a03c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2 -size 18091 +oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6 +size 11013 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_9_en.png new file mode 100644 index 0000000000..abe89bd9a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2 +size 18091 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png index 46b47ed015..3d7fac8294 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45 -size 11051 +oid sha256:af97cc1cda5eab600630181788cbbd7ce6f4db3c18d6f6b5c44519f1a4e0b1e0 +size 45622 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png index dddb67bec9..46b47ed015 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536 -size 11938 +oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45 +size 11051 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_3_en.png index 46b47ed015..dddb67bec9 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45 -size 11051 +oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536 +size 11938 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png index 0fbe95ff70..46b47ed015 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0 -size 7501 +oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45 +size 11051 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_5_en.png index 228b02c0a9..0fbe95ff70 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de -size 6299 +oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0 +size 7501 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_6_en.png index 3473f077b5..228b02c0a9 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56 -size 24486 +oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de +size 6299 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_7_en.png index 39750c36e6..3473f077b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a -size 10637 +oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56 +size 24486 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png index c01e5b8a0a..39750c36e6 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62 -size 17091 +oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a +size 10637 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_9_en.png new file mode 100644 index 0000000000..c01e5b8a0a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62 +size 17091 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_16_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_16_en.png new file mode 100644 index 0000000000..85f39a65e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e3400d75df1c13bde15394e1ab69df7657257bce86b7ac16953142ea7c61389 +size 41775 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_17_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_17_en.png new file mode 100644 index 0000000000..fbfb4d1992 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ba55e0d3271f81d8bcbd6f0a956f50c8e4ed082b23f565357c0c732f2bd7395 +size 38805 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_18_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_18_en.png new file mode 100644 index 0000000000..f15bd024a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:659e2f942ff1ba3aee9e63ec329aac2386e755c04324c3865dae892f67f5ade5 +size 38762 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_16_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_16_en.png new file mode 100644 index 0000000000..b64c6af3d4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:398dd93176f5bb6058ccd8a3d4257bec7d14ebd2043d11c6b9f0de0108bf9b68 +size 42708 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_17_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_17_en.png new file mode 100644 index 0000000000..1d721d5c87 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a67e601d851d95f73fe7f6165b91703e87f6c1f71fc6c1d9c614d46ee35623ee +size 39515 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_18_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_18_en.png new file mode 100644 index 0000000000..552e6b96c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:533db6c347770157ef484b53218885a04f12f732775a94da8b915d700d356256 +size 39399 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en.png new file mode 100644 index 0000000000..b467353a6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d23d7fef77a749e30f90e2222d7c0c5dc191aca7c3b94ebf05ed449dfe7c4b5b +size 24276 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en.png new file mode 100644 index 0000000000..674d4f8fb2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b5727b159ca6d4acc82c72befac497a6179b60696184e53f60fc0ec1cd6799b +size 24107 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_9_en.png new file mode 100644 index 0000000000..cfea88a8e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ab40b06cc3b9e9b1926085160d64919dad9be61dcf504ee8778357db952fca0 +size 33062 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_9_en.png new file mode 100644 index 0000000000..912e7ac5bf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f34cf73456894888268d0ee3d75881a11a3efb4c09534d6885f832e20081f57 +size 31830