Create or retrieve DM

This commit is contained in:
Florian Renaud
2023-03-24 16:01:14 +01:00
parent 7aa1c26e2e
commit 6450fc5724
17 changed files with 220 additions and 28 deletions

View File

@@ -32,6 +32,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -178,7 +179,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.build()
}
NavTarget.CreateRoom -> {
createRoomEntryPoint.createNode(this, buildContext)
val callback = object : CreateRoomEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.replace(NavTarget.Room(roomId))
}
}
createRoomEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext)

View File

@@ -24,4 +24,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

@@ -16,6 +16,21 @@
package io.element.android.features.createroom.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : SimpleFeatureEntryPoint
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
}
}

View File

@@ -23,17 +23,20 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -64,6 +67,10 @@ class CreateRoomFlowNode @AssistedInject constructor(
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
override fun onOpenRoom(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
}
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
}

View File

@@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
@@ -26,7 +27,21 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreateRoomEntryPoint.NodeBuilder {
override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins)
}
}
}
}

View File

@@ -19,6 +19,8 @@ package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface CreateRoomRootEvents {
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object InvitePeople : CreateRoomRootEvents
data class SelectUser(val matrixUser: MatrixUser) : CreateRoomRootEvents
data class CreateDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CancelCreateDM : CreateRoomRootEvents
}

View File

@@ -16,7 +16,6 @@
package io.element.android.features.createroom.impl.root
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
@@ -27,7 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor(
@@ -38,15 +37,17 @@ class CreateRoomRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onCreateNewRoom()
fun onOpenRoom(roomId: RoomId)
}
private fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
private val callback = object : Callback {
override fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
override fun onOpenRoom(roomId: RoomId) {
plugins<Callback>().forEach { it.onOpenRoom(roomId) }
}
}
@Composable
@@ -56,7 +57,8 @@ class CreateRoomRootNode @AssistedInject constructor(
state = state,
modifier = modifier,
onClosePressed = this::navigateUp,
onNewRoomClicked = this::onCreateNewRoom,
onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onOpenRoom,
)
}
}

View File

@@ -17,16 +17,30 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.selectusers.api.SelectUsersEvents
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: SelectUsersPresenter.Factory,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
@@ -37,20 +51,44 @@ class CreateRoomRootPresenter @Inject constructor(
override fun present(): CreateRoomRootState {
val selectUsersState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
var showCreateDmConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser)
is CreateRoomRootEvents.SelectUser -> {
val existingDM = matrixClient.findDM(event.matrixUser.id)
if (existingDM == null) {
showCreateDmConfirmationDialog = true
} else {
startDmAction.value = Async.Success(existingDM.roomId)
}
}
is CreateRoomRootEvents.CreateDM -> {
showCreateDmConfirmationDialog = false
localCoroutineScope.createDM(event.matrixUser, startDmAction)
}
CreateRoomRootEvents.CancelCreateDM -> {
showCreateDmConfirmationDialog = false
selectUsersState.eventSink(SelectUsersEvents.ClearSelection)
}
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
}
}
return CreateRoomRootState(
selectUsersState = selectUsersState,
showCreateDmConfirmationDialog = showCreateDmConfirmationDialog,
startDmAction = startDmAction.value,
eventSink = ::handleEvents,
)
}
private fun handleStartDM(matrixUser: MatrixUser) {
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
suspend {
matrixClient.createDM(user.id).getOrThrow()
}.execute(startDmAction)
}
}

View File

@@ -17,8 +17,12 @@
package io.element.android.features.createroom.impl.root
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState(
val selectUsersState: SelectUsersState,
val showCreateDmConfirmationDialog: Boolean,
val startDmAction: Async<RoomId>,
val eventSink: (CreateRoomRootEvents) -> Unit,
)

View File

@@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.libraries.architecture.Async
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState>
@@ -28,5 +29,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
selectUsersState = aSelectUsersState(),
startDmAction = Async.Uninitialized,
showCreateDmConfirmationDialog = false,
selectUsersState = aSelectUsersState(),
)

View File

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -39,6 +40,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
@@ -46,6 +50,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@@ -56,7 +62,14 @@ fun CreateRoomRootView(
modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {},
onNewRoomClicked: () -> Unit = {},
onOpenDM: (RoomId) -> Unit = {},
) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state)
}
}
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
@@ -72,7 +85,7 @@ fun CreateRoomRootView(
SelectUsersView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) },
onUserSelected = { state.eventSink(CreateRoomRootEvents.SelectUser(it)) },
)
if (!state.selectUsersState.isSearchActive) {
@@ -83,6 +96,12 @@ fun CreateRoomRootView(
}
}
}
CreateDmConfirmationDialog(state)
if (state.startDmAction is Async.Loading) {
ProgressDialog(text = "Creating room...")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -153,6 +172,26 @@ fun CreateRoomActionButton(
}
}
@Composable
fun CreateDmConfirmationDialog(
state: CreateRoomRootState,
modifier: Modifier = Modifier,
) {
if (state.showCreateDmConfirmationDialog) {
val selectedUser = state.selectUsersState.selectedUsers.firstOrNull()
if (selectedUser != null) {
ConfirmationDialog(
modifier = modifier,
title = "Start chat",
content = "You're about starting a chat with ${selectedUser.getBestName()}, do you want to continue?",
submitText = stringResource(io.element.android.libraries.ui.strings.R.string._continue),
onSubmitClicked = { state.eventSink(CreateRoomRootEvents.CreateDM(selectedUser)) },
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelCreateDM) },
)
}
}
}
@Preview
@Composable
fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =

View File

@@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -34,13 +35,15 @@ import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@Before
fun setup() {
val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args)
}
presenter = CreateRoomRootPresenter(selectUsersPresenter)
fakeMatrixClient = FakeMatrixClient()
presenter = CreateRoomRootPresenter(selectUsersPresenter, fakeMatrixClient)
}
@Test
@@ -64,13 +67,13 @@ class CreateRoomRootPresenterTests {
}
@Test
fun `present - trigger start DM action`() = runTest {
fun `present - trigger select user action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
initialState.eventSink(CreateRoomRootEvents.SelectUser(matrixUser))
}
}
}

View File

@@ -22,5 +22,6 @@ sealed interface SelectUsersEvents {
data class UpdateSearchQuery(val query: String) : SelectUsersEvents
data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents
object ClearSelection : SelectUsersEvents
data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents
}

View File

@@ -79,6 +79,7 @@ class DefaultSelectUsersPresenter @AssistedInject constructor(
localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState)
}
is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList()
SelectUsersEvents.ClearSelection -> selectedUsers.value = persistentListOf()
}
}

View File

@@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
@@ -28,6 +29,8 @@ interface MatrixClient : Closeable {
val sessionId: SessionId
val roomSummaryDataSource: RoomSummaryDataSource
fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun createDM(userId: UserId): Result<RoomId>
fun findDM(userId: UserId): MatrixRoom?
fun startSync()
fun stopSync()
fun mediaResolver(): MediaResolver

View File

@@ -37,7 +37,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.RoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
@@ -154,6 +157,30 @@ class RustMatrixClient constructor(
)
}
override fun findDM(userId: UserId): MatrixRoom? {
val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
return roomId?.let { getRoom(it) }
}
override suspend fun createDM(userId: UserId): Result<RoomId> =
withContext(dispatchers.io) {
runCatching {
val roomId = client.createRoom(
CreateRoomParameters(
name = "",
topic = null,
isEncrypted = true,
isDirect = true,
visibility = RoomVisibility.PRIVATE,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(userId.value),
avatar = null,
)
)
RoomId(roomId)
}
}
override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test
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.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
@@ -37,12 +38,22 @@ class FakeMatrixClient(
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService()
) : MatrixClient {
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
private var logoutFailure: Throwable? = null
override fun getRoom(roomId: RoomId): MatrixRoom? {
return FakeMatrixRoom(roomId)
}
override suspend fun createDM(userId: UserId): Result<RoomId> {
return createDmResult
}
override fun findDM(userId: UserId): MatrixRoom? {
return findDmResult
}
override fun startSync() = Unit
override fun stopSync() = Unit
@@ -51,10 +62,6 @@ class FakeMatrixClient(
return FakeMediaResolver()
}
fun givenLogoutError(failure: Throwable) {
logoutFailure = failure
}
override suspend fun logout() {
delay(100)
logoutFailure?.let { throw it }
@@ -81,4 +88,18 @@ class FakeMatrixClient(
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun onSlidingSyncUpdate() {}
// Mocks
fun givenLogoutError(failure: Throwable) {
logoutFailure = failure
}
fun givenCreateDmResult(result: Result<RoomId>) {
createDmResult = result
}
fun givenFindDmResult(result: MatrixRoom?) {
findDmResult = result
}
}