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

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

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

* Fix `IdentityChangePresenter` after the previous changes

* Fix `withFakeLifecycleOwner` and add `testWithLifecycleOwner` helper

* Display verified badge in DM top app bar when possible

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

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

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

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

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

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

* Update screenshots

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,74 +0,0 @@
/*
* Copyright 2025 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.messagecomposer
import androidx.compose.runtime.ProduceStateScope
import io.element.android.features.messages.impl.crypto.identity.IdentityRoomMember
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@OptIn(ExperimentalCoroutinesApi::class)
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}
.launchIn(this)
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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