diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt new file mode 100644 index 0000000000..af19408324 --- /dev/null +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/ConfirmingStartDmWithMatrixUser.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 New Vector 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.createroom.api + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class ConfirmingStartDmWithMatrixUser( + val matrixUser: MatrixUser, +) : AsyncAction.Confirming diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt index 50c67ba956..e64be9f923 100644 --- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt @@ -10,13 +10,19 @@ package io.element.android.features.createroom.api import androidx.compose.runtime.MutableState import io.element.android.libraries.architecture.AsyncAction 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.user.MatrixUser interface StartDMAction { /** * Try to find an existing DM with the given user, or create one if none exists. - * @param userId The user to start a DM with. + * @param matrixUser The user to start a DM with. + * @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM + * does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser]. * @param actionState The state to update with the result of the action. */ - suspend fun execute(userId: UserId, actionState: MutableState>) + suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt index d48712c400..c9b60786bd 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt @@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl import androidx.compose.runtime.MutableState import com.squareup.anvil.annotations.ContributesBinding import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.createroom.api.StartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.di.SessionScope 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.room.StartDMResult import io.element.android.libraries.matrix.api.room.startDM +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.services.analytics.api.AnalyticsService import javax.inject.Inject @@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor( private val matrixClient: MatrixClient, private val analyticsService: AnalyticsService, ) : StartDMAction { - override suspend fun execute(userId: UserId, actionState: MutableState>) { + override suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) { actionState.value = AsyncAction.Loading - when (val result = matrixClient.startDM(userId)) { + when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) { is StartDMResult.Success -> { if (result.isNew) { analyticsService.capture(CreatedRoom(isDM = true)) @@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor( is StartDMResult.Failure -> { actionState.value = AsyncAction.Failure(result.throwable) } + StartDMResult.DmDoesNotExist -> { + actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser) + } } } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 2d5ebc87e1..63880209e0 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -50,7 +50,11 @@ class CreateRoomRootPresenter @Inject constructor( fun handleEvents(event: CreateRoomRootEvents) { when (event) { is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch { - startDMAction.execute(event.matrixUser.userId, startDmActionState) + startDMAction.execute( + matrixUser = event.matrixUser, + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) } CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 0e33cf8b6e..af3aa3b9fb 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -28,6 +28,7 @@ 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.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.components.UserListView import io.element.android.libraries.designsystem.components.async.AsyncActionView @@ -43,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf @@ -110,6 +112,19 @@ fun CreateRoomRootView( ?: state.eventSink(CreateRoomRootEvents.CancelStartDM) }, onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + onSendInvite = { + state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser)) + }, + onDismiss = { + state.eventSink(CreateRoomRootEvents.CancelStartDM) + }, + ) + } + }, ) } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt index dabdc288db..ca52969e53 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt @@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl import androidx.compose.runtime.mutableStateOf import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +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 kotlinx.coroutines.test.runTest @@ -28,10 +29,12 @@ class DefaultStartDMActionTest { val matrixClient = FakeMatrixClient().apply { givenFindDmResult(A_ROOM_ID) } - val action = createStartDMAction(matrixClient) + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) val state = mutableStateOf>(AsyncAction.Uninitialized) - action.execute(A_USER_ID, state) + action.execute(aMatrixUser(), true, state) assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID)) + assertThat(analyticsService.capturedEvents).isEmpty() } @Test @@ -43,21 +46,38 @@ class DefaultStartDMActionTest { val analyticsService = FakeAnalyticsService() val action = createStartDMAction(matrixClient, analyticsService) val state = mutableStateOf>(AsyncAction.Uninitialized) - action.execute(A_USER_ID, state) + action.execute(aMatrixUser(), true, state) assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID)) assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true)) } + @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 { + givenFindDmResult(null) + givenCreateDmResult(Result.success(A_ROOM_ID)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + val matrixUser = aMatrixUser() + action.execute(matrixUser, false, state) + assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser)) + assertThat(analyticsService.capturedEvents).isEmpty() + } + @Test fun `when dm creation fails, assert state is updated with given error`() = runTest { val matrixClient = FakeMatrixClient().apply { givenFindDmResult(null) givenCreateDmResult(Result.failure(A_THROWABLE)) } - val action = createStartDMAction(matrixClient) + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) val state = mutableStateOf>(AsyncAction.Uninitialized) - action.execute(A_USER_ID, state) + action.execute(aMatrixUser(), true, state) assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE)) + assertThat(analyticsService.capturedEvents).isEmpty() } private fun createStartDMAction( diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt index 139fae5982..0eaae1a3df 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt @@ -7,16 +7,19 @@ package io.element.android.features.createroom.impl.root +import androidx.compose.runtime.MutableState import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.libraries.architecture.AsyncAction +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.user.MatrixUser import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -24,6 +27,9 @@ import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -33,46 +39,130 @@ class CreateRoomRootPresenterTest { val warmUpRule = WarmUpRule() @Test - fun `present - start DM action complete scenario`() = runTest { - val startDMAction = FakeStartDMAction() + fun `present - start DM action failure scenario`() = runTest { + val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMFailureResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) val presenter = createCreateRoomRootPresenter(startDMAction) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) assertThat(initialState.userListState.selectedUsers).isEmpty() assertThat(initialState.userListState.isSearchActive).isFalse() assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() - val matrixUser = MatrixUser(UserId("@name:domain")) - val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) - val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) - - // Failure - startDMAction.givenExecuteResult(startDMFailureResult) initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) - assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java) awaitItem().also { state -> assertThat(state.startDmAction).isEqualTo(startDMFailureResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) state.eventSink(CreateRoomRootEvents.CancelStartDM) } - - // Success - startDMAction.givenExecuteResult(startDMSuccessResult) awaitItem().also { state -> - assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized) - state.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(state.startDmAction.isUninitialized()).isTrue() } - assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java) + } + } + + @Test + fun `present - start DM action success scenario`() = runTest { + val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMSuccessResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createCreateRoomRootPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) + assertThat(initialState.userListState.selectedUsers).isEmpty() + assertThat(initialState.userListState.isSearchActive).isFalse() + assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() + val matrixUser = MatrixUser(UserId("@name:domain")) + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) awaitItem().also { state -> assertThat(state.startDmAction).isEqualTo(startDMSuccessResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) } } } + @Test + fun `present - start DM action confirmation scenario - cancel`() = runTest { + val matrixUser = MatrixUser(UserId("@name:domain")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createCreateRoomRootPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Cancelling should not create the DM + confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM) + val finalState = awaitItem() + assertThat(finalState.startDmAction.isUninitialized()).isTrue() + executeResult.assertions().isCalledExactly(1) + } + } + + @Test + fun `present - start DM action confirmation scenario - confirm`() = runTest { + val matrixUser = MatrixUser(UserId("@name:domain")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createCreateRoomRootPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Start DM again should invoke the action with createIfDmDoesNotExist = true + confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + executeResult.assertions().isCalledExactly(2).withSequence( + listOf(value(matrixUser), value(false), any()), + listOf(value(matrixUser), value(true), any()), + ) + } + } + private fun createCreateRoomRootPresenter( startDMAction: StartDMAction = FakeStartDMAction(), ): CreateRoomRootPresenter { diff --git a/features/createroom/test/build.gradle.kts b/features/createroom/test/build.gradle.kts index f2f2b8280a..b7df0caab6 100644 --- a/features/createroom/test/build.gradle.kts +++ b/features/createroom/test/build.gradle.kts @@ -18,5 +18,6 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.test) implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) api(projects.features.createroom.api) } diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt index 27ad5f7d62..90e2ecf1c1 100644 --- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt +++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeStartDMAction.kt @@ -11,20 +11,19 @@ import androidx.compose.runtime.MutableState import io.element.android.features.createroom.api.StartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.test.A_ROOM_ID -import kotlinx.coroutines.delay +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.tests.testutils.lambda.lambdaError -class FakeStartDMAction : StartDMAction { - private var executeResult: AsyncAction = AsyncAction.Success(A_ROOM_ID) - - fun givenExecuteResult(result: AsyncAction) { - executeResult = result +class FakeStartDMAction( + private val executeResult: (MatrixUser, Boolean, MutableState>) -> Unit = { _, _, _ -> + lambdaError() } - - override suspend fun execute(userId: UserId, actionState: MutableState>) { - actionState.value = AsyncAction.Loading - delay(1) - actionState.value = executeResult +) : StartDMAction { + override suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) { + executeResult(matrixUser, createIfDmDoesNotExist, actionState) } } 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 1695ea886c..8189f75aa3 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 @@ -119,7 +119,11 @@ class UserProfilePresenter @AssistedInject constructor( } UserProfileEvents.StartDM -> { coroutineScope.launch { - startDMAction.execute(userId, startDmActionState) + startDMAction.execute( + matrixUser = userProfile ?: MatrixUser(userId), + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) } } UserProfileEvents.ClearStartDMState -> { 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 80bfd81a8a..41626737cc 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 @@ -7,8 +7,13 @@ package io.element.android.features.userprofile.impl +import androidx.compose.runtime.MutableState +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.userprofile.api.UserProfileEvents @@ -19,6 +24,7 @@ import io.element.android.libraries.architecture.AsyncData 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.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_THROWABLE @@ -30,6 +36,9 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -229,37 +238,122 @@ class UserProfilePresenterTest { } @Test - fun `present - start DM action complete scenario`() = runTest { - val startDMAction = FakeStartDMAction() + fun `present - start DM action failure scenario`() = runTest { + val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMFailureResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) val presenter = createUserProfilePresenter(startDMAction = startDMAction) - presenter.test { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { val initialState = awaitFirstItem() assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) - val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) - val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) - - // Failure - startDMAction.givenExecuteResult(startDMFailureResult) + val matrixUser = MatrixUser(UserId("@alice:server.org")) initialState.eventSink(UserProfileEvents.StartDM) - assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java) awaitItem().also { state -> assertThat(state.startDmActionState).isEqualTo(startDMFailureResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) state.eventSink(UserProfileEvents.ClearStartDMState) } - - // Success - startDMAction.givenExecuteResult(startDMSuccessResult) awaitItem().also { state -> - assertThat(state.startDmActionState).isEqualTo(AsyncAction.Uninitialized) - state.eventSink(UserProfileEvents.StartDM) + assertThat(state.startDmActionState.isUninitialized()).isTrue() } - assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java) + } + } + + @Test + fun `present - start DM action success scenario`() = runTest { + val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMSuccessResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + val matrixUser = MatrixUser(UserId("@alice:server.org")) + initialState.eventSink(UserProfileEvents.StartDM) awaitItem().also { state -> assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) } } } + @Test + fun `present - start DM action confirmation scenario - cancel`() = runTest { + val matrixUser = MatrixUser(UserId("@alice:server.org")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(UserProfileEvents.StartDM) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Cancelling should not create the DM + confirmingState.eventSink(UserProfileEvents.ClearStartDMState) + val finalState = awaitItem() + assertThat(finalState.startDmActionState.isUninitialized()).isTrue() + executeResult.assertions().isCalledExactly(1) + } + } + + @Test + fun `present - start DM action confirmation scenario - confirm`() = runTest { + val matrixUser = MatrixUser(UserId("@alice:server.org")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(UserProfileEvents.StartDM) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Start DM again should invoke the action with createIfDmDoesNotExist = true + confirmingState.eventSink(UserProfileEvents.StartDM) + executeResult.assertions().isCalledExactly(2).withSequence( + listOf(value(matrixUser), value(false), any()), + listOf(value(matrixUser), value(true), any()), + ) + } + } + @Test fun `present - when user is verified, the value in the state is true`() = runTest { val client = createFakeMatrixClient(isUserVerified = true) 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 6fb3e5ccb7..dee44377d6 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.userprofile.api.UserProfileEvents import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs @@ -37,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet import io.element.android.libraries.ui.strings.CommonStrings @OptIn(ExperimentalMaterial3Api::class) @@ -95,6 +97,19 @@ fun UserProfileView( errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) }, onRetry = { state.eventSink(UserProfileEvents.StartDM) }, onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + onSendInvite = { + state.eventSink(UserProfileEvents.StartDM) + }, + onDismiss = { + state.eventSink(UserProfileEvents.ClearStartDMState) + }, + ) + } + }, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index d0950d2960..437b36235e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -59,4 +59,6 @@ enum class AvatarSize(val dp: Dp) { KnockRequestBanner(32.dp), MediaSender(32.dp), + + DmCreationConfirmation(64.dp), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt index 973ecc74e2..7755d97d8c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt @@ -12,21 +12,27 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId /** - * Try to find an existing DM with the given user, or create one if none exists. + * Try to find an existing DM with the given user, or create one if none exists and [createIfDmDoesNotExist] is true. */ -suspend fun MatrixClient.startDM(userId: UserId): StartDMResult { +suspend fun MatrixClient.startDM( + userId: UserId, + createIfDmDoesNotExist: Boolean, +): StartDMResult { val existingDM = findDM(userId) return if (existingDM != null) { StartDMResult.Success(existingDM, isNew = false) - } else { + } else if (createIfDmDoesNotExist) { createDM(userId).fold( { StartDMResult.Success(it, isNew = true) }, { StartDMResult.Failure(it) } ) + } else { + StartDMResult.DmDoesNotExist } } sealed interface StartDMResult { data class Success(val roomId: RoomId, val isNew: Boolean) : StartDMResult + data object DmDoesNotExist : StartDMResult data class Failure(val throwable: Throwable) : StartDMResult } 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 new file mode 100644 index 0000000000..3e1e09a1f2 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2025 New Vector 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.libraries.matrix.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.components.avatar.Avatar +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.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +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 +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getFullName +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Figma: + * https://www.figma.com/design/dywzKQvHYxFD1Ncn4a5NkI/PSB-675%253A-Improve-invite-into-a-DM?node-id=12-36886 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateDmConfirmationBottomSheet( + matrixUser: MatrixUser, + onSendInvite: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Avatar( + avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation), + ) + 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( + // TODO Check if finally we do not want to remove string duplication + text = stringResource(R.string.screen_bottom_sheet_create_dm_message_no_displayname, 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), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun CreateDmConfirmationBottomSheetPreview() = ElementPreview { + CreateDmConfirmationBottomSheet( + matrixUser = aMatrixUser(), + onSendInvite = {}, + onDismiss = {}, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt index 3f9e945ee5..d10f428f1c 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -21,7 +21,7 @@ open class MatrixUserProvider : PreviewParameterProvider { fun aMatrixUser( id: String = "@id_of_alice:server.org", - displayName: String = "Alice", + displayName: String? = "Alice", avatarUrl: String? = null, ) = MatrixUser( userId = UserId(id), diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt index 464cc57f6e..a159adc74d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -21,3 +21,11 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( fun MatrixUser.getBestName(): String { return displayName?.takeIf { it.isNotEmpty() } ?: userId.value } + +fun MatrixUser.getFullName(): String { + return if (displayName.isNullOrBlank()) { + userId.value + } else { + "$displayName ($userId)" + } +} diff --git a/libraries/matrixui/src/main/res/values/localazy.xml b/libraries/matrixui/src/main/res/values/localazy.xml index 80939a8863..11b1683635 100644 --- a/libraries/matrixui/src/main/res/values/localazy.xml +++ b/libraries/matrixui/src/main/res/values/localazy.xml @@ -1,4 +1,8 @@ + "Send invite" + "Would you like to start a chat with %1$s (%2$s)?" + "Would you like to start a chat with %1$s?" + "Send invite?" "%1$s (%2$s) invited you" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index b75c0380ff..d6cfc44134 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -273,7 +273,8 @@ { "name" : ":libraries:matrixui", "includeRegex" : [ - "screen_invites_invited_you" + "screen_invites_invited_you", + "screen\\.bottom_sheet\\.create_dm\\..*" ] }, {