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:
parent
cc15c9e4d1
commit
82cec2016e
30 changed files with 618 additions and 58 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -15,9 +15,13 @@ import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
|||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
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.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.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
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_2
|
||||
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.FakeJoinedRoom
|
||||
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.test.FakeAppErrorStateService
|
||||
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.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
|
|
@ -56,6 +62,7 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
internal class DefaultInvitePeoplePresenterTest {
|
||||
@get:Rule
|
||||
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(
|
||||
users: List<MatrixUser>,
|
||||
isSearching: Boolean = false
|
||||
|
|
@ -646,6 +878,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
|
|||
userRepository: UserRepository = FakeUserRepository(),
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
): DefaultInvitePeoplePresenter {
|
||||
return DefaultInvitePeoplePresenter(
|
||||
|
|
@ -655,6 +888,7 @@ fun TestScope.createDefaultInvitePeoplePresenter(
|
|||
coroutineDispatchers = coroutineDispatchers,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
appErrorStateService = appErrorStateService,
|
||||
featureFlagService = featureFlagService,
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue