mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -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"),
|
||||
|
||||
+224
-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,14 @@ fun RoomSelectView(
|
||||
Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.then(
|
||||
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events
|
||||
if (state.showAccountSwitch && !state.isSearchActive) {
|
||||
Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
SearchBar(
|
||||
modifier = Modifier
|
||||
@@ -165,6 +205,14 @@ fun RoomSelectView(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
if (summaries.isNotEmpty()) {
|
||||
item {
|
||||
ListSectionHeader(
|
||||
title = stringResource(CommonStrings.common_header_rooms),
|
||||
hasDivider = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
SelectedRoomsHelper(
|
||||
selectedRooms = state.selectedRooms,
|
||||
@@ -172,7 +220,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 +275,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 +283,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 +415,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 +455,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,
|
||||
)
|
||||
|
||||
+102
@@ -13,16 +13,27 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
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 kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@@ -72,6 +83,90 @@ 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 `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
mode = RoomSelectMode.Share,
|
||||
resultState = SearchBarResultState.Results(aLongRoomList()),
|
||||
),
|
||||
)
|
||||
onNodeWithText("Room 0").assertIsDisplayed()
|
||||
onRoot().performTouchInput { swipeUp() }
|
||||
onNodeWithText("Room 0").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
mode = RoomSelectMode.Share,
|
||||
resultState = SearchBarResultState.Results(aLongRoomList()),
|
||||
otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")),
|
||||
),
|
||||
)
|
||||
onNodeWithText("Room 0").assertIsDisplayed()
|
||||
onRoot().performTouchInput { swipeUp() }
|
||||
onNodeWithText("Room 0").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@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(
|
||||
@@ -87,3 +182,10 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLongRoomList() = List(30) { index ->
|
||||
aSelectRoomInfo(
|
||||
roomId = RoomId("!room$index:domain"),
|
||||
name = "Room $index",
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
||||
Reference in New Issue
Block a user