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/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index 6c45325576..a3ce8a35a9 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.features.createroom.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.createroom.impl.userlist.UserListState import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList import io.element.android.features.createroom.impl.userlist.aUserListState @@ -49,6 +50,9 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider + 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/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index 01a36077fb..a53f53c48c 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 @@ -8,12 +8,14 @@ package io.element.android.features.userprofile.shared import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class UserProfileStateProvider : PreviewParameterProvider { override val values: Sequence @@ -26,7 +28,7 @@ open class UserProfileStateProvider : PreviewParameterProvider aUserProfileState(isBlocked = AsyncData.Loading(true), isVerified = AsyncData.Loading()), aUserProfileState(startDmActionState = AsyncAction.Loading), aUserProfileState(canCall = true), - // Add other states here + aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())), ) } 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..75b8e5a5f3 --- /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.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.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( + 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), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { + CreateDmConfirmationBottomSheet( + matrixUser = matrixUser, + 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..72f69932aa 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 @@ -7,9 +7,12 @@ package io.element.android.libraries.matrix.ui.model +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( id = userId.value, @@ -21,3 +24,14 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( fun MatrixUser.getBestName(): String { return displayName?.takeIf { it.isNotEmpty() } ?: userId.value } + +@Composable +fun MatrixUser.getFullName(): String { + return displayName.let { name -> + if (name.isNullOrBlank()) { + userId.value + } else { + stringResource(CommonStrings.common_name_and_id, name, userId.value) + } + } +} diff --git a/libraries/matrixui/src/main/res/values/localazy.xml b/libraries/matrixui/src/main/res/values/localazy.xml index 80939a8863..b27021ebd6 100644 --- a/libraries/matrixui/src/main/res/values/localazy.xml +++ b/libraries/matrixui/src/main/res/values/localazy.xml @@ -1,4 +1,7 @@ + "Send invite" + "Would you like to start a chat with %1$s?" + "Send invite?" "%1$s (%2$s) invited you" diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 6ef12cc305..288d0b3d9c 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -7,12 +7,6 @@ package io.element.android.libraries.matrix.ui.messages.reply -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -52,6 +46,7 @@ import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.tests.testutils.withConfigurationAndContext import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -350,7 +345,7 @@ class InReplyToMetadataKtTest { @Test fun `a location message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - testEnv { + withConfigurationAndContext { anInReplyToDetailsReady( eventContent = aMessageContent( messageType = LocationMessageType( @@ -380,7 +375,7 @@ class InReplyToMetadataKtTest { @Test fun `a voice message content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - testEnv { + withConfigurationAndContext { anInReplyToDetailsReady( eventContent = aMessageContent( messageType = VoiceMessageType( @@ -588,17 +583,3 @@ fun anImageInfo(): ImageInfo { blurhash = A_BLUR_HASH, ) } - -@Composable -private fun testEnv(content: @Composable () -> Any?): Any? { - var result: Any? = null - CompositionLocalProvider( - LocalConfiguration provides Configuration(), - LocalContext provides ApplicationProvider.getApplicationContext(), - ) { - content().apply { - result = this - } - } - return result -} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt new file mode 100644 index 0000000000..611616cdd7 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -0,0 +1,101 @@ +/* + * 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.model + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.withConfigurationAndContext +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MatrixUserExtensionsTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `getAvatarData should return the expected value`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + avatarUrl = "avatarUrl", + ) + val expected = AvatarData( + id = A_USER_ID.value, + name = "displayName", + url = "avatarUrl", + size = AvatarSize.UserHeader, + ) + assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) + } + + @Test + fun `getBestName should return the display name is available`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + ) + assertThat(matrixUser.getBestName()).isEqualTo("displayName") + } + + @Test + fun `getBestName should return the id when name is not available`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = null, + ) + assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value) + } + + @Test + fun `getBestName should return the id when name is empty`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "", + ) + assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value) + } + + @Test + fun `getFullName should return the display name is available and the userId`() = runTest { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + ) + moleculeFlow(RecompositionMode.Immediate) { + withConfigurationAndContext { + matrixUser.getFullName() + } + }.test { + assertThat(awaitItem()).isEqualTo("displayName (@alice:server.org)") + } + } + + @Test + fun `getBestName should return only the id when name is not available`() = runTest { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = null, + ) + moleculeFlow(RecompositionMode.Immediate) { + matrixUser.getFullName() + }.test { + assertThat(awaitItem()).isEqualTo(A_USER_ID.value) + } + } +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 2d6c7eea53..9430f509d7 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -193,6 +193,7 @@ Reason: %1$s." "Message removed" "Modern" "Mute" + "%1$s (%2$s)" "No results" "No room name" "Offline" diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestComposable.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestComposable.kt new file mode 100644 index 0000000000..673fb70bd5 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestComposable.kt @@ -0,0 +1,27 @@ +/* + * 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.tests.testutils + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.test.core.app.ApplicationProvider + +@Composable +fun withConfigurationAndContext(content: @Composable () -> Any?): Any? { + var result: Any? = null + CompositionLocalProvider( + LocalConfiguration provides Configuration(), + LocalContext provides ApplicationProvider.getApplicationContext(), + ) { + result = content() + } + return result +} diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Day_4_en.png new file mode 100644 index 0000000000..d7cbe99c6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ced96a86269f22fad7d93a27bc0c302ce63f45857b4f15f764a8f04fe0d96b6 +size 41925 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Night_4_en.png new file mode 100644 index 0000000000..4a5085b6f3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.root_CreateRoomRootView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13019305b802fb72dc92e3ee68f581f2fa93e122d8e8d6f5fd1311869535ef2e +size 40334 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_8_en.png new file mode 100644 index 0000000000..f123f80513 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbd7611e8df66b7e691c3faa78ada45a186f004a5676ac276c35c5de86e72d0a +size 34954 diff --git a/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_8_en.png new file mode 100644 index 0000000000..508d898810 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.userprofile.shared_UserProfileView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4dc1b2fcd2aca5b0180e3551d2bad43e50927f154e3b21e305046d9b9abedb49 +size 33472 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_87_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_87_en.png new file mode 100644 index 0000000000..1f283638d5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_87_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1104fc4c6acc07da2aa9fcc7e15a95a3ba1263f7bf976040af068724ec7caee6 +size 20708 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_88_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_88_en.png new file mode 100644 index 0000000000..562db54d92 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_88_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2249dc7c9627f50d8895208c79e22760aa2bc484939bfc648f2fbcffc20bbca +size 19239 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_89_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_89_en.png new file mode 100644 index 0000000000..62aa6aff31 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_89_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fd50a626209cc9b66c8e4847a0ba4e96e39ed2638235fc6e50b56b0d239961b +size 24405 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_0_en.png new file mode 100644 index 0000000000..03233205de --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0fa36e2715a13dc388a9a7446c233ba0b5d03f8bd4861ac32f776164545409e +size 26128 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 new file mode 100644 index 0000000000..ad1ce28248 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b16ac7e4ea4e1e5d34d301f848f0f819df813204994ef1ec8db23402dbfefb22 +size 24305 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_0_en.png new file mode 100644 index 0000000000..d5d86a46ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcbe93a450fd92eec005ac15e526c2a3973dc841129a14cb32505d173f6b24db +size 25031 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 new file mode 100644 index 0000000000..7fcbdd9786 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29a91d04c8f4e10a8cc5f90a0ec4d0b42b9461e427a19873c429cd3589cd2768 +size 23285 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\\..*" ] }, {