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

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