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

@ -37,10 +37,12 @@ dependencies {
implementation(projects.libraries.usersearch.api) implementation(projects.libraries.usersearch.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.services.apperror.api) implementation(projects.services.apperror.api)
implementation(projects.libraries.featureflag.api)
api(projects.features.invitepeople.api) api(projects.features.invitepeople.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.services.apperror.test) testImplementation(projects.services.apperror.test)
testImplementation(projects.libraries.featureflag.test)
} }

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 { sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -69,6 +74,7 @@ class DefaultInvitePeoplePresenter(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService, private val appErrorStateService: AppErrorStateService,
private val featureFlagService: FeatureFlagService,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
) : InvitePeoplePresenter { ) : InvitePeoplePresenter {
@AssistedFactory @AssistedFactory
@ -87,6 +93,8 @@ class DefaultInvitePeoplePresenter(
val showSearchLoader = rememberSaveable { mutableStateOf(false) } val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } 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) { val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) { if (roomMembers.value.isSuccess()) {
val activeMemberIds = roomMembers.value.dataOrNull().orEmpty() 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()) { LaunchedEffect(room.isSuccess()) {
room.dataOrNull()?.let { room.dataOrNull()?.let {
fetchMembers(it, roomMembers) fetchMembers(it, roomMembers)
@ -144,21 +186,41 @@ class DefaultInvitePeoplePresenter(
fun handleEvent(event: InvitePeopleEvents) { fun handleEvent(event: InvitePeopleEvents) {
when (event) { when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> { // Dedicated `when` for exhaustivity.
searchActive = event.active is DefaultInvitePeopleEvents -> when (event) {
if (!event.active) { is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
queryState.clearText() 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 -> { is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let { if (enableKeyShareOnInvite && unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction) sendInvitesAction.value = ConfirmingUnknownUserInvitation(
unknownUsers
)
} else {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
}
} }
} }
is InvitePeopleEvents.CloseSearch -> { is InvitePeopleEvents.CloseSearch -> {

View file

@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading, 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.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.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.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.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser 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.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData 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.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName 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 @PreviewsDayNight
@Composable @Composable
internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) = internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =

View file

@ -15,9 +15,13 @@ import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
@ -28,6 +32,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -43,6 +48,7 @@ import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.apperror.api.AppErrorStateService import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -56,6 +62,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
internal class DefaultInvitePeoplePresenterTest { internal class DefaultInvitePeoplePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -605,6 +612,231 @@ internal class DefaultInvitePeoplePresenterTest {
} }
} }
@Test
fun `present - users are prompted for confirmation if they attempt to invite unknown users`() = runTest {
val alice = aMatrixUser("@alice:example.com")
val bob = aMatrixUser("@bob:example.com")
val charlie = aMatrixUser("@charlie:example.com")
val getUserIdentityResult = lambdaRecorder<UserId, Result<IdentityState?>> { userId ->
when (userId.value) {
alice.userId.value -> Result.success(IdentityState.Pinned)
bob.userId.value -> Result.success(null)
else -> Result.failure(AN_EXCEPTION)
}
}
val inviteUserResult = lambdaRecorder<UserId, Result<Unit>> { userId: UserId ->
Result.success(Unit)
}
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
}
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
inviteUserResult = inviteUserResult,
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItem()
skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie))
delay(100)
// If we do not have their identity cached, or fail to fetch it, we should mark them as unknown.
awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
eventSink(InvitePeopleEvents.SendInvites)
}
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
listOf(value(alice.userId)),
listOf(value(bob.userId)),
listOf(value(charlie.userId))
)
// When we then try to invite these users, we should prompt for confirmation first.
awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue()
eventSink(InvitePeopleEvents.SendInvites)
}
delay(1_000)
inviteUserResult.assertions().isCalledExactly(3).withSequence(
listOf(value(alice.userId)),
listOf(value(bob.userId)),
listOf(value(charlie.userId))
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - selecting remove on confirmation prompt unselects unknown users`() = runTest {
val alice = aMatrixUser("@alice:example.com")
val bob = aMatrixUser("@bob:example.com")
val charlie = aMatrixUser("@charlie:example.com")
val repository = FakeUserRepository()
val getUserIdentityResult = lambdaRecorder<UserId, Result<IdentityState?>> { userId ->
when (userId.value) {
alice.userId.value -> Result.success(IdentityState.Pinned)
bob.userId.value -> Result.success(null)
else -> Result.failure(AN_EXCEPTION)
}
}
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
}
val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItemAsDefault()
skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie))
delay(100)
// And the search is matching Alice and Bob
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(
UserSearchResultState(
results = listOf(UserSearchResult(alice), UserSearchResult(bob)),
isSearching = true
)
)
skipItems(3)
awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
// Both Alice and Bob are selected in searchResults
assertThat(
searchResults.users().map { Pair(it.matrixUser, it.isSelected) }
).containsExactly(Pair(alice, true), Pair(bob, true))
eventSink(InvitePeopleEvents.SendInvites)
}
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
listOf(value(alice.userId)),
listOf(value(bob.userId)),
listOf(value(charlie.userId))
)
// When we then try to invite these user, we should prompt for confirmation first.
awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue()
eventSink(DefaultInvitePeopleEvents.RemoveUnknownUsers)
}
// Selecting "remove" should remove all unknown users, but keeps those who are known.
(awaitLastSequentialItem() as DefaultInvitePeopleState).run {
assertThat(sendInvitesAction.isUninitialized()).isTrue()
assertThat(selectedUsers).containsExactly(alice)
// Bob is no longer selected in searchResults
assertThat(
searchResults.users().map { Pair(it.matrixUser, it.isSelected) }
).containsExactly(Pair(alice, true), Pair(bob, false))
}
}
}
@Test
fun `present - dismissing confirmation prompt does not affect selection`() = runTest {
val alice = aMatrixUser("@alice:example.com")
val bob = aMatrixUser("@bob:example.com")
val charlie = aMatrixUser("@charlie:example.com")
val getUserIdentityResult = lambdaRecorder<UserId, Result<IdentityState?>> { userId ->
when (userId.value) {
alice.userId.value -> Result.success(IdentityState.Pinned)
bob.userId.value -> Result.success(null)
else -> Result.failure(AN_EXCEPTION)
}
}
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
}
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItem()
skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob))
delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie))
delay(100)
awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
eventSink(InvitePeopleEvents.SendInvites)
}
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
listOf(value(alice.userId)),
listOf(value(bob.userId)),
listOf(value(charlie.userId))
)
// When we then try to invite these user, we should prompt for confirmation first.
awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue()
eventSink(DefaultInvitePeopleEvents.DismissUnknownUsersModal)
}
// Dismissing should not modify the selection at all
(awaitLastSequentialItem() as DefaultInvitePeopleState).run {
assertThat(sendInvitesAction.isUninitialized()).isTrue()
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
}
}
}
private suspend fun FakeUserRepository.emitStateWithUsers( private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>, users: List<MatrixUser>,
isSearching: Boolean = false isSearching: Boolean = false
@ -646,6 +878,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
userRepository: UserRepository = FakeUserRepository(), userRepository: UserRepository = FakeUserRepository(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(), appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
): DefaultInvitePeoplePresenter { ): DefaultInvitePeoplePresenter {
return DefaultInvitePeoplePresenter( return DefaultInvitePeoplePresenter(
@ -655,6 +888,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
appErrorStateService = appErrorStateService, appErrorStateService = appErrorStateService,
featureFlagService = featureFlagService,
matrixClient = matrixClient, matrixClient = matrixClient,
) )
} }

View file

@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
data class ConfirmingStartDmWithMatrixUser( data class ConfirmingStartDmWithMatrixUser(
val matrixUser: MatrixUser, val matrixUser: MatrixUser,
val isUserIdentityUnknown: Boolean,
) : AsyncAction.Confirming ) : AsyncAction.Confirming

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.features.startchat.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.di.SessionScope 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.StartDMResult import io.element.android.libraries.matrix.api.room.StartDMResult
@ -26,6 +28,7 @@ import io.element.android.services.analytics.api.AnalyticsService
class DefaultStartDMAction( class DefaultStartDMAction(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
) : StartDMAction { ) : StartDMAction {
override suspend fun execute( override suspend fun execute(
matrixUser: MatrixUser, matrixUser: MatrixUser,
@ -44,7 +47,11 @@ class DefaultStartDMAction(
actionState.value = AsyncAction.Failure(result.throwable) actionState.value = AsyncAction.Failure(result.throwable)
} }
StartDMResult.DmDoesNotExist -> { 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) featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false)
fun handleEvent(event: StartChatEvents) { fun handleEvent(event: StartChatEvents) {
when (event) { when (event) {
is StartChatEvents.StartDM -> localCoroutineScope.launch { is StartChatEvents.StartDM -> localCoroutineScope.launch {
@ -76,6 +78,7 @@ class StartChatPresenter(
userListState = userListState, userListState = userListState,
startDmAction = startDmActionState.value, startDmAction = startDmActionState.value,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
enableKeyShareOnInvite = enableKeyShareOnInvite.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -17,5 +17,6 @@ data class StartChatState(
val userListState: UserListState, val userListState: UserListState,
val startDmAction: AsyncAction<RoomId>, val startDmAction: AsyncAction<RoomId>,
val isRoomDirectorySearchEnabled: Boolean, val isRoomDirectorySearchEnabled: Boolean,
val enableKeyShareOnInvite: Boolean,
val eventSink: (StartChatEvents) -> Unit, 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.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.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.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -52,7 +53,7 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
) )
), ),
aCreateRoomRootState( aCreateRoomRootState(
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()), startDmAction = aConfirmingStartDmWithMatrixUser()
), ),
aCreateRoomRootState( aCreateRoomRootState(
isRoomDirectorySearchEnabled = true, 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( fun aCreateRoomRootState(
applicationName: String = "Element X Preview", applicationName: String = "Element X Preview",
userListState: UserListState = aUserListState(), userListState: UserListState = aUserListState(),
@ -71,5 +82,6 @@ fun aCreateRoomRootState(
userListState = userListState, userListState = userListState,
startDmAction = startDmAction, startDmAction = startDmAction,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
enableKeyShareOnInvite = false,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -130,6 +130,8 @@ fun StartChatView(
if (data is ConfirmingStartDmWithMatrixUser) { if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet( CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser, matrixUser = data.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = data.isUserIdentityUnknown,
onSendInvite = { onSendInvite = {
state.eventSink(StartChatEvents.StartDM(data.matrixUser)) 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 im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.libraries.architecture.AsyncAction 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -67,7 +74,12 @@ class DefaultStartDMActionTest {
@Test @Test
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest { 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)) givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID)) givenCreateDmResult(Result.success(A_ROOM_ID))
} }
@ -76,7 +88,7 @@ class DefaultStartDMActionTest {
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
val matrixUser = aMatrixUser() val matrixUser = aMatrixUser()
action.execute(matrixUser, false, state) action.execute(matrixUser, false, state)
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser)) assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false))
assertThat(analyticsService.capturedEvents).isEmpty() assertThat(analyticsService.capturedEvents).isEmpty()
} }
@ -94,13 +106,38 @@ class DefaultStartDMActionTest {
assertThat(analyticsService.capturedEvents).isEmpty() 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( private fun createStartDMAction(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
): DefaultStartDMAction { ): DefaultStartDMAction {
return DefaultStartDMAction( return DefaultStartDMAction(
matrixClient = matrixClient, matrixClient = matrixClient,
analyticsService = analyticsService, analyticsService = analyticsService,
featureFlagService = featureFlagService,
) )
} }
} }

View file

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

View file

@ -26,6 +26,7 @@ data class UserProfileState(
val dmRoomId: RoomId?, val dmRoomId: RoomId?,
val canCall: Boolean, val canCall: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val enableKeyShareOnInvite: Boolean,
val eventSink: (UserProfileEvents) -> Unit val eventSink: (UserProfileEvents) -> Unit
) { ) {
enum class ConfirmationDialog { enum class ConfirmationDialog {

View file

@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.verifysession.api) implementation(projects.features.verifysession.api)
@ -46,6 +47,7 @@ dependencies {
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.features.verifysession.test) testImplementation(projects.features.verifysession.test)
testImplementation(projects.features.startchat.test) testImplementation(projects.features.startchat.test)

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@ -31,6 +32,8 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
@ -50,6 +53,7 @@ class UserProfilePresenter(
private val client: MatrixClient, private val client: MatrixClient,
private val startDMAction: StartDMAction, private val startDMAction: StartDMAction,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
private val featureFlagService: FeatureFlagService,
) : Presenter<UserProfileState> { ) : Presenter<UserProfileState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -101,6 +105,8 @@ class UserProfilePresenter(
} }
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() } val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false)
fun handleEvent(event: UserProfileEvents) { fun handleEvent(event: UserProfileEvents) {
when (event) { when (event) {
is UserProfileEvents.BlockUser -> { is UserProfileEvents.BlockUser -> {
@ -153,6 +159,7 @@ class UserProfilePresenter(
dmRoomId = dmRoomId, dmRoomId = dmRoomId,
canCall = canCall, canCall = canCall,
snackbarMessage = null, snackbarMessage = null,
enableKeyShareOnInvite = enableKeyShareOnInvite.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -24,6 +24,7 @@ import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -324,7 +325,7 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - start DM action confirmation scenario - cancel`() = runTest { fun `present - start DM action confirmation scenario - cancel`() = runTest {
val matrixUser = MatrixUser(UserId("@alice:server.org")) val matrixUser = MatrixUser(UserId("@alice:server.org"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, false)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState -> val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult actionState.value = startDMConfirmationResult
} }
@ -354,7 +355,7 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - start DM action confirmation scenario - confirm`() = runTest { fun `present - start DM action confirmation scenario - confirm`() = runTest {
val matrixUser = MatrixUser(UserId("@alice:server.org")) val matrixUser = MatrixUser(UserId("@alice:server.org"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, false)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState -> val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult actionState.value = startDMConfirmationResult
} }
@ -414,6 +415,7 @@ class UserProfilePresenterTest {
sessionEnterpriseService = FakeSessionEnterpriseService( sessionEnterpriseService = FakeSessionEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable }, isElementCallAvailableResult = { isElementCallAvailable },
), ),
featureFlagService = FakeFeatureFlagService()
) )
} }
} }

View file

@ -31,7 +31,7 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN), aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN),
aUserProfileState(startDmActionState = AsyncAction.Loading), aUserProfileState(startDmActionState = AsyncAction.Loading),
aUserProfileState(canCall = true), aUserProfileState(canCall = true),
aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())), aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = false)),
aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION), aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
) )
} }
@ -61,5 +61,6 @@ fun aUserProfileState(
dmRoomId = dmRoomId, dmRoomId = dmRoomId,
canCall = canCall, canCall = canCall,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
enableKeyShareOnInvite = false,
eventSink = eventSink, eventSink = eventSink,
) )

View file

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

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
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.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -33,6 +38,7 @@ 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.Button
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet 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.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.R import io.element.android.libraries.matrix.ui.R
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun CreateDmConfirmationBottomSheet( fun CreateDmConfirmationBottomSheet(
matrixUser: MatrixUser, matrixUser: MatrixUser,
enableKeyShareOnInvite: Boolean,
isUserIdentityUnknown: Boolean,
onSendInvite: () -> Unit, onSendInvite: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_title)
}
val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName())
}
ModalBottomSheet( ModalBottomSheet(
modifier = modifier, modifier = modifier,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), .padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar( if (isUserIdentityUnknown) {
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation), IconTitleSubtitleMolecule(
avatarType = AvatarType.User, modifier = Modifier.padding(
) bottom = 16.dp,
Spacer(modifier = Modifier.height(16.dp)) start = 16.dp,
Text( end = 16.dp,
text = stringResource(R.string.screen_bottom_sheet_create_dm_title), ),
style = ElementTheme.typography.fontHeadingMdBold, title = titleContent,
color = ElementTheme.colors.textPrimary, subTitle = descriptionContent,
textAlign = TextAlign.Center, iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
) )
Spacer(modifier = Modifier.height(8.dp)) MatrixUserRow(matrixUser)
Text( Spacer(modifier = Modifier.height(32.dp))
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()), ButtonRowMolecule(
style = ElementTheme.typography.fontBodyMdRegular, horizontalArrangement = Arrangement.spacedBy(16.dp),
color = ElementTheme.colors.textSecondary, ) {
textAlign = TextAlign.Center, OutlinedButton(
) modifier = Modifier.weight(1f),
Spacer(modifier = Modifier.height(40.dp)) text = stringResource(CommonStrings.action_cancel),
Button( onClick = onDismiss
modifier = Modifier.fillMaxWidth(), )
onClick = onSendInvite, Button(
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()), modifier = Modifier.weight(1f),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title), text = stringResource(CommonStrings.action_continue),
) onClick = onSendInvite
Spacer(modifier = Modifier.height(16.dp)) )
TextButton( }
modifier = Modifier.fillMaxWidth(), Spacer(modifier = Modifier.height(32.dp))
onClick = onDismiss, } else {
text = stringResource(CommonStrings.action_cancel), Avatar(
) avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = titleContent,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = descriptionContent,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
}
} }
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
CreateDmConfirmationBottomSheetStateProvider::class
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
CreateDmConfirmationBottomSheet( CreateDmConfirmationBottomSheet(
matrixUser = matrixUser, matrixUser = state.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = state.isUserIdentityUnknown,
onSendInvite = {}, onSendInvite = {},
onDismiss = {}, onDismiss = {},
) )
} }
data class CreateDmConfirmationBottomSheetState(
val matrixUser: MatrixUser,
val enableKeyShareOnInvite: Boolean,
val isUserIdentityUnknown: Boolean,
)
class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider<CreateDmConfirmationBottomSheetState> {
override val values = sequenceOf(
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true),
)
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8ee76c2369a9671cbe370f367718fcda5bb08a89ed5116accc96928a64e9724
size 55689

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:029b19808dd54b74ef30737242a10af31b80e579b313d001dd9fa377bc2cca58
size 58319

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21a24fade9819efdb9114ec0ba3db21ec87cf93e32d896e22117fcd4f23e07ce
size 53601

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eca54577cffddb66921623ede7ab39e017f5cd95e5049d6ad2763fa4f1f88ad4
size 58133

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c5a4487507334ec43c9d659f57f2ec0d86856d941f8b1b437c101b696a5b49d oid sha256:bb4d6bfb9c412de00a2b4956032dd42906b5451eb99e6ebb1880dc01f6b55af5
size 24223 size 26077

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26bf76ccdb56d042422553f557d91d0f26d874a710f696ac106c5c2b5590d332
size 38833

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ab0ba9a693ede4106d09170710f215bccfd82dbfbadfdafa5fb49fe39a03c25d oid sha256:b8c422787b67d477d3b7c8d5dee8879f33d47153dc93dd29bb3883e4ed863a41
size 23471 size 25232

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8413aed02383572cfe8c481c6ba8b0db4cfb3402334c37f2d8b54a73fe4bf594
size 37343