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

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