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,
),
)