Add confirmation dialog when inviting users with unknown identities (#6523)

* feat: Add confirmation modal when inviting unknown users

* tests: Add preview tests for invite confirmation modal

* tests: Add unit tests for invite confirmation modal

* feat: Switch confirmation sheet contents based on identity state

* tests: Add history sharing unit tests for `DefaultStartDMActionTest`

* tests: Update snapshots for `CreateDmConfirmationBottomSheet`

* chore: Fix tiny nits

* fix: Remove default param on `ConfirmingStartDmWithMatrixUser`

* refactor: Use new AsyncAction over boolean flag

* fix: Add sleeps to tests

* refactor: Remove `PromptOrInvite` and switch on async action

* fix: Remove redundant `assertThat`

* feat: Alllow invite confirmation modal to be dismissed

* tests: Update snapshots for InvitePeopleView

* fix: Adjust `CreateDmConfirmationBottomSheet` to conform to design

* feat: Use localazy translations and plurals

* fix: When users are unselected, unselect them in search results too

* tests: Use aMatrixUserList to provide multiple users

* Update screenshots

* fix: Add missing parameter in UserProfilePresenterTest

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Skye Elliot 2026-04-15 10:25:58 +01:00 committed by GitHub
parent e0554bbaf3
commit 897c68e7b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 618 additions and 58 deletions

View file

@ -15,6 +15,8 @@ import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.di.SessionScope
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.core.RoomId
import io.element.android.libraries.matrix.api.room.StartDMResult
@ -26,6 +28,7 @@ import io.element.android.services.analytics.api.AnalyticsService
class DefaultStartDMAction(
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
) : StartDMAction {
override suspend fun execute(
matrixUser: MatrixUser,
@ -44,7 +47,11 @@ class DefaultStartDMAction(
actionState.value = AsyncAction.Failure(result.throwable)
}
StartDMResult.DmDoesNotExist -> {
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
val identityState = matrixClient.encryptionService.getUserIdentity(matrixUser.userId, fallbackToServer = false).getOrNull()
actionState.value = ConfirmingStartDmWithMatrixUser(
matrixUser = matrixUser,
isUserIdentityUnknown = featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite) && identityState == null
)
}
}
}

View file

@ -58,6 +58,8 @@ class StartChatPresenter(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
}.collectAsState(initial = false)
val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false)
fun handleEvent(event: StartChatEvents) {
when (event) {
is StartChatEvents.StartDM -> localCoroutineScope.launch {
@ -76,6 +78,7 @@ class StartChatPresenter(
userListState = userListState,
startDmAction = startDmActionState.value,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
enableKeyShareOnInvite = enableKeyShareOnInvite.value,
eventSink = ::handleEvent,
)
}

View file

@ -17,5 +17,6 @@ data class StartChatState(
val userListState: UserListState,
val startDmAction: AsyncAction<RoomId>,
val isRoomDirectorySearchEnabled: Boolean,
val enableKeyShareOnInvite: Boolean,
val eventSink: (StartChatEvents) -> Unit,
)

View file

@ -16,6 +16,7 @@ import io.element.android.features.startchat.impl.userlist.aUserListState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf
@ -52,7 +53,7 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
)
),
aCreateRoomRootState(
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
startDmAction = aConfirmingStartDmWithMatrixUser()
),
aCreateRoomRootState(
isRoomDirectorySearchEnabled = true,
@ -60,6 +61,16 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
)
}
fun aConfirmingStartDmWithMatrixUser(
matrixUser: MatrixUser = aMatrixUser(),
isUserIdentityUnknown: Boolean = false
): ConfirmingStartDmWithMatrixUser {
return ConfirmingStartDmWithMatrixUser(
matrixUser,
isUserIdentityUnknown
)
}
fun aCreateRoomRootState(
applicationName: String = "Element X Preview",
userListState: UserListState = aUserListState(),
@ -71,5 +82,6 @@ fun aCreateRoomRootState(
userListState = userListState,
startDmAction = startDmAction,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
enableKeyShareOnInvite = false,
eventSink = eventSink,
)

View file

@ -130,6 +130,8 @@ fun StartChatView(
if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = data.isUserIdentityUnknown,
onSendInvite = {
state.eventSink(StartChatEvents.StartDM(data.matrixUser))
},

View file

@ -13,14 +13,21 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -67,7 +74,12 @@ class DefaultStartDMActionTest {
@Test
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
val matrixClient = FakeMatrixClient().apply {
val encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(null) }
)
val matrixClient = FakeMatrixClient(
encryptionService = encryptionService
).apply {
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
@ -76,7 +88,7 @@ class DefaultStartDMActionTest {
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
val matrixUser = aMatrixUser()
action.execute(matrixUser, false, state)
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false))
assertThat(analyticsService.capturedEvents).isEmpty()
}
@ -94,13 +106,38 @@ class DefaultStartDMActionTest {
assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test
fun `when history sharing enabled, user identity fetched and identity unknown`() = runTest {
val getUserIdentityResult = lambdaRecorder<UserId, Result<IdentityState?>> { _ -> Result.success(null) }
val encryptionService = FakeEncryptionService(getUserIdentityResult = getUserIdentityResult)
val matrixClient = FakeMatrixClient(encryptionService = encryptionService).apply {
givenFindDmResult(Result.success(null))
}
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
}
val action = createStartDMAction(
matrixClient = matrixClient,
featureFlagService = featureFlagService
)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), false, state)
assertThat(getUserIdentityResult.assertions().isCalledOnce())
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = true))
}
private fun createStartDMAction(
matrixClient: MatrixClient = FakeMatrixClient(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
): DefaultStartDMAction {
return DefaultStartDMAction(
matrixClient = matrixClient,
analyticsService = analyticsService,
featureFlagService = featureFlagService,
)
}
}

View file

@ -102,7 +102,7 @@ class StartChatPresenterTest {
@Test
fun `present - start DM action confirmation scenario - cancel`() = runTest {
val matrixUser = MatrixUser(UserId("@name:domain"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}
@ -130,7 +130,7 @@ class StartChatPresenterTest {
@Test
fun `present - start DM action confirmation scenario - confirm`() = runTest {
val matrixUser = MatrixUser(UserId("@name:domain"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}