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 cc15c9e4d1
commit 82cec2016e
30 changed files with 618 additions and 58 deletions

View file

@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations 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.invitepeople.impl
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ConfirmingUnknownUserInvitation(
val users: ImmutableList<MatrixUser>
) : AsyncAction.Confirming

View file

@ -14,4 +14,6 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
data object DismissUnknownUsersModal : DefaultInvitePeopleEvents
data object RemoveUnknownUsers : DefaultInvitePeopleEvents
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -36,8 +37,11 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -50,6 +54,7 @@ import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.launchIn
@ -69,6 +74,7 @@ class DefaultInvitePeoplePresenter(
private val coroutineDispatchers: CoroutineDispatchers,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService,
private val featureFlagService: FeatureFlagService,
private val matrixClient: MatrixClient,
) : InvitePeoplePresenter {
@AssistedFactory
@ -87,6 +93,8 @@ class DefaultInvitePeoplePresenter(
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val enableKeyShareOnInvite by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) {
val activeMemberIds = roomMembers.value.dataOrNull().orEmpty()
@ -126,6 +134,40 @@ class DefaultInvitePeoplePresenter(
}
}
val selectedUserIdentities = produceState(
emptyMap<MatrixUser, IdentityState?>().toImmutableMap(),
selectedUsers.value,
enableKeyShareOnInvite,
) {
if (!enableKeyShareOnInvite) {
return@produceState
}
val selected = selectedUsers.value
val cached = value
.filterKeys { it in selected }
val uncached = selected
.filterNot(cached::containsKey)
.associateWith { user ->
matrixClient.encryptionService
.getUserIdentity(user.userId, fallbackToServer = false)
.getOrNull()
}
value = (cached + uncached).toImmutableMap()
}
val unknownUsers by remember {
derivedStateOf {
selectedUserIdentities.value
.filterValues { it == null }
.keys
.toImmutableList()
}
}
LaunchedEffect(room.isSuccess()) {
room.dataOrNull()?.let {
fetchMembers(it, roomMembers)
@ -144,21 +186,41 @@ class DefaultInvitePeoplePresenter(
fun handleEvent(event: InvitePeopleEvents) {
when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
searchActive = event.active
if (!event.active) {
queryState.clearText()
// Dedicated `when` for exhaustivity.
is DefaultInvitePeopleEvents -> when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
searchActive = event.active
if (!event.active) {
queryState.clearText()
}
}
is DefaultInvitePeopleEvents.ToggleUser -> {
selectedUsers.toggleUser(event.user)
searchResults.toggleUser(event.user)
// suggestions will automatically update via derivedStateOf when selectedUsers changes
}
is DefaultInvitePeopleEvents.DismissUnknownUsersModal -> {
sendInvitesAction.value = AsyncAction.Uninitialized
}
is DefaultInvitePeopleEvents.RemoveUnknownUsers -> {
val usersToRemove = selectedUsers.value.filter { it in unknownUsers }
usersToRemove.forEach { user ->
selectedUsers.toggleUser(user)
searchResults.toggleUser(user)
}
sendInvitesAction.value = AsyncAction.Uninitialized
}
}
is DefaultInvitePeopleEvents.ToggleUser -> {
selectedUsers.toggleUser(event.user)
searchResults.toggleUser(event.user)
// suggestions will automatically update via derivedStateOf when selectedUsers changes
}
is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
if (enableKeyShareOnInvite && unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
sendInvitesAction.value = ConfirmingUnknownUserInvitation(
unknownUsers
)
} else {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
}
}
}
is InvitePeopleEvents.CloseSearch -> {

View file

@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading,
),
aDefaultInvitePeopleState(
sendInvitesAction = ConfirmingUnknownUserInvitation(persistentListOf(
aMatrixUser("@alice:server.org")
))
),
aDefaultInvitePeopleState(
sendInvitesAction = ConfirmingUnknownUserInvitation(
aMatrixUserList().toImmutableList()
)
),
)
}

View file

@ -16,30 +16,42 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
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.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@ -143,6 +155,15 @@ private fun InvitePeopleContentView(
}
}
}
if (state.sendInvitesAction is ConfirmingUnknownUserInvitation) {
InvitePeopleConfirmModal(
users = state.sendInvitesAction.users,
onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvents.DismissUnknownUsersModal) },
onInvite = { state.eventSink.invoke(InvitePeopleEvents.SendInvites) },
onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvents.RemoveUnknownUsers) }
)
}
}
}
@ -230,6 +251,56 @@ private fun InvitePeopleSearchBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InvitePeopleConfirmModal(
users: ImmutableList<MatrixUser>,
onDismiss: () -> Unit,
onInvite: () -> Unit,
onRemove: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
) {
IconTitleSubtitleMolecule(
title = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_title, users.size),
subTitle = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_subtitle, users.size),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
modifier = Modifier.padding(
top = 32.dp,
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
)
)
LazyColumn {
items(users) { user ->
MatrixUserRow(user)
}
}
ButtonRowMolecule(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp)
) {
OutlinedButton(
text = stringResource(CommonStrings.action_remove),
onClick = onRemove,
leadingIcon = IconSource.Vector(CompoundIcons.Close()),
modifier = Modifier.weight(1f)
)
Button(
text = stringResource(CommonStrings.action_invite),
onClick = onInvite,
leadingIcon = IconSource.Vector(CompoundIcons.Check()),
modifier = Modifier.weight(1f)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =