Let the user switch account from the room select screen when sharing

When several accounts are signed in, sharing into the app no longer goes through the account selection screen. The room select screen (Share mode) now displays the selected account and an account switch on top of the room list, with a design similar to the multi account section of the settings.

Selecting another account displays the rooms of this account in place, scrolled to the top, without recreating the screen. The content is then shared from the room select screen using the selected account, with the same progress and error dialogs as when sharing with the current account. The current account is switched only once the share has succeeded, then the room is opened if the content has been shared to a single room.

Implementation:
- RoomSelectNode is now in the AppScope and uses MatrixClientProvider to get the room list of the selected session.
- ShareNode gets the SharePresenter of the selected session from its session graph, using the new ShareBindings.
- SessionGraphFactory is moved to the matrix api module so that it can be used by features.

The account selection screen is still used for permalinks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-02 16:09:32 +02:00
parent dc031dd0e6
commit 792e0adde3
25 changed files with 634 additions and 125 deletions
@@ -10,8 +10,8 @@ package io.element.android.x.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.SessionGraphFactory
@ContributesBinding(AppScope::class)
class DefaultSessionGraphFactory(
@@ -26,13 +26,15 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
@@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
@@ -88,6 +88,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -173,6 +174,7 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -574,10 +576,16 @@ class LoggedInFlowNode(
buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
if (sessionId != matrixClient.sessionId) {
// The data has been shared using another session, switch to it
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
return
}
// Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch {
@@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId
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.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
@@ -349,8 +350,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?,
val permalinkData: PermalinkData,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
@@ -389,6 +389,15 @@ class RootFlowNode(
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
lifecycleScope.launch {
val loggedInFlowNode = attachSession(sessionId)
roomId?.let {
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
}
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) {
@@ -463,13 +472,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
}
}
@@ -539,21 +542,9 @@ class RootFlowNode(
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = shareIntentData,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(shareIntentData)
}
// wait for the current session to be restored, then attach the incoming share node.
// In case of multiple accounts, the user can switch account from the room select screen.
attachSession(latestSessionId).attachIncomingShare(shareIntentData)
}
}
@@ -576,7 +567,6 @@ class RootFlowNode(
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData,
)
)
@@ -20,13 +20,13 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.parcelize.Parcelize
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
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.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -40,6 +41,7 @@ class ForwardMessagesNode(
@Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -62,7 +64,8 @@ class ForwardMessagesNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
// In Forward mode, the session cannot be changed
presenter.onRoomSelected(roomIds)
}
@@ -75,6 +78,7 @@ class ForwardMessagesNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -39,6 +40,7 @@ class DefaultForwardEntryPointTest {
plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ForwardEntryPoint.Callback {
@@ -13,6 +13,7 @@ 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
import io.element.android.libraries.matrix.api.core.SessionId
interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val shareIntentData: ShareIntentData)
@@ -25,6 +26,11 @@ interface ShareEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
fun onDone(roomIds: List<RoomId>)
/**
* Called when the share is done, or cancelled.
* @param sessionId the session which has been used to share the data. It can be different from the current session.
* @param roomIds the rooms the data has been shared to, empty if the share has been cancelled.
*/
fun onDone(sessionId: SessionId, roomIds: List<RoomId>)
}
}
@@ -11,7 +11,12 @@ package io.element.android.features.share.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
@@ -23,14 +28,20 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.impl.di.ShareBindings
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -39,6 +50,9 @@ class ShareNode(
@Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
private val matrixClientProvider: MatrixClientProvider,
private val sessionGraphFactory: SessionGraphFactory,
) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -52,18 +66,57 @@ class ShareNode(
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
/**
* The presenter used to share the data, and the session it belongs to.
*/
private data class ActiveShare(
val sessionId: SessionId,
val presenter: SharePresenter,
)
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback()
private val currentSessionShare = ActiveShare(
sessionId = sessionId,
presenter = presenterFactory.create(inputs.shareIntentData),
)
private var activeShare by mutableStateOf(currentSessionShare)
private fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
if (sessionId == currentSessionShare.sessionId) {
activeShare = currentSessionShare
currentSessionShare.presenter.onRoomSelected(roomIds)
} else if (sessionId == activeShare.sessionId) {
// Retry with the same other session
activeShare.presenter.onRoomSelected(roomIds)
} else {
lifecycleScope.launch {
// Share using the graph of the selected session, the current session will be changed once the share is done
matrixClientProvider.getOrRestore(sessionId)
.onSuccess { client ->
val presenterFactory = (sessionGraphFactory.create(client) as ShareBindings).sharePresenterFactory()
activeShare = ActiveShare(
sessionId = sessionId,
presenter = presenterFactory.create(inputs.shareIntentData),
).also {
it.presenter.onRoomSelected(roomIds)
}
}
.onFailure {
Timber.e(it, "Failed to get the client for session $sessionId")
}
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
presenter.onRoomSelected(roomIds)
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
this@ShareNode.onRoomSelected(sessionId, roomIds)
}
override fun onCancel() {
callback.onDone(emptyList())
callback.onDone(sessionId, emptyList())
}
}
@@ -71,6 +124,7 @@ class ShareNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -86,11 +140,14 @@ class ShareNode(
navModel = navModel,
)
val state = presenter.present()
ShareView(
state = state,
onShareSuccess = callback::onDone,
)
val currentShare = activeShare
key(currentShare) {
val state = currentShare.presenter.present()
ShareView(
state = state,
onShareSuccess = { roomIds -> callback.onDone(currentShare.sessionId, roomIds) },
)
}
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations 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.share.impl.di
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.share.impl.SharePresenter
import io.element.android.libraries.di.SessionScope
/**
* Used to get a [SharePresenter.Factory] from the graph of a session which is not the current one.
*/
@ContributesTo(SessionScope::class)
interface ShareBindings {
fun sharePresenterFactory(): SharePresenter.Factory
}
@@ -14,8 +14,13 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@@ -39,10 +44,15 @@ class DefaultShareEntryPointTest {
plugins = plugins,
presenterFactory = { createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
matrixClientProvider = FakeMatrixClientProvider(),
sessionGraphFactory = object : SessionGraphFactory {
override fun create(client: MatrixClient): Any = lambdaError()
},
)
}
val callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
}
val params = ShareEntryPoint.Params(
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
@@ -6,10 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.di
import io.element.android.libraries.matrix.api.MatrixClient
package io.element.android.libraries.matrix.api
/**
* Create the dependency injection graph of a session.
* It can be used to get session scoped dependencies of a session which is not the current one.
*/
interface SessionGraphFactory {
fun create(client: MatrixClient): Any
}
@@ -13,9 +13,16 @@ 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
import io.element.android.libraries.matrix.api.core.SessionId
interface RoomSelectEntryPoint : FeatureEntryPoint {
/**
* @param sessionId the session to use initially. In [RoomSelectMode.Share] mode, the user can select another session.
* @param mode the mode of the screen.
* @param maxNumberOfRooms the maximum number of rooms the user can select.
*/
data class Params(
val sessionId: SessionId,
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
)
@@ -28,7 +35,11 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
fun onRoomSelected(roomIds: List<RoomId>)
/**
* @param sessionId the session which has been selected by the user, the rooms belong to this session.
* @param roomIds the selected rooms.
*/
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>)
fun onCancel()
}
@@ -32,10 +32,12 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode(
parentNode: Node,
@@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(
sessionId = params.sessionId,
mode = params.mode,
maxNumberOfRooms = params.maxNumberOfRooms,
),
@@ -8,10 +8,13 @@
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@@ -22,11 +23,11 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class)
@ContributesNode(AppScope::class)
@AssistedInject
class RoomSelectNode(
@Assisted buildContext: BuildContext,
@@ -34,12 +35,14 @@ class RoomSelectNode(
presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val sessionId: SessionId,
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId,
mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms,
)
@@ -52,7 +55,7 @@ class RoomSelectNode(
RoomSelectView(
state = state,
onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected,
onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) },
modifier = modifier
)
}
@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -15,7 +16,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -24,22 +27,34 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject
class RoomSelectPresenter(
@Assisted private val initialSessionId: SessionId,
@Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
private val matrixClientProvider: MatrixClientProvider,
private val sessionStore: SessionStore,
) : Presenter<RoomSelectState> {
@AssistedFactory
fun interface Factory {
fun create(
initialSessionId: SessionId,
mode: RoomSelectMode,
maxNumberOfRooms: Int,
): RoomSelectPresenter
@@ -50,18 +65,51 @@ class RoomSelectPresenter(
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
var isAccountListExpanded by remember { mutableStateOf(false) }
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val accounts by remember {
sessionStore.sessionsFlow().map { list ->
list.map { it.toMatrixUser() }
}
}.collectAsState(initial = emptyList())
// Fallback to a MatrixUser with only the userId until the sessions are loaded
val selectedAccount = accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId)
val otherAccounts by remember {
derivedStateOf {
accounts.filter { it.userId != selectedSessionId }.toImmutableList()
}
}
val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val coroutineScope = rememberCoroutineScope()
// Create one data source per session. When the selected session changes, the previous
// data source is removed from the composition and its coroutine scope is cancelled.
val dataSource = key(selectedSessionId) {
val dataSourceCoroutineScope = rememberCoroutineScope()
// The sessions are restored at startup, so the client should already be in memory
val roomListService by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService) {
if (value == null) {
value = matrixClientProvider.getOrRestore(selectedSessionId)
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") }
.getOrNull()
?.roomListService
}
}
remember(roomListService) {
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) }
}
}
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
val searchQuery = queryState.text.toString()
LaunchedEffect(dataSource, searchQuery) {
dataSource?.setSearchQuery(searchQuery)
}
// Use a key so that the rooms of the previously selected session are not rendered while loading the new ones
val roomSummaryDetailsList by key(dataSource) {
(dataSource?.roomInfoList ?: flowOf(persistentListOf())).collectAsState(initial = persistentListOf())
}
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>>(dataSource) {
derivedStateOf {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
@@ -82,8 +130,16 @@ class RoomSelectPresenter(
}
}
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId
// The selected rooms and the search query are specific to the session
selectedRooms = persistentListOf()
queryState.clearText()
isAccountListExpanded = false
}
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
dataSource?.updateVisibleRange(event.range)
}
}
}
@@ -95,6 +151,9 @@ class RoomSelectPresenter(
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = ::handleEvent,
)
}
@@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30
@AssistedInject
class RoomSelectSearchDataSource(
@Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService,
@Assisted roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource
fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource
}
private val roomList = roomListService.createRoomList(
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
@@ -21,7 +22,15 @@ data class RoomSelectState(
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser,
val otherAccounts: ImmutableList<MatrixUser>,
val isAccountListExpanded: Boolean,
val eventSink: (RoomSelectEvent) -> Unit,
) {
val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms
/**
* The account switch is only displayed when sharing, and if there are several accounts.
*/
val showAccountSwitch = mode == RoomSelectMode.Share && otherAccounts.isNotEmpty()
}
@@ -13,6 +13,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
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.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@@ -47,6 +49,19 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
selectedRooms = aRoomSelectRoomList().subList(0, 1),
maxNumberOfRooms = 1,
),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = anOtherAccountList(),
),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = anOtherAccountList(),
isAccountListExpanded = true,
),
)
}
@@ -57,6 +72,9 @@ internal fun aRoomSelectState(
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
otherAccounts: ImmutableList<MatrixUser> = persistentListOf(),
isAccountListExpanded: Boolean = false,
eventSink: (RoomSelectEvent) -> Unit = {},
) = RoomSelectState(
mode = mode,
@@ -65,9 +83,17 @@ internal fun aRoomSelectState(
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = eventSink,
)
private fun anOtherAccountList() = persistentListOf(
aMatrixUser(id = "@bob:example.org", displayName = "Bob"),
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
)
internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
@@ -9,6 +9,11 @@
package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,7 +26,6 @@ import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -31,25 +35,34 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
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.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -60,6 +73,10 @@ import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffec
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -68,6 +85,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-38510
*/
@Suppress("MultipleEmitters") // False positive
@Composable
fun RoomSelectView(
@@ -111,7 +131,8 @@ fun RoomSelectView(
onBack = { onBackButton(state) }
)
val lazyListState = rememberLazyListState()
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() }
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
}
@@ -161,6 +182,12 @@ fun RoomSelectView(
resultState = state.resultState,
showBackButton = false,
) { summaries ->
if (summaries.isNotEmpty()) {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
@@ -172,7 +199,45 @@ fun RoomSelectView(
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
}
}
}
if (!state.isSearchActive) {
if (state.showAccountSwitch) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
if (state.resultState is SearchBarResultState.Results) {
if (state.resultState.results.isNotEmpty()) {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
@@ -181,34 +246,6 @@ fun RoomSelectView(
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
if (!state.isSearchActive) {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = true,
)
if (state.resultState is SearchBarResultState.Results) {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
@@ -217,6 +254,103 @@ fun RoomSelectView(
}
}
/**
* Similar to the MultiAccountSection of the PreferencesRootView.
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759
*/
@Composable
private fun AccountSwitchSection(
selectedAccount: MatrixUser,
otherAccounts: ImmutableList<MatrixUser>,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
onSelectAccount: (SessionId) -> Unit,
) {
Column {
Spacer(modifier = Modifier.height(8.dp))
MatrixUserHeader(
matrixUser = selectedAccount,
)
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
val expandedStateDescription = if (isExpanded) {
stringResource(CommonStrings.a11y_state_expanded)
} else {
stringResource(CommonStrings.a11y_state_collapsed)
}
ListItem(
modifier = Modifier.semantics {
stateDescription = expandedStateDescription
},
content = { Text(stringResource(CommonStrings.common_switch_account)) },
onClick = onToggleExpand,
trailingContent = ListItemContent.Custom { _ ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = !isExpanded,
enter = fadeIn(),
exit = fadeOut(),
) {
AvatarRow(
avatarDataList = otherAccounts
.take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(),
avatarType = AvatarType.User,
lastOnTop = true,
)
}
// Animate the chevron icon to rotate when the section is expanded/collapsed
val rotation: Float by animateFloatAsState(
targetValue = if (isExpanded) -180f else 0f,
animationSpec = tween(
delayMillis = 0,
durationMillis = 300,
),
label = "chevron"
)
Icon(
modifier = Modifier.rotate(rotation),
imageVector = CompoundIcons.ChevronDown(),
contentDescription = null,
)
}
},
)
AnimatedVisibility(
visible = isExpanded,
) {
Column {
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
otherAccounts.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier
.clickable {
onSelectAccount(matrixUser.userId)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
)
}
}
}
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
}
@Composable
private fun SelectedRooms(
selectedRooms: ImmutableList<SelectRoomInfo>,
@@ -241,28 +375,25 @@ private fun RoomSummaryView(
canBeSelected: Boolean,
onSelection: (SelectRoomInfo) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onSelection(roomInfo) }
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
ListItem(
onClick = { onSelection(roomInfo) },
leadingContent = ListItemContent.Custom {
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
},
trailingContent = ListItemContent.Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
),
) {
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.weight(1f)
) {
Column {
// Name
Text(
style = ElementTheme.typography.fontBodyLgRegular,
@@ -284,11 +415,6 @@ private fun RoomSummaryView(
)
}
}
Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
onCheckedChange = { onSelection(roomInfo) },
)
}
}
@@ -12,6 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.lambda.lambdaError
@@ -36,7 +38,8 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
RoomSelectNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { mode, maxNumberOfRooms ->
presenterFactory = { initialSessionId, mode, maxNumberOfRooms ->
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
assertThat(mode).isEqualTo(testMode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
createRoomSelectPresenter(mode, maxNumberOfRooms)
@@ -44,10 +47,11 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
)
}
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) = lambdaError()
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
}
val params = RoomSelectEntryPoint.Params(
sessionId = A_SESSION_ID,
mode = testMode,
maxNumberOfRooms = testMaxNumberOfRooms,
)
@@ -58,7 +62,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
callback = callback,
)
assertThat(result).isInstanceOf(RoomSelectNode::class.java)
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms))
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.sessionId, params.mode, params.maxNumberOfRooms))
assertThat(result.plugins).contains(callback)
}
}
@@ -11,15 +11,27 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
@@ -46,9 +58,99 @@ class RoomSelectPresenterTest {
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
assertThat(initialState.otherAccounts).isEmpty()
assertThat(initialState.isAccountListExpanded).isFalse()
assertThat(initialState.showAccountSwitch).isFalse()
}
}
@Test
fun `present - share mode with a single account does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
),
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat(state.otherAccounts).isEmpty()
assertThat(state.showAccountSwitch).isFalse()
}
}
@Test
fun `present - share mode with several accounts shows the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat(state.selectedAccount.displayName).isEqualTo("Alice")
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
assertThat(state.showAccountSwitch).isTrue()
state.eventSink(RoomSelectEvent.ToggleAccountListExpanded)
assertThat(awaitItem().isAccountListExpanded).isTrue()
state.eventSink(RoomSelectEvent.ToggleAccountListExpanded)
assertThat(awaitItem().isAccountListExpanded).isFalse()
}
}
@Test
fun `present - select another account displays the rooms of this account`() = runTest {
val roomSummary1 = aRoomSummary(roomId = A_ROOM_ID)
val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2)
val roomListServices = mapOf(
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary1))) }),
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }),
)
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
},
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat((state.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID)
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary1.toSelectRoomInfo()))
state.eventSink(RoomSelectEvent.ToggleAccountListExpanded)
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val finalState = awaitLastSequentialItem()
assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2)
assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID)
assertThat(finalState.selectedRooms).isEmpty()
assertThat(finalState.isAccountListExpanded).isFalse()
assertThat((finalState.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID_2)
}
}
@Test
fun `present - forward mode with several accounts does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Forward,
sessionStore = aSessionStoreWithTwoAccounts(),
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.otherAccounts).isNotEmpty()
assertThat(state.showAccountSwitch).isFalse()
}
}
private fun aSessionStoreWithTwoAccounts() = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice"),
aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob"),
),
)
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
@@ -157,16 +259,23 @@ internal fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
roomListService: RoomListService = FakeRoomListService(),
sessionStore: SessionStore = InMemorySessionStore(),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
},
) = RoomSelectPresenter(
initialSessionId = A_SESSION_ID,
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource {
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource(
coroutineScope = coroutineScope,
roomListService = roomListService,
coroutineDispatchers = testCoroutineDispatchers(),
)
}
}
},
matrixClientProvider = matrixClientProvider,
sessionStore = sessionStore,
)
@@ -14,13 +14,19 @@ import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
@@ -72,6 +78,50 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room with a single hero").assertIsDisplayed()
onNodeWithText("@alice:example.org").assertDoesNotExist()
}
@Test
fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomSelectEvent>()
setRoomSelectView(
aRoomSelectState(
mode = RoomSelectMode.Share,
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")),
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_switch_account)
// The first event is the initial UpdateVisibleRange
eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.ToggleAccountListExpanded))
}
@Test
fun `clicking on another account emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomSelectEvent>()
setRoomSelectView(
aRoomSelectState(
mode = RoomSelectMode.Share,
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")),
isAccountListExpanded = true,
eventSink = eventsRecorder,
),
)
onNodeWithText("Bob").performClick()
// The first event is the initial UpdateVisibleRange
eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.SelectAccount(UserId("@bob:example.org"))))
}
@Test
fun `the account switch is not displayed if there is no other account`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
mode = RoomSelectMode.Share,
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
),
)
onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(