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

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

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

* Fix `IdentityChangePresenter` after the previous changes

* Fix `withFakeLifecycleOwner` and add `testWithLifecycleOwner` helper

* Display verified badge in DM top app bar when possible

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

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

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

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

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

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

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-12 12:22:53 +01:00 committed by GitHub
parent 3112243a8a
commit 96e34cb8ee
75 changed files with 889 additions and 364 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,11 +11,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope 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.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId 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.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UserId 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.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.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient 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.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember 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.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList 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( return RoomDetailsState(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomName,
@ -180,6 +188,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowKnockRequests = canShowKnockRequests, canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount, knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,10 +10,12 @@ package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp 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. * Default Icon content for [ListItem]. Sets the Icon component to a predefined size.
* @param iconSource The icon to display, using [IconSource.getPainter]. * @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. * Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow.
@ -119,7 +122,8 @@ sealed interface ListItemContent {
IconComponent( IconComponent(
modifier = Modifier.size(maxCompactSize), modifier = Modifier.size(maxCompactSize),
painter = iconSource.getPainter(), 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) is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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