Display a confirmation dialog before creating a DM.

This commit is contained in:
Benoit Marty
2025-02-05 09:29:04 +01:00
committed by Benoit Marty
parent 7b46c9c277
commit bfcc741312
19 changed files with 461 additions and 63 deletions

View File

@@ -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

View File

@@ -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<AsyncAction<RoomId>>)
suspend fun execute(
matrixUser: MatrixUser,
createIfDmDoesNotExist: Boolean,
actionState: MutableState<AsyncAction<RoomId>>,
)
}

View File

@@ -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<AsyncAction<RoomId>>) {
override suspend fun execute(
matrixUser: MatrixUser,
createIfDmDoesNotExist: Boolean,
actionState: MutableState<AsyncAction<RoomId>>,
) {
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)
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
},
)
}
},
)
}

View File

@@ -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<RoomId>>(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<RoomId>>(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<RoomId>>(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<RoomId>>(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(

View File

@@ -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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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 {

View File

@@ -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)
}

View File

@@ -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<RoomId> = AsyncAction.Success(A_ROOM_ID)
fun givenExecuteResult(result: AsyncAction<RoomId>) {
executeResult = result
class FakeStartDMAction(
private val executeResult: (MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>) -> Unit = { _, _, _ ->
lambdaError()
}
override suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>) {
actionState.value = AsyncAction.Loading
delay(1)
actionState.value = executeResult
) : StartDMAction {
override suspend fun execute(
matrixUser: MatrixUser,
createIfDmDoesNotExist: Boolean,
actionState: MutableState<AsyncAction<RoomId>>,
) {
executeResult(matrixUser, createIfDmDoesNotExist, actionState)
}
}

View File

@@ -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 -> {

View File

@@ -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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, 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)

View File

@@ -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)
},
)
}
},
)
}
}

View File

@@ -59,4 +59,6 @@ enum class AvatarSize(val dp: Dp) {
KnockRequestBanner(32.dp),
MediaSender(32.dp),
DmCreationConfirmation(64.dp),
}

View File

@@ -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
}

View File

@@ -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 = {},
)
}

View File

@@ -21,7 +21,7 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
fun aMatrixUser(
id: String = "@id_of_alice:server.org",
displayName: String = "Alice",
displayName: String? = "Alice",
avatarUrl: String? = null,
) = MatrixUser(
userId = UserId(id),

View File

@@ -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)"
}
}

View File

@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invite"</string>
<string name="screen_bottom_sheet_create_dm_message">"Would you like to start a chat with %1$s (%2$s)?"</string>
<string name="screen_bottom_sheet_create_dm_message_no_displayname">"Would you like to start a chat with %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Send invite?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
</resources>

View File

@@ -273,7 +273,8 @@
{
"name" : ":libraries:matrixui",
"includeRegex" : [
"screen_invites_invited_you"
"screen_invites_invited_you",
"screen\\.bottom_sheet\\.create_dm\\..*"
]
},
{