mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge pull request #7839 from element-hq/feature/bma/multiAccountShare
Multi account: switch account from the room select screen when sharing
This commit is contained in:
@@ -33,6 +33,8 @@ 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.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,
|
||||
)
|
||||
)
|
||||
|
||||
+5
-1
@@ -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,
|
||||
),
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
-7
@@ -8,8 +8,8 @@
|
||||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder {
|
||||
matrixUser
|
||||
} else {
|
||||
// Use the data from the DB
|
||||
MatrixUser(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
it.toMatrixUser()
|
||||
}
|
||||
}
|
||||
.let { sessionList ->
|
||||
|
||||
+2
-10
@@ -29,8 +29,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -85,14 +84,7 @@ class PreferencesRootPresenter(
|
||||
sessionStore.sessionsFlow().map { list ->
|
||||
list
|
||||
.filter { it.userId != matrixClient.sessionId.value }
|
||||
.map {
|
||||
MatrixUser(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.map { it.toMatrixUser() }
|
||||
.toImmutableList()
|
||||
}
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
+7
-1
@@ -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>)
|
||||
}
|
||||
}
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.share.api.OnSharedData
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
|
||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultShareDataSender(
|
||||
private val mediaSenderRoomFactory: MediaSenderRoomFactory,
|
||||
private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory,
|
||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||
private val onSharedData: OnSharedData,
|
||||
) : ShareDataSender {
|
||||
override suspend fun send(
|
||||
client: MatrixClient,
|
||||
shareIntentData: ShareIntentData,
|
||||
roomIds: List<RoomId>,
|
||||
): Result<Unit> = runCatchingExceptions {
|
||||
val result = when (shareIntentData) {
|
||||
is ShareIntentData.PlainText -> {
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
client.findJoinedRoom(roomId)?.liveTimeline?.sendMessage(
|
||||
body = shareIntentData.content,
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList(),
|
||||
)?.isSuccess.orFalse()
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
is ShareIntentData.Uris -> {
|
||||
val filesToShare = shareIntentData.uris
|
||||
if (filesToShare.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
// The media optimization config is the same for all the files and all the rooms
|
||||
val mediaOptimizationConfig = mediaOptimizationConfigProviderFactory.create(client).get()
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
val room = client.findJoinedRoom(roomId) ?: return@map false
|
||||
val mediaSender = mediaSenderRoomFactory.create(client = client, room = room)
|
||||
filesToShare
|
||||
.map { fileToShare ->
|
||||
val result = mediaSender.sendMedia(
|
||||
caption = shareIntentData.text,
|
||||
uri = fileToShare.uri,
|
||||
mimeType = fileToShare.mimeType,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
// If the coroutine was cancelled, destroy the room and rethrow the exception
|
||||
val cancellationException = result.exceptionOrNull() as? CancellationException
|
||||
if (cancellationException != null) {
|
||||
if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) {
|
||||
room.destroy()
|
||||
}
|
||||
throw cancellationException
|
||||
}
|
||||
result.isSuccess
|
||||
}
|
||||
.all { isSuccess -> isSuccess }
|
||||
.also {
|
||||
if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) {
|
||||
room.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle post-processing of shared data
|
||||
onSharedData(shareIntentData)
|
||||
|
||||
if (!result) {
|
||||
error("Failed to handle incoming share intent")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MatrixClient.findJoinedRoom(roomId: RoomId): JoinedRoom? {
|
||||
return activeRoomsHolder.getActiveRoom(sessionId)
|
||||
?.takeIf { it.roomId == roomId }
|
||||
?: getJoinedRoom(roomId)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
/**
|
||||
* Sends the shared data to some rooms of a session.
|
||||
*/
|
||||
interface ShareDataSender {
|
||||
/**
|
||||
* Send [shareIntentData] to the rooms with [roomIds], using the session of [client].
|
||||
*/
|
||||
suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List<RoomId>): Result<Unit>
|
||||
}
|
||||
+8
-5
@@ -28,6 +28,7 @@ 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.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.parcelize.Parcelize
|
||||
@@ -39,6 +40,7 @@ class ShareNode(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: SharePresenter.Factory,
|
||||
private val roomSelectEntryPoint: RoomSelectEntryPoint,
|
||||
private val sessionId: SessionId,
|
||||
) : ParentNode<ShareNode.NavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget),
|
||||
@@ -53,17 +55,17 @@ class ShareNode(
|
||||
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
private val presenter = presenterFactory.create(inputs.shareIntentData)
|
||||
private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
|
||||
private val callback: ShareEntryPoint.Callback = callback()
|
||||
|
||||
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>) {
|
||||
presenter.onRoomSelected(sessionId, roomIds)
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
callback.onDone(emptyList())
|
||||
callback.onDone(sessionId, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ class ShareNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = RoomSelectEntryPoint.Params(
|
||||
sessionId = sessionId,
|
||||
mode = RoomSelectMode.Share,
|
||||
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
|
||||
),
|
||||
@@ -89,7 +92,7 @@ class ShareNode(
|
||||
val state = presenter.present()
|
||||
ShareView(
|
||||
state = state,
|
||||
onShareSuccess = callback::onDone,
|
||||
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-91
@@ -10,47 +10,54 @@ package io.element.android.features.share.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.share.api.OnSharedData
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
|
||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@AssistedInject
|
||||
class SharePresenter(
|
||||
@Assisted private val shareIntentData: ShareIntentData,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val mediaSenderRoomFactory: MediaSenderRoomFactory,
|
||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val onSharedData: OnSharedData,
|
||||
@Assisted initialSessionId: SessionId,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val shareDataSender: ShareDataSender,
|
||||
) : Presenter<ShareState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
fun create(shareIntentData: ShareIntentData): SharePresenter
|
||||
fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter
|
||||
}
|
||||
|
||||
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||
|
||||
fun onRoomSelected(roomIds: List<RoomId>) {
|
||||
sessionCoroutineScope.share(shareIntentData, roomIds)
|
||||
/**
|
||||
* The session the data is shared with, it can be updated if the user selects rooms from another session.
|
||||
*/
|
||||
private var targetSessionId by mutableStateOf(initialSessionId)
|
||||
|
||||
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
|
||||
if (shareActionState.value.isLoading()) return
|
||||
targetSessionId = sessionId
|
||||
appCoroutineScope.launch {
|
||||
suspend {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
|
||||
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
|
||||
roomIds
|
||||
}.runCatchingUpdatingState(shareActionState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -62,80 +69,9 @@ class SharePresenter(
|
||||
}
|
||||
|
||||
return ShareState(
|
||||
sessionId = targetSessionId,
|
||||
shareAction = shareActionState.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? {
|
||||
return activeRoomsHolder.getActiveRoom(matrixClient.sessionId)
|
||||
?.takeIf { it.roomId == roomId }
|
||||
?: matrixClient.getJoinedRoom(roomId)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.share(
|
||||
shareIntentData: ShareIntentData,
|
||||
roomIds: List<RoomId>,
|
||||
) = launch {
|
||||
suspend {
|
||||
val result = when (shareIntentData) {
|
||||
is ShareIntentData.PlainText -> {
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
|
||||
body = shareIntentData.content,
|
||||
htmlBody = null,
|
||||
intentionalMentions = emptyList(),
|
||||
)?.isSuccess.orFalse()
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
is ShareIntentData.Uris -> {
|
||||
val filesToShare = shareIntentData.uris
|
||||
if (filesToShare.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
roomIds
|
||||
.map { roomId ->
|
||||
val room = getJoinedRoom(roomId) ?: return@map false
|
||||
val mediaSender = mediaSenderRoomFactory.create(room = room)
|
||||
filesToShare
|
||||
.map { fileToShare ->
|
||||
val result = mediaSender.sendMedia(
|
||||
caption = shareIntentData.text,
|
||||
uri = fileToShare.uri,
|
||||
mimeType = fileToShare.mimeType,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
)
|
||||
// If the coroutine was cancelled, destroy the room and rethrow the exception
|
||||
val cancellationException = result.exceptionOrNull() as? CancellationException
|
||||
if (cancellationException != null) {
|
||||
if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) {
|
||||
room.destroy()
|
||||
}
|
||||
throw cancellationException
|
||||
}
|
||||
result.isSuccess
|
||||
}
|
||||
.all { isSuccess -> isSuccess }
|
||||
.also {
|
||||
if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) {
|
||||
room.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.all { it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle post-processing of shared data
|
||||
onSharedData(shareIntentData)
|
||||
|
||||
if (!result) {
|
||||
error("Failed to handle incoming share intent")
|
||||
}
|
||||
roomIds
|
||||
}.runCatchingUpdatingState(shareActionState)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -10,8 +10,10 @@ package io.element.android.features.share.impl
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
data class ShareState(
|
||||
val sessionId: SessionId,
|
||||
val shareAction: AsyncAction<List<RoomId>>,
|
||||
val eventSink: (ShareEvent) -> Unit
|
||||
)
|
||||
|
||||
+3
@@ -11,6 +11,7 @@ package io.element.android.features.share.impl
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
|
||||
override val values: Sequence<ShareState>
|
||||
@@ -31,9 +32,11 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
|
||||
}
|
||||
|
||||
fun aShareState(
|
||||
sessionId: SessionId = SessionId("@alice:server.org"),
|
||||
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
|
||||
eventSink: (ShareEvent) -> Unit = {}
|
||||
) = ShareState(
|
||||
sessionId = sessionId,
|
||||
shareAction = shareAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.share.api.OnSharedData
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.features.share.api.UriToShare
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaSender
|
||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultShareDataSenderTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `send text ok`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, joinedRoom)
|
||||
}
|
||||
val onSharedData = lambdaRecorder<ShareIntentData, Unit> { }
|
||||
val sender = createDefaultShareDataSender(
|
||||
onSharedData = { onSharedData(it) },
|
||||
)
|
||||
val result = sender.send(matrixClient, ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID))
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
onSharedData.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send text, room not found`() = runTest {
|
||||
val onSharedData = lambdaRecorder<ShareIntentData, Unit> { }
|
||||
val sender = createDefaultShareDataSender(
|
||||
onSharedData = { onSharedData(it) },
|
||||
)
|
||||
val result = sender.send(FakeMatrixClient(), ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID))
|
||||
assertThat(result.isFailure).isTrue()
|
||||
onSharedData.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send media ok`() = runTest {
|
||||
val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, joinedRoom)
|
||||
}
|
||||
val mediaSender = FakeMediaSender(
|
||||
sendMediaResult = sendMediaResult,
|
||||
)
|
||||
val sender = createDefaultShareDataSender(
|
||||
mediaSenderRoomFactory = { _, _ -> mediaSender },
|
||||
)
|
||||
val result = sender.send(
|
||||
client = matrixClient,
|
||||
shareIntentData = ShareIntentData.Uris(
|
||||
text = A_MESSAGE,
|
||||
listOf(
|
||||
UriToShare(
|
||||
uri = Uri.parse("content://image.jpg"),
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
)
|
||||
)
|
||||
),
|
||||
roomIds = listOf(A_ROOM_ID),
|
||||
)
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send media with no uris is a failure`() = runTest {
|
||||
val sender = createDefaultShareDataSender()
|
||||
val result = sender.send(
|
||||
client = FakeMatrixClient(),
|
||||
shareIntentData = ShareIntentData.Uris(text = A_MESSAGE, emptyList()),
|
||||
roomIds = listOf(A_ROOM_ID),
|
||||
)
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultShareDataSender(
|
||||
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { _, _ -> FakeMediaSender() },
|
||||
mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory = MediaOptimizationConfigProviderFactory {
|
||||
FakeMediaOptimizationConfigProvider()
|
||||
},
|
||||
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
|
||||
onSharedData: OnSharedData = OnSharedData {},
|
||||
) = DefaultShareDataSender(
|
||||
mediaSenderRoomFactory = mediaSenderRoomFactory,
|
||||
mediaOptimizationConfigProviderFactory = mediaOptimizationConfigProviderFactory,
|
||||
activeRoomsHolder = activeRoomsHolder,
|
||||
onSharedData = onSharedData,
|
||||
)
|
||||
+5
-2
@@ -15,7 +15,9 @@ 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.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.roomselect.test.FakeRoomSelectEntryPoint
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
@@ -37,12 +39,13 @@ class DefaultShareEntryPointTest {
|
||||
ShareNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
presenterFactory = { createSharePresenter() },
|
||||
presenterFactory = { _, _ -> createSharePresenter() },
|
||||
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
|
||||
sessionId = A_SESSION_ID,
|
||||
)
|
||||
}
|
||||
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),
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeShareDataSender(
|
||||
private val sendResult: (MatrixClient, ShareIntentData, List<RoomId>) -> Result<Unit> = { _, _, _ -> lambdaError() },
|
||||
) : ShareDataSender {
|
||||
override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List<RoomId>): Result<Unit> {
|
||||
return sendResult(client, shareIntentData, roomIds)
|
||||
}
|
||||
}
|
||||
+80
-80
@@ -8,37 +8,32 @@
|
||||
|
||||
package io.element.android.features.share.impl
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.share.api.OnSharedData
|
||||
import io.element.android.features.share.api.ShareIntentData
|
||||
import io.element.android.features.share.api.UriToShare
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
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.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaSender
|
||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class SharePresenterTest : RobolectricTest() {
|
||||
class SharePresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@@ -49,22 +44,25 @@ class SharePresenterTest : RobolectricTest() {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on room selected error then clear error`() = runTest {
|
||||
val presenter = createSharePresenter()
|
||||
val presenter = createSharePresenter(
|
||||
shareDataSender = FakeShareDataSender { _, _, _ -> Result.failure(AN_EXCEPTION) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val failure = awaitItem()
|
||||
assertThat(failure.shareAction.isFailure()).isTrue()
|
||||
assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||
failure.eventSink.invoke(ShareEvent.ClearError)
|
||||
assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
|
||||
}
|
||||
@@ -72,113 +70,115 @@ class SharePresenterTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `present - on room selected ok`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, joinedRoom)
|
||||
}
|
||||
val client = FakeMatrixClient(sessionId = A_SESSION_ID)
|
||||
val shareIntentData = ShareIntentData.PlainText(A_MESSAGE)
|
||||
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
||||
val presenter = createSharePresenter(
|
||||
matrixClient = matrixClient,
|
||||
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
||||
shareIntentData = shareIntentData,
|
||||
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
|
||||
shareDataSender = FakeShareDataSender(sendResult),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send text ok`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, joinedRoom)
|
||||
fun `present - on room selected from another session ok`() = runTest {
|
||||
val getClient = lambdaRecorder<SessionId, Result<MatrixClient>> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) }
|
||||
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { client, _, _ ->
|
||||
assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2)
|
||||
Result.success(Unit)
|
||||
}
|
||||
val presenter = createSharePresenter(
|
||||
matrixClient = matrixClient,
|
||||
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
||||
matrixClientProvider = FakeMatrixClientProvider { getClient(it) },
|
||||
shareDataSender = FakeShareDataSender(sendResult),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
|
||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||
val sessionChanged = awaitItem()
|
||||
assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2)
|
||||
assertThat(sessionChanged.shareAction.isUninitialized()).isTrue()
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2)
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
|
||||
sendResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send media ok`() = runTest {
|
||||
val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, joinedRoom)
|
||||
}
|
||||
val mediaSender = FakeMediaSender(
|
||||
sendMediaResult = sendMediaResult,
|
||||
)
|
||||
fun `present - on room selected, failure to get the client`() = runTest {
|
||||
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
||||
val presenter = createSharePresenter(
|
||||
matrixClient = matrixClient,
|
||||
shareIntentData = ShareIntentData.Uris(
|
||||
text = A_MESSAGE,
|
||||
listOf(
|
||||
UriToShare(
|
||||
uri = Uri.parse("content://image.jpg"),
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
)
|
||||
)
|
||||
),
|
||||
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
|
||||
matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) },
|
||||
shareDataSender = FakeShareDataSender(sendResult),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||
presenter.onRoomSelected(listOf(A_ROOM_ID))
|
||||
skipItems(1)
|
||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2)
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||
sendResult.assertions().isNeverCalled()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on room selected while sharing is ignored`() = runTest {
|
||||
val completable = CompletableDeferred<Result<Unit>>()
|
||||
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
||||
val presenter = createSharePresenter(
|
||||
shareDataSender = object : ShareDataSender {
|
||||
override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List<RoomId>): Result<Unit> {
|
||||
sendResult(client, shareIntentData, roomIds)
|
||||
return completable.await()
|
||||
}
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
|
||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||
completable.complete(Result.success(Unit))
|
||||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun TestScope.createSharePresenter(
|
||||
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
|
||||
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
|
||||
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
onSharedData: OnSharedData = OnSharedData {},
|
||||
initialSessionId: SessionId = A_SESSION_ID,
|
||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
shareDataSender: ShareDataSender = FakeShareDataSender(),
|
||||
): SharePresenter {
|
||||
return SharePresenter(
|
||||
shareIntentData = shareIntentData,
|
||||
sessionCoroutineScope = this,
|
||||
matrixClient = matrixClient,
|
||||
activeRoomsHolder = activeRoomsHolder,
|
||||
mediaSenderRoomFactory = mediaSenderRoomFactory,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
onSharedData = onSharedData,
|
||||
initialSessionId = initialSessionId,
|
||||
appCoroutineScope = this,
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
shareDataSender = shareDataSender,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-8
@@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
@@ -58,11 +58,4 @@ class SignedOutPresenter(
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SessionData.toMatrixUser() = MatrixUser(
|
||||
userId = sessionId,
|
||||
displayName = userDisplayName,
|
||||
avatarUrl = userAvatarUrl,
|
||||
avatarThumbnail = userAvatarData,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-10
@@ -29,6 +29,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
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.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
@@ -101,16 +102,7 @@ class IncomingVerificationPresenter(
|
||||
}
|
||||
val currentUser by remember {
|
||||
sessionStore.sessionsFlow().mapNotNull { sessions ->
|
||||
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
|
||||
MatrixUser(
|
||||
userId = sessionId,
|
||||
displayName = sessionData.userDisplayName,
|
||||
avatarUrl = sessionData.userAvatarUrl,
|
||||
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
|
||||
// for instance for a session which is not the current one
|
||||
avatarThumbnail = sessionData.userAvatarData,
|
||||
)
|
||||
}
|
||||
sessions.find { it.userId == sessionId.value }?.toMatrixUser()
|
||||
}
|
||||
}.collectAsState(MatrixUser(sessionId))
|
||||
val step by remember {
|
||||
|
||||
+2
-9
@@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -29,14 +29,7 @@ class AccountSelectPresenter(
|
||||
val accounts by produceState<ImmutableList<MatrixUser>>(persistentListOf()) {
|
||||
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
|
||||
value = sessionStore.getAllSessions()
|
||||
.map {
|
||||
MatrixUser(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.map { it.toMatrixUser() }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.libraries.matrix.api.user
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
|
||||
/**
|
||||
* Create a [MatrixUser] from the profile data stored locally for the session.
|
||||
* The avatar thumbnail is used as a fallback when the avatar cannot be loaded,
|
||||
* for instance for a session which is not the current one.
|
||||
*/
|
||||
fun SessionData.toMatrixUser() = MatrixUser(
|
||||
userId = UserId(userId),
|
||||
displayName = userDisplayName,
|
||||
avatarUrl = userAvatarUrl,
|
||||
avatarThumbnail = userAvatarData,
|
||||
)
|
||||
+14
@@ -8,6 +8,8 @@
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
||||
/**
|
||||
* Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults.
|
||||
*/
|
||||
@@ -15,3 +17,15 @@ fun interface MediaOptimizationConfigProvider {
|
||||
/** Returns the configuration to pass to the pre-processing and sending calls. */
|
||||
suspend fun get(): MediaOptimizationConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [MediaOptimizationConfigProvider] for a given session, used outside the session scope, for instance when sharing to another account.
|
||||
*/
|
||||
fun interface MediaOptimizationConfigProviderFactory {
|
||||
/**
|
||||
* Create a [MediaOptimizationConfigProvider] using the preferences of the session of [client].
|
||||
*
|
||||
* @param client the client of the session whose preferences should be used.
|
||||
*/
|
||||
fun create(client: MatrixClient): MediaOptimizationConfigProvider
|
||||
}
|
||||
|
||||
+3
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
@@ -34,9 +35,11 @@ fun interface MediaSenderRoomFactory {
|
||||
/**
|
||||
* Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live.
|
||||
*
|
||||
* @param client the client of the session the room belongs to, used to read the media preferences of that session.
|
||||
* @param room the room the media should be sent to.
|
||||
*/
|
||||
fun create(
|
||||
client: MatrixClient,
|
||||
room: JoinedRoom,
|
||||
): MediaSender
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.libraries.mediaupload.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMediaOptimizationConfigProviderFactory(
|
||||
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : MediaOptimizationConfigProviderFactory {
|
||||
override fun create(client: MatrixClient): MediaOptimizationConfigProvider {
|
||||
return DefaultMediaOptimizationConfigProvider(
|
||||
sessionPreferencesStore = sessionPreferencesStoreFactory.get(client.sessionId, client.sessionCoroutineScope),
|
||||
featureFlagsService = featureFlagService,
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -9,12 +9,13 @@
|
||||
package io.element.android.libraries.mediaupload.impl
|
||||
|
||||
import android.net.Uri
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.flatMapCatching
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
@@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
@@ -52,19 +54,20 @@ class DefaultMediaSenderFactory(
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMediaSenderRoomFactory(
|
||||
private val preProcessor: MediaPreProcessor,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory,
|
||||
) : MediaSenderRoomFactory {
|
||||
override fun create(
|
||||
client: MatrixClient,
|
||||
room: JoinedRoom,
|
||||
): MediaSender {
|
||||
return DefaultMediaSender(
|
||||
preProcessor = preProcessor,
|
||||
room = room,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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,
|
||||
),
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+93
-10
@@ -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,33 @@ 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.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 +64,75 @@ 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 sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
|
||||
// All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error.
|
||||
val accounts by remember {
|
||||
derivedStateOf { sessions.map { it.toMatrixUser() } }
|
||||
}
|
||||
// The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is
|
||||
// signed out from another device. The invalidation of the initial session, which is the current one, is handled by the application.
|
||||
val isSelectedSessionInvalid by remember {
|
||||
derivedStateOf {
|
||||
selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(isSelectedSessionInvalid) {
|
||||
if (isSelectedSessionInvalid) {
|
||||
// The rooms of an invalid session cannot be used
|
||||
selectedRooms = persistentListOf()
|
||||
}
|
||||
}
|
||||
val selectedAccount by remember {
|
||||
derivedStateOf {
|
||||
// Fallback to a MatrixUser with only the userId until the sessions are loaded
|
||||
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 (selectedSessionDataSource, hasClientError) = key(selectedSessionId) {
|
||||
val dataSourceCoroutineScope = rememberCoroutineScope()
|
||||
// The sessions are restored at startup, so the client should already be in memory.
|
||||
// A null value means that the client is being restored, or that the session is invalid: its client cannot be restored,
|
||||
// and an in-memory client would have been destroyed, so do not try to get it.
|
||||
val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService
|
||||
val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) {
|
||||
if (value == null && !isSelectedSessionInvalid) {
|
||||
value = matrixClientProvider.getOrRestore(selectedSessionId)
|
||||
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") }
|
||||
.map { it.roomListService }
|
||||
}
|
||||
}
|
||||
val roomListService = roomListServiceResult?.getOrNull()
|
||||
val dataSource = remember(roomListService) {
|
||||
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) }
|
||||
}
|
||||
dataSource to (roomListServiceResult?.isFailure == true)
|
||||
}
|
||||
// Do not use the data source of an invalid session, its client has been destroyed
|
||||
val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid }
|
||||
val hasRoomListError = hasClientError || isSelectedSessionInvalid
|
||||
|
||||
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 +153,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 +174,10 @@ class RoomSelectPresenter(
|
||||
searchQuery = queryState,
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
otherAccounts = otherAccounts,
|
||||
isAccountListExpanded = isAccountListExpanded,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+13
@@ -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,19 @@ data class RoomSelectState(
|
||||
val searchQuery: TextFieldState,
|
||||
val isSearchActive: Boolean,
|
||||
val selectedRooms: ImmutableList<SelectRoomInfo>,
|
||||
val selectedAccount: MatrixUser,
|
||||
/**
|
||||
* True if the rooms of the selected account cannot be loaded.
|
||||
*/
|
||||
val hasRoomListError: Boolean,
|
||||
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()
|
||||
}
|
||||
|
||||
+34
@@ -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,25 @@ 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,
|
||||
),
|
||||
aRoomSelectState(
|
||||
mode = RoomSelectMode.Share,
|
||||
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
|
||||
otherAccounts = anOtherAccountList(),
|
||||
hasRoomListError = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +78,10 @@ internal fun aRoomSelectState(
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
|
||||
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
|
||||
hasRoomListError: Boolean = false,
|
||||
otherAccounts: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
isAccountListExpanded: Boolean = false,
|
||||
eventSink: (RoomSelectEvent) -> Unit = {},
|
||||
) = RoomSelectState(
|
||||
mode = mode,
|
||||
@@ -65,9 +90,18 @@ internal fun aRoomSelectState(
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
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"),
|
||||
|
||||
+215
-58
@@ -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
|
||||
@@ -29,27 +33,42 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
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.input.nestedscroll.nestedScroll
|
||||
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.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
|
||||
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 +79,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 +91,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 +137,13 @@ 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() }
|
||||
// Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down.
|
||||
// Use a new state when the selected account changes, so that the section is fully displayed again.
|
||||
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
|
||||
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
|
||||
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
|
||||
}
|
||||
@@ -145,6 +177,7 @@ fun RoomSelectView(
|
||||
Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
SearchBar(
|
||||
modifier = Modifier
|
||||
@@ -161,6 +194,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 +211,53 @@ 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) {
|
||||
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
|
||||
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.hasRoomListError) {
|
||||
RoomListError()
|
||||
} else if (state.resultState is SearchBarResultState.Results) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
if (state.resultState.results.isNotEmpty()) {
|
||||
item {
|
||||
ListSectionHeader(
|
||||
title = stringResource(CommonStrings.common_header_rooms),
|
||||
hasDivider = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
|
||||
RoomSummaryView(
|
||||
roomSummary,
|
||||
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||
@@ -181,34 +266,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 +274,114 @@ fun RoomSelectView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomListError() {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
title = stringResource(CommonStrings.common_something_went_wrong),
|
||||
subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms),
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 +406,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 +446,6 @@ private fun RoomSummaryView(
|
||||
)
|
||||
}
|
||||
}
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
enabled = isSelected || canBeSelected,
|
||||
onCheckedChange = { onSelection(roomInfo) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_room_select_error_cannot_load_rooms">"The room list associated with this account cannot be loaded."</string>
|
||||
</resources>
|
||||
+7
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+244
-2
@@ -9,18 +9,34 @@
|
||||
package io.element.android.libraries.roomselect.impl
|
||||
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import app.cash.turbine.Event
|
||||
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.AN_EXCEPTION
|
||||
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.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
@@ -46,9 +62,228 @@ 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()
|
||||
assertThat(initialState.hasRoomListError).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - failure to get the client shows an error`() = runTest {
|
||||
val presenter = createRoomSelectPresenter(
|
||||
matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) },
|
||||
)
|
||||
presenter.test {
|
||||
val state = consumeItemsUntilPredicate { it.hasRoomListError }.last()
|
||||
assertThat(state.hasRoomListError).isTrue()
|
||||
assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select another account whose client cannot be restored shows an error`() = runTest {
|
||||
val presenter = createRoomSelectPresenter(
|
||||
mode = RoomSelectMode.Share,
|
||||
sessionStore = aSessionStoreWithTwoAccounts(),
|
||||
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||
if (sessionId == A_SESSION_ID) {
|
||||
Result.success(FakeMatrixClient(sessionId = sessionId))
|
||||
} else {
|
||||
Result.failure(AN_EXCEPTION)
|
||||
}
|
||||
},
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state.hasRoomListError).isFalse()
|
||||
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
|
||||
val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last()
|
||||
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2)
|
||||
// Going back to the first account clears the error
|
||||
errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID))
|
||||
val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last()
|
||||
assertThat(finalState.hasRoomListError).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 - accounts with an invalid token are displayed, selecting one displays an error`() = runTest {
|
||||
val presenter = createRoomSelectPresenter(
|
||||
mode = RoomSelectMode.Share,
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true),
|
||||
aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = false),
|
||||
),
|
||||
),
|
||||
// The client of the invalid session must not be requested
|
||||
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||
if (sessionId == A_SESSION_ID) Result.success(FakeMatrixClient(sessionId = sessionId)) else lambdaError()
|
||||
},
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
|
||||
assertThat(state.hasRoomListError).isFalse()
|
||||
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
|
||||
val errorState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 && it.hasRoomListError }.last()
|
||||
// The account information is still available
|
||||
assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob")
|
||||
assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - the selected account is invalidated, an error is displayed and the selected rooms are cleared`() = runTest {
|
||||
val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2)
|
||||
val roomListServices = mapOf(
|
||||
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }),
|
||||
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }),
|
||||
)
|
||||
val sessionStore = aSessionStoreWithTwoAccounts()
|
||||
val presenter = createRoomSelectPresenter(
|
||||
mode = RoomSelectMode.Share,
|
||||
sessionStore = sessionStore,
|
||||
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
|
||||
},
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
|
||||
val otherAccountState = consumeItemsUntilPredicate {
|
||||
it.selectedAccount.userId == A_SESSION_ID_2 &&
|
||||
(it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2)
|
||||
}.last()
|
||||
otherAccountState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary2.toSelectRoomInfo()))
|
||||
assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1)
|
||||
// The session of the selected account is invalidated, for instance signed out from another device
|
||||
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false))
|
||||
val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last()
|
||||
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2)
|
||||
assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob")
|
||||
assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
// Go back to the current account: the invalid account is still proposed
|
||||
errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID))
|
||||
val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last()
|
||||
assertThat(finalState.showAccountSwitch).isTrue()
|
||||
assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
|
||||
val sessionStore = aSessionStoreWithTwoAccounts()
|
||||
val presenter = createRoomSelectPresenter(
|
||||
mode = RoomSelectMode.Share,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
|
||||
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false))
|
||||
testScheduler.advanceUntilIdle()
|
||||
// The state may not change at all
|
||||
val finalState = cancelAndConsumeRemainingEvents().filterIsInstance<Event.Item<RoomSelectState>>().lastOrNull()?.value ?: state
|
||||
assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID)
|
||||
assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
|
||||
assertThat(finalState.hasRoomListError).isFalse()
|
||||
assertThat(finalState.showAccountSwitch).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
// Wait for the rooms to be loaded, they are emitted on another dispatcher
|
||||
val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last()
|
||||
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 = consumeItemsUntilPredicate {
|
||||
it.selectedAccount.userId == A_SESSION_ID_2 &&
|
||||
(it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2)
|
||||
}.last()
|
||||
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", isTokenValid = true),
|
||||
aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = true),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `present - toggle search active`() = runTest {
|
||||
val presenter = createRoomSelectPresenter()
|
||||
@@ -157,16 +392,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,
|
||||
)
|
||||
|
||||
+63
@@ -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,63 @@ 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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
|
||||
hasRoomListError = true,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.common_something_went_wrong)).assertIsDisplayed()
|
||||
onNodeWithText(activity!!.getString(R.string.screen_room_select_error_cannot_load_rooms)).assertIsDisplayed()
|
||||
onNodeWithText("Room with alias").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -14,6 +14,12 @@
|
||||
"screen_bug_report_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : ":libraries:roomselect:impl",
|
||||
"includeRegex" : [
|
||||
"screen\\.room_select\\..*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : ":features:rageshake:api",
|
||||
"includeRegex" : [
|
||||
|
||||
Reference in New Issue
Block a user