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:
parent
b0e6b50c79
commit
fd50ce4daf
75 changed files with 889 additions and 364 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue