diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts index 0c3f988659..390ccce7b9 100644 --- a/features/invitepeople/impl/build.gradle.kts +++ b/features/invitepeople/impl/build.gradle.kts @@ -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) } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt new file mode 100644 index 0000000000..8f4d5e1510 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/ConfirmingUnknownUserInvitation.kt @@ -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 +) : AsyncAction.Confirming diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt index b1f18b1df9..449d0ce6ac 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt @@ -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 } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt index 3450587e82..58b3fb67f6 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt @@ -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.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().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 -> { diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt index 15ded2ae3f..c26b8de254 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt @@ -76,6 +76,16 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider, + 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) = diff --git a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt index ab9e20437e..a1d72010f6 100644 --- a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt +++ b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt @@ -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 -> + 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: 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 -> + 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 -> + 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, 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, ) } diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt index 5bf015c0f0..059002d983 100644 --- a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt @@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser data class ConfirmingStartDmWithMatrixUser( val matrixUser: MatrixUser, + val isUserIdentityUnknown: Boolean, ) : AsyncAction.Confirming diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt index a484fe2e72..3bfbd1ca18 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt @@ -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 + ) } } } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt index e176f202ad..7afbe19c3d 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt @@ -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, ) } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt index 65f977d3e3..989a5b8d20 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt @@ -17,5 +17,6 @@ data class StartChatState( val userListState: UserListState, val startDmAction: AsyncAction, val isRoomDirectorySearchEnabled: Boolean, + val enableKeyShareOnInvite: Boolean, val eventSink: (StartChatEvents) -> Unit, ) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt index 448ad1a80a..17d83a9e11 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt @@ -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 { ) ), aCreateRoomRootState( - startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()), + startDmAction = aConfirmingStartDmWithMatrixUser() ), aCreateRoomRootState( isRoomDirectorySearchEnabled = true, @@ -60,6 +61,16 @@ open class StartChatStateProvider : PreviewParameterProvider { ) } +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, ) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt index 0b8da1bd94..28bf52549e 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt @@ -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)) }, diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt index 122775f2cc..88b935e47d 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt @@ -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.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> { _ -> 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.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, ) } } diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt index 7c209d9052..2bc15e989d 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -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>, 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>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt index 0e0016ee14..e2a309c17f 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -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 { diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 0b65441cc3..3e68fb2b9c 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -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) diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index 7e09a03ec3..a451d86b70 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -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 { @AssistedFactory interface Factory { @@ -101,6 +105,8 @@ class UserProfilePresenter( } val userProfile by produceState(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, ) } diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 511effe750..1325b46bc0 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -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>, 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>, Unit> { _, _, actionState -> actionState.value = startDMConfirmationResult } @@ -414,6 +415,7 @@ class UserProfilePresenterTest { sessionEnterpriseService = FakeSessionEnterpriseService( isElementCallAvailableResult = { isElementCallAvailable }, ), + featureFlagService = FakeFeatureFlagService() ) } } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index 49a2fee4b5..a4bbcd6aa4 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -31,7 +31,7 @@ open class UserProfileStateProvider : PreviewParameterProvider 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, ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 380bb006ab..34f992f77d 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -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) }, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt index dca173d780..7e0e51050a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt @@ -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 { + 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), + ) +} diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png new file mode 100644 index 0000000000..8c6b1bd7cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8ee76c2369a9671cbe370f367718fcda5bb08a89ed5116accc96928a64e9724 +size 55689 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png new file mode 100644 index 0000000000..35f633b3a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Day_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:029b19808dd54b74ef30737242a10af31b80e579b313d001dd9fa377bc2cca58 +size 58319 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png new file mode 100644 index 0000000000..7983ef5e8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21a24fade9819efdb9114ec0ba3db21ec87cf93e32d896e22117fcd4f23e07ce +size 53601 diff --git a/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png new file mode 100644 index 0000000000..1c50d23117 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.invitepeople.impl_InvitePeopleView_Night_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eca54577cffddb66921623ede7ab39e017f5cd95e5049d6ad2763fa4f1f88ad4 +size 58133 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png index acec20812f..c7e3599c58 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c5a4487507334ec43c9d659f57f2ec0d86856d941f8b1b437c101b696a5b49d -size 24223 +oid sha256:bb4d6bfb9c412de00a2b4956032dd42906b5451eb99e6ebb1880dc01f6b55af5 +size 26077 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png new file mode 100644 index 0000000000..d7ff4a1d2f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26bf76ccdb56d042422553f557d91d0f26d874a710f696ac106c5c2b5590d332 +size 38833 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png index 0c60a3da07..fe44b8941c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab0ba9a693ede4106d09170710f215bccfd82dbfbadfdafa5fb49fe39a03c25d -size 23471 +oid sha256:b8c422787b67d477d3b7c8d5dee8879f33d47153dc93dd29bb3883e4ed863a41 +size 25232 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png new file mode 100644 index 0000000000..f5ff7856b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8413aed02383572cfe8c481c6ba8b0db4cfb3402334c37f2d8b54a73fe4bf594 +size 37343