Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-08 17:38:14 +02:00
432 changed files with 4950 additions and 2279 deletions
@@ -32,10 +32,12 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode(
parentNode: Node,
@@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(
sessionId = params.sessionId,
mode = params.mode,
maxNumberOfRooms = params.maxNumberOfRooms,
),
@@ -8,10 +8,13 @@
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@@ -22,11 +23,11 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class)
@ContributesNode(AppScope::class)
@AssistedInject
class RoomSelectNode(
@Assisted buildContext: BuildContext,
@@ -34,12 +35,14 @@ class RoomSelectNode(
presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val sessionId: SessionId,
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId,
mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms,
)
@@ -52,7 +55,7 @@ class RoomSelectNode(
RoomSelectView(
state = state,
onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected,
onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) },
modifier = modifier
)
}
@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -15,7 +16,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -24,22 +27,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,
)
}
@@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30
@AssistedInject
class RoomSelectSearchDataSource(
@Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService,
@Assisted roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource
fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource
}
private val roomList = roomListService.createRoomList(
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
@@ -21,7 +22,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()
}
@@ -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"),
@@ -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>
@@ -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)
}
}
@@ -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,
)
@@ -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()