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
e0554bbaf3
commit
897c68e7b7
30 changed files with 618 additions and 58 deletions
|
|
@ -37,10 +37,12 @@ dependencies {
|
|||
implementation(projects.libraries.usersearch.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.services.apperror.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
api(projects.features.invitepeople.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.services.apperror.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
|||
|
||||
data class ConfirmingStartDmWithMatrixUser(
|
||||
val matrixUser: MatrixUser,
|
||||
val isUserIdentityUnknown: Boolean,
|
||||
) : AsyncAction.Confirming
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
|
|||
import io.element.android.features.startchat.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.StartDMResult
|
||||
|
|
@ -26,6 +28,7 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||
class DefaultStartDMAction(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : StartDMAction {
|
||||
override suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
|
|
@ -44,7 +47,11 @@ class DefaultStartDMAction(
|
|||
actionState.value = AsyncAction.Failure(result.throwable)
|
||||
}
|
||||
StartDMResult.DmDoesNotExist -> {
|
||||
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
|
||||
val identityState = matrixClient.encryptionService.getUserIdentity(matrixUser.userId, fallbackToServer = false).getOrNull()
|
||||
actionState.value = ConfirmingStartDmWithMatrixUser(
|
||||
matrixUser = matrixUser,
|
||||
isUserIdentityUnknown = featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite) && identityState == null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ class StartChatPresenter(
|
|||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false)
|
||||
|
||||
fun handleEvent(event: StartChatEvents) {
|
||||
when (event) {
|
||||
is StartChatEvents.StartDM -> localCoroutineScope.launch {
|
||||
|
|
@ -76,6 +78,7 @@ class StartChatPresenter(
|
|||
userListState = userListState,
|
||||
startDmAction = startDmActionState.value,
|
||||
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
|
||||
enableKeyShareOnInvite = enableKeyShareOnInvite.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ data class StartChatState(
|
|||
val userListState: UserListState,
|
||||
val startDmAction: AsyncAction<RoomId>,
|
||||
val isRoomDirectorySearchEnabled: Boolean,
|
||||
val enableKeyShareOnInvite: Boolean,
|
||||
val eventSink: (StartChatEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import io.element.android.features.startchat.impl.userlist.aUserListState
|
|||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -52,7 +53,7 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
|
|||
)
|
||||
),
|
||||
aCreateRoomRootState(
|
||||
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
|
||||
startDmAction = aConfirmingStartDmWithMatrixUser()
|
||||
),
|
||||
aCreateRoomRootState(
|
||||
isRoomDirectorySearchEnabled = true,
|
||||
|
|
@ -60,6 +61,16 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
|
|||
)
|
||||
}
|
||||
|
||||
fun aConfirmingStartDmWithMatrixUser(
|
||||
matrixUser: MatrixUser = aMatrixUser(),
|
||||
isUserIdentityUnknown: Boolean = false
|
||||
): ConfirmingStartDmWithMatrixUser {
|
||||
return ConfirmingStartDmWithMatrixUser(
|
||||
matrixUser,
|
||||
isUserIdentityUnknown
|
||||
)
|
||||
}
|
||||
|
||||
fun aCreateRoomRootState(
|
||||
applicationName: String = "Element X Preview",
|
||||
userListState: UserListState = aUserListState(),
|
||||
|
|
@ -71,5 +82,6 @@ fun aCreateRoomRootState(
|
|||
userListState = userListState,
|
||||
startDmAction = startDmAction,
|
||||
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
|
||||
enableKeyShareOnInvite = false,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ fun StartChatView(
|
|||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
|
||||
isUserIdentityUnknown = data.isUserIdentityUnknown,
|
||||
onSendInvite = {
|
||||
state.eventSink(StartChatEvents.StartDM(data.matrixUser))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,14 +13,21 @@ import com.google.common.truth.Truth.assertThat
|
|||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -67,7 +74,12 @@ class DefaultStartDMActionTest {
|
|||
|
||||
@Test
|
||||
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(null) }
|
||||
)
|
||||
val matrixClient = FakeMatrixClient(
|
||||
encryptionService = encryptionService
|
||||
).apply {
|
||||
givenFindDmResult(Result.success(null))
|
||||
givenCreateDmResult(Result.success(A_ROOM_ID))
|
||||
}
|
||||
|
|
@ -76,7 +88,7 @@ class DefaultStartDMActionTest {
|
|||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
val matrixUser = aMatrixUser()
|
||||
action.execute(matrixUser, false, state)
|
||||
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
|
||||
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
|
|
@ -94,13 +106,38 @@ class DefaultStartDMActionTest {
|
|||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when history sharing enabled, user identity fetched and identity unknown`() = runTest {
|
||||
val getUserIdentityResult = lambdaRecorder<UserId, Result<IdentityState?>> { _ -> Result.success(null) }
|
||||
val encryptionService = FakeEncryptionService(getUserIdentityResult = getUserIdentityResult)
|
||||
val matrixClient = FakeMatrixClient(encryptionService = encryptionService).apply {
|
||||
givenFindDmResult(Result.success(null))
|
||||
}
|
||||
val featureFlagService = FakeFeatureFlagService().apply {
|
||||
setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
|
||||
}
|
||||
|
||||
val action = createStartDMAction(
|
||||
matrixClient = matrixClient,
|
||||
featureFlagService = featureFlagService
|
||||
)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
|
||||
action.execute(aMatrixUser(), false, state)
|
||||
|
||||
assertThat(getUserIdentityResult.assertions().isCalledOnce())
|
||||
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = true))
|
||||
}
|
||||
|
||||
private fun createStartDMAction(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
|
||||
): DefaultStartDMAction {
|
||||
return DefaultStartDMAction(
|
||||
matrixClient = matrixClient,
|
||||
analyticsService = analyticsService,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class StartChatPresenterTest {
|
|||
@Test
|
||||
fun `present - start DM action confirmation scenario - cancel`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ class StartChatPresenterTest {
|
|||
@Test
|
||||
fun `present - start DM action confirmation scenario - confirm`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser, isUserIdentityUnknown = false)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ data class UserProfileState(
|
|||
val dmRoomId: RoomId?,
|
||||
val canCall: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val enableKeyShareOnInvite: Boolean,
|
||||
val eventSink: (UserProfileEvents) -> Unit
|
||||
) {
|
||||
enum class ConfirmationDialog {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
|
|
@ -46,6 +47,7 @@ dependencies {
|
|||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaviewer.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.features.verifysession.test)
|
||||
testImplementation(projects.features.startchat.test)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.Presenter
|
||||
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.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -50,6 +53,7 @@ class UserProfilePresenter(
|
|||
private val client: MatrixClient,
|
||||
private val startDMAction: StartDMAction,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<UserProfileState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -101,6 +105,8 @@ class UserProfilePresenter(
|
|||
}
|
||||
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
|
||||
|
||||
val enableKeyShareOnInvite = featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(false)
|
||||
|
||||
fun handleEvent(event: UserProfileEvents) {
|
||||
when (event) {
|
||||
is UserProfileEvents.BlockUser -> {
|
||||
|
|
@ -153,6 +159,7 @@ class UserProfilePresenter(
|
|||
dmRoomId = dmRoomId,
|
||||
canCall = canCall,
|
||||
snackbarMessage = null,
|
||||
enableKeyShareOnInvite = enableKeyShareOnInvite.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.libraries.architecture.AsyncAction
|
||||
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.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -324,7 +325,7 @@ class UserProfilePresenterTest {
|
|||
@Test
|
||||
fun `present - start DM action confirmation scenario - cancel`() = runTest {
|
||||
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 ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
|
|
@ -354,7 +355,7 @@ class UserProfilePresenterTest {
|
|||
@Test
|
||||
fun `present - start DM action confirmation scenario - confirm`() = runTest {
|
||||
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 ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
|
|
@ -414,6 +415,7 @@ class UserProfilePresenterTest {
|
|||
sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isElementCallAvailableResult = { isElementCallAvailable },
|
||||
),
|
||||
featureFlagService = FakeFeatureFlagService()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
|
|||
aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN),
|
||||
aUserProfileState(startDmActionState = AsyncAction.Loading),
|
||||
aUserProfileState(canCall = true),
|
||||
aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())),
|
||||
aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser(), isUserIdentityUnknown = false)),
|
||||
aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
|
||||
)
|
||||
}
|
||||
|
|
@ -61,5 +61,6 @@ fun aUserProfileState(
|
|||
dmRoomId = dmRoomId,
|
||||
canCall = canCall,
|
||||
snackbarMessage = snackbarMessage,
|
||||
enableKeyShareOnInvite = false,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ fun UserProfileView(
|
|||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
|
||||
isUserIdentityUnknown = data.isUserIdentityUnknown,
|
||||
onSendInvite = {
|
||||
state.eventSink(UserProfileEvents.StartDM)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
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.Spacer
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.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.AvatarSize
|
||||
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.IconSource
|
||||
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.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.R
|
||||
|
|
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun CreateDmConfirmationBottomSheet(
|
||||
matrixUser: MatrixUser,
|
||||
enableKeyShareOnInvite: Boolean,
|
||||
isUserIdentityUnknown: Boolean,
|
||||
onSendInvite: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
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(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
|
|||
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
|
||||
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),
|
||||
)
|
||||
if (isUserIdentityUnknown) {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(
|
||||
bottom = 16.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
),
|
||||
title = titleContent,
|
||||
subTitle = descriptionContent,
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
|
||||
)
|
||||
MatrixUserRow(matrixUser)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
ButtonRowMolecule(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = onDismiss
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
onClick = onSendInvite
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
} else {
|
||||
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
|
||||
@Composable
|
||||
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
|
||||
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
|
||||
CreateDmConfirmationBottomSheetStateProvider::class
|
||||
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = matrixUser,
|
||||
matrixUser = state.matrixUser,
|
||||
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
|
||||
isUserIdentityUnknown = state.isUserIdentityUnknown,
|
||||
onSendInvite = {},
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8ee76c2369a9671cbe370f367718fcda5bb08a89ed5116accc96928a64e9724
|
||||
size 55689
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:029b19808dd54b74ef30737242a10af31b80e579b313d001dd9fa377bc2cca58
|
||||
size 58319
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21a24fade9819efdb9114ec0ba3db21ec87cf93e32d896e22117fcd4f23e07ce
|
||||
size 53601
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eca54577cffddb66921623ede7ab39e017f5cd95e5049d6ad2763fa4f1f88ad4
|
||||
size 58133
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c5a4487507334ec43c9d659f57f2ec0d86856d941f8b1b437c101b696a5b49d
|
||||
size 24223
|
||||
oid sha256:bb4d6bfb9c412de00a2b4956032dd42906b5451eb99e6ebb1880dc01f6b55af5
|
||||
size 26077
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:26bf76ccdb56d042422553f557d91d0f26d874a710f696ac106c5c2b5590d332
|
||||
size 38833
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab0ba9a693ede4106d09170710f215bccfd82dbfbadfdafa5fb49fe39a03c25d
|
||||
size 23471
|
||||
oid sha256:b8c422787b67d477d3b7c8d5dee8879f33d47153dc93dd29bb3883e4ed863a41
|
||||
size 25232
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8413aed02383572cfe8c481c6ba8b0db4cfb3402334c37f2d8b54a73fe4bf594
|
||||
size 37343
|
||||
Loading…
Add table
Add a link
Reference in a new issue