Skip to content

Commit fc96c00

Browse files
authored
Merge pull request #4233 from element-hq/feature/bma/dmCreationConfirmation
Display a bottom sheet to let user confirm the DM creation
2 parents 6362e01 + 1214b20 commit fc96c00

File tree

36 files changed

+638
-86
lines changed

36 files changed

+638
-86
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.createroom.api
9+
10+
import io.element.android.libraries.architecture.AsyncAction
11+
import io.element.android.libraries.matrix.api.user.MatrixUser
12+
13+
data class ConfirmingStartDmWithMatrixUser(
14+
val matrixUser: MatrixUser,
15+
) : AsyncAction.Confirming

features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/StartDMAction.kt

+9-3
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
1010
import androidx.compose.runtime.MutableState
1111
import io.element.android.libraries.architecture.AsyncAction
1212
import io.element.android.libraries.matrix.api.core.RoomId
13-
import io.element.android.libraries.matrix.api.core.UserId
13+
import io.element.android.libraries.matrix.api.user.MatrixUser
1414

1515
interface StartDMAction {
1616
/**
1717
* Try to find an existing DM with the given user, or create one if none exists.
18-
* @param userId The user to start a DM with.
18+
* @param matrixUser The user to start a DM with.
19+
* @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM
20+
* does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser].
1921
* @param actionState The state to update with the result of the action.
2022
*/
21-
suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>)
23+
suspend fun execute(
24+
matrixUser: MatrixUser,
25+
createIfDmDoesNotExist: Boolean,
26+
actionState: MutableState<AsyncAction<RoomId>>,
27+
)
2228
}

features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultStartDMAction.kt

+11-3
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl
1010
import androidx.compose.runtime.MutableState
1111
import com.squareup.anvil.annotations.ContributesBinding
1212
import im.vector.app.features.analytics.plan.CreatedRoom
13+
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
1314
import io.element.android.features.createroom.api.StartDMAction
1415
import io.element.android.libraries.architecture.AsyncAction
1516
import io.element.android.libraries.di.SessionScope
1617
import io.element.android.libraries.matrix.api.MatrixClient
1718
import io.element.android.libraries.matrix.api.core.RoomId
18-
import io.element.android.libraries.matrix.api.core.UserId
1919
import io.element.android.libraries.matrix.api.room.StartDMResult
2020
import io.element.android.libraries.matrix.api.room.startDM
21+
import io.element.android.libraries.matrix.api.user.MatrixUser
2122
import io.element.android.services.analytics.api.AnalyticsService
2223
import javax.inject.Inject
2324

@@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor(
2627
private val matrixClient: MatrixClient,
2728
private val analyticsService: AnalyticsService,
2829
) : StartDMAction {
29-
override suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>) {
30+
override suspend fun execute(
31+
matrixUser: MatrixUser,
32+
createIfDmDoesNotExist: Boolean,
33+
actionState: MutableState<AsyncAction<RoomId>>,
34+
) {
3035
actionState.value = AsyncAction.Loading
31-
when (val result = matrixClient.startDM(userId)) {
36+
when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
3237
is StartDMResult.Success -> {
3338
if (result.isNew) {
3439
analyticsService.capture(CreatedRoom(isDM = true))
@@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor(
3843
is StartDMResult.Failure -> {
3944
actionState.value = AsyncAction.Failure(result.throwable)
4045
}
46+
StartDMResult.DmDoesNotExist -> {
47+
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
48+
}
4149
}
4250
}
4351
}

features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ class CreateRoomRootPresenter @Inject constructor(
5050
fun handleEvents(event: CreateRoomRootEvents) {
5151
when (event) {
5252
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
53-
startDMAction.execute(event.matrixUser.userId, startDmActionState)
53+
startDMAction.execute(
54+
matrixUser = event.matrixUser,
55+
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
56+
actionState = startDmActionState,
57+
)
5458
}
5559
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
5660
}

features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package io.element.android.features.createroom.impl.root
99

1010
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
11+
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
1112
import io.element.android.features.createroom.impl.userlist.UserListState
1213
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
1314
import io.element.android.features.createroom.impl.userlist.aUserListState
@@ -49,6 +50,9 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
4950
recentDirectRooms = aRecentDirectRoomList()
5051
)
5152
),
53+
aCreateRoomRootState(
54+
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
55+
),
5256
)
5357
}
5458

features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt

+15
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
2828
import androidx.compose.ui.unit.dp
2929
import io.element.android.compound.theme.ElementTheme
3030
import io.element.android.compound.tokens.generated.CompoundIcons
31+
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
3132
import io.element.android.features.createroom.impl.R
3233
import io.element.android.features.createroom.impl.components.UserListView
3334
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -43,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
4344
import io.element.android.libraries.designsystem.theme.components.Text
4445
import io.element.android.libraries.designsystem.theme.components.TopAppBar
4546
import io.element.android.libraries.matrix.api.core.RoomId
47+
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
4648
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
4749
import io.element.android.libraries.ui.strings.CommonStrings
4850
import kotlinx.collections.immutable.persistentListOf
@@ -110,6 +112,19 @@ fun CreateRoomRootView(
110112
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
111113
},
112114
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
115+
confirmationDialog = { data ->
116+
if (data is ConfirmingStartDmWithMatrixUser) {
117+
CreateDmConfirmationBottomSheet(
118+
matrixUser = data.matrixUser,
119+
onSendInvite = {
120+
state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser))
121+
},
122+
onDismiss = {
123+
state.eventSink(CreateRoomRootEvents.CancelStartDM)
124+
},
125+
)
126+
}
127+
},
113128
)
114129
}
115130

features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt

+26-6
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl
1010
import androidx.compose.runtime.mutableStateOf
1111
import com.google.common.truth.Truth.assertThat
1212
import im.vector.app.features.analytics.plan.CreatedRoom
13+
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
1314
import io.element.android.libraries.architecture.AsyncAction
1415
import io.element.android.libraries.matrix.api.MatrixClient
1516
import io.element.android.libraries.matrix.api.core.RoomId
1617
import io.element.android.libraries.matrix.test.A_ROOM_ID
1718
import io.element.android.libraries.matrix.test.A_THROWABLE
18-
import io.element.android.libraries.matrix.test.A_USER_ID
1919
import io.element.android.libraries.matrix.test.FakeMatrixClient
20+
import io.element.android.libraries.matrix.ui.components.aMatrixUser
2021
import io.element.android.services.analytics.api.AnalyticsService
2122
import io.element.android.services.analytics.test.FakeAnalyticsService
2223
import kotlinx.coroutines.test.runTest
@@ -28,10 +29,12 @@ class DefaultStartDMActionTest {
2829
val matrixClient = FakeMatrixClient().apply {
2930
givenFindDmResult(A_ROOM_ID)
3031
}
31-
val action = createStartDMAction(matrixClient)
32+
val analyticsService = FakeAnalyticsService()
33+
val action = createStartDMAction(matrixClient, analyticsService)
3234
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
33-
action.execute(A_USER_ID, state)
35+
action.execute(aMatrixUser(), true, state)
3436
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
37+
assertThat(analyticsService.capturedEvents).isEmpty()
3538
}
3639

3740
@Test
@@ -43,21 +46,38 @@ class DefaultStartDMActionTest {
4346
val analyticsService = FakeAnalyticsService()
4447
val action = createStartDMAction(matrixClient, analyticsService)
4548
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
46-
action.execute(A_USER_ID, state)
49+
action.execute(aMatrixUser(), true, state)
4750
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
4851
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
4952
}
5053

54+
@Test
55+
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
56+
val matrixClient = FakeMatrixClient().apply {
57+
givenFindDmResult(null)
58+
givenCreateDmResult(Result.success(A_ROOM_ID))
59+
}
60+
val analyticsService = FakeAnalyticsService()
61+
val action = createStartDMAction(matrixClient, analyticsService)
62+
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
63+
val matrixUser = aMatrixUser()
64+
action.execute(matrixUser, false, state)
65+
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
66+
assertThat(analyticsService.capturedEvents).isEmpty()
67+
}
68+
5169
@Test
5270
fun `when dm creation fails, assert state is updated with given error`() = runTest {
5371
val matrixClient = FakeMatrixClient().apply {
5472
givenFindDmResult(null)
5573
givenCreateDmResult(Result.failure(A_THROWABLE))
5674
}
57-
val action = createStartDMAction(matrixClient)
75+
val analyticsService = FakeAnalyticsService()
76+
val action = createStartDMAction(matrixClient, analyticsService)
5877
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
59-
action.execute(A_USER_ID, state)
78+
action.execute(aMatrixUser(), true, state)
6079
assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
80+
assertThat(analyticsService.capturedEvents).isEmpty()
6181
}
6282

6383
private fun createStartDMAction(

features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt

+106-16
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,29 @@
77

88
package io.element.android.features.createroom.impl.root
99

10+
import androidx.compose.runtime.MutableState
1011
import app.cash.molecule.RecompositionMode
1112
import app.cash.molecule.moleculeFlow
1213
import app.cash.turbine.test
1314
import com.google.common.truth.Truth.assertThat
15+
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
1416
import io.element.android.features.createroom.api.StartDMAction
1517
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
1618
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
1719
import io.element.android.features.createroom.impl.userlist.UserListDataStore
1820
import io.element.android.features.createroom.test.FakeStartDMAction
1921
import io.element.android.libraries.architecture.AsyncAction
22+
import io.element.android.libraries.matrix.api.core.RoomId
2023
import io.element.android.libraries.matrix.api.core.UserId
2124
import io.element.android.libraries.matrix.api.user.MatrixUser
2225
import io.element.android.libraries.matrix.test.A_ROOM_ID
2326
import io.element.android.libraries.matrix.test.A_THROWABLE
2427
import io.element.android.libraries.matrix.test.core.aBuildMeta
2528
import io.element.android.libraries.usersearch.test.FakeUserRepository
2629
import io.element.android.tests.testutils.WarmUpRule
30+
import io.element.android.tests.testutils.lambda.any
31+
import io.element.android.tests.testutils.lambda.lambdaRecorder
32+
import io.element.android.tests.testutils.lambda.value
2733
import kotlinx.coroutines.test.runTest
2834
import org.junit.Rule
2935
import org.junit.Test
@@ -33,46 +39,130 @@ class CreateRoomRootPresenterTest {
3339
val warmUpRule = WarmUpRule()
3440

3541
@Test
36-
fun `present - start DM action complete scenario`() = runTest {
37-
val startDMAction = FakeStartDMAction()
42+
fun `present - start DM action failure scenario`() = runTest {
43+
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
44+
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
45+
actionState.value = startDMFailureResult
46+
}
47+
val startDMAction = FakeStartDMAction(executeResult = executeResult)
3848
val presenter = createCreateRoomRootPresenter(startDMAction)
3949
moleculeFlow(RecompositionMode.Immediate) {
4050
presenter.present()
4151
}.test {
4252
val initialState = awaitItem()
43-
4453
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
4554
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
4655
assertThat(initialState.userListState.selectedUsers).isEmpty()
4756
assertThat(initialState.userListState.isSearchActive).isFalse()
4857
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
49-
5058
val matrixUser = MatrixUser(UserId("@name:domain"))
51-
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
52-
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
53-
54-
// Failure
55-
startDMAction.givenExecuteResult(startDMFailureResult)
5659
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
57-
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
5860
awaitItem().also { state ->
5961
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
62+
executeResult.assertions().isCalledOnce().with(
63+
value(matrixUser),
64+
value(false),
65+
any(),
66+
)
6067
state.eventSink(CreateRoomRootEvents.CancelStartDM)
6168
}
62-
63-
// Success
64-
startDMAction.givenExecuteResult(startDMSuccessResult)
6569
awaitItem().also { state ->
66-
assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized)
67-
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
70+
assertThat(state.startDmAction.isUninitialized()).isTrue()
6871
}
69-
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
72+
}
73+
}
74+
75+
@Test
76+
fun `present - start DM action success scenario`() = runTest {
77+
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
78+
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
79+
actionState.value = startDMSuccessResult
80+
}
81+
val startDMAction = FakeStartDMAction(executeResult = executeResult)
82+
val presenter = createCreateRoomRootPresenter(startDMAction)
83+
moleculeFlow(RecompositionMode.Immediate) {
84+
presenter.present()
85+
}.test {
86+
val initialState = awaitItem()
87+
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
88+
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
89+
assertThat(initialState.userListState.selectedUsers).isEmpty()
90+
assertThat(initialState.userListState.isSearchActive).isFalse()
91+
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
92+
val matrixUser = MatrixUser(UserId("@name:domain"))
93+
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
7094
awaitItem().also { state ->
7195
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
96+
executeResult.assertions().isCalledOnce().with(
97+
value(matrixUser),
98+
value(false),
99+
any(),
100+
)
72101
}
73102
}
74103
}
75104

105+
@Test
106+
fun `present - start DM action confirmation scenario - cancel`() = runTest {
107+
val matrixUser = MatrixUser(UserId("@name:domain"))
108+
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
109+
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
110+
actionState.value = startDMConfirmationResult
111+
}
112+
val startDMAction = FakeStartDMAction(executeResult = executeResult)
113+
val presenter = createCreateRoomRootPresenter(startDMAction)
114+
moleculeFlow(RecompositionMode.Immediate) {
115+
presenter.present()
116+
}.test {
117+
val initialState = awaitItem()
118+
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
119+
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
120+
val confirmingState = awaitItem()
121+
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
122+
executeResult.assertions().isCalledOnce().with(
123+
value(matrixUser),
124+
value(false),
125+
any(),
126+
)
127+
// Cancelling should not create the DM
128+
confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM)
129+
val finalState = awaitItem()
130+
assertThat(finalState.startDmAction.isUninitialized()).isTrue()
131+
executeResult.assertions().isCalledExactly(1)
132+
}
133+
}
134+
135+
@Test
136+
fun `present - start DM action confirmation scenario - confirm`() = runTest {
137+
val matrixUser = MatrixUser(UserId("@name:domain"))
138+
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
139+
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
140+
actionState.value = startDMConfirmationResult
141+
}
142+
val startDMAction = FakeStartDMAction(executeResult = executeResult)
143+
val presenter = createCreateRoomRootPresenter(startDMAction)
144+
moleculeFlow(RecompositionMode.Immediate) {
145+
presenter.present()
146+
}.test {
147+
val initialState = awaitItem()
148+
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
149+
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
150+
val confirmingState = awaitItem()
151+
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
152+
executeResult.assertions().isCalledOnce().with(
153+
value(matrixUser),
154+
value(false),
155+
any(),
156+
)
157+
// Start DM again should invoke the action with createIfDmDoesNotExist = true
158+
confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
159+
executeResult.assertions().isCalledExactly(2).withSequence(
160+
listOf(value(matrixUser), value(false), any()),
161+
listOf(value(matrixUser), value(true), any()),
162+
)
163+
}
164+
}
165+
76166
private fun createCreateRoomRootPresenter(
77167
startDMAction: StartDMAction = FakeStartDMAction(),
78168
): CreateRoomRootPresenter {

0 commit comments

Comments
 (0)