Merge pull request #2536 from element-hq/feature/fga/room_list_filter_iteration

Feature/fga/room list filter iteration
This commit is contained in:
ganfra 2024-03-13 16:36:54 +01:00 committed by GitHub
commit 8ed4d3c48f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 1240 additions and 561 deletions

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> {
override val values: Sequence<RoomListContentState>
get() = sequenceOf(
aRoomsContentState(),
aRoomsContentState(summaries = persistentListOf()),
aSkeletonContentState(),
anEmptyContentState(),
aMigrationContentState(),
)
}
internal fun aRoomsContentState(
invitesState: InvitesState = InvitesState.NoInvites,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
) = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = summaries,
)
internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState(
invitesState: InvitesState = InvitesState.NoInvites,
) = RoomListContentState.Empty(invitesState)

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@ -38,7 +40,7 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
@ -52,6 +54,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -60,6 +63,7 @@ import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
@ -85,7 +89,7 @@ class RoomListPresenter @Inject constructor(
private val indicatorService: IndicatorService,
private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>,
private val migrationScreenPresenter: MigrationScreenPresenter,
private val migrationScreenPresenter: Presenter<MigrationScreenState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> {
@ -100,11 +104,7 @@ class RoomListPresenter @Inject constructor(
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null)
}
val roomList by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
@ -113,28 +113,7 @@ class RoomListPresenter @Inject constructor(
initialLoad(matrixUser)
}
val isMigrating = migrationScreenPresenter.present().isMigrating
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val syncState by syncService.syncState.collectAsState()
val securityBannerState by remember {
derivedStateOf {
when {
securityBannerDismissed -> SecurityBannerState.None
canVerifySession -> if (isLastDevice) {
SecurityBannerState.RecoveryKeyConfirmation
} else {
SecurityBannerState.SessionVerification
}
recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None
}
}
}
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
@ -162,19 +141,18 @@ class RoomListPresenter @Inject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val contentState = roomListContentState(securityBannerDismissed)
return RoomListState(
matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
securityBannerState = securityBannerState,
snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(),
contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
displayMigrationStatus = isMigrating,
contentState = contentState,
eventSink = ::handleEvents,
)
}
@ -183,6 +161,70 @@ class RoomListPresenter @Inject constructor(
matrixUser.value = client.getCurrentUser()
}
@Composable
private fun securityBannerState(
securityBannerDismissed: Boolean,
): State<SecurityBannerState> {
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val syncState by syncService.syncState.collectAsState()
return remember {
derivedStateOf {
when {
currentSecurityBannerDismissed -> SecurityBannerState.None
canVerifySession -> if (isLastDevice) {
SecurityBannerState.RecoveryKeyConfirmation
} else {
SecurityBannerState.SessionVerification
}
recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None
}
}
}
}
@Composable
private fun roomListContentState(
securityBannerDismissed: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showMigration = migrationScreenPresenter.present().isMigrating
val showEmpty by remember {
derivedStateOf {
(loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
}
}
val showSkeleton by remember {
derivedStateOf {
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
return when {
showMigration -> RoomListContentState.Migration
showEmpty -> {
val invitesState = inviteStateDataSource.inviteState()
RoomListContentState.Empty(invitesState)
}
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
val invitesState = inviteStateDataSource.inviteState()
val securityBannerState by securityBannerState(securityBannerDismissed)
RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(

View file

@ -21,7 +21,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -31,20 +30,17 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomListState(
val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val securityBannerState: SecurityBannerState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState,
val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
val displayMigrationStatus: Boolean,
val contentState: RoomListContentState,
val eventSink: (RoomListEvents) -> Unit,
) {
val displayFilters = filtersState.isFeatureEnabled && !displayMigrationStatus
val displayEmptyState = roomList is AsyncData.Success && roomList.data.isEmpty()
val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
val displayActions = contentState !is RoomListContentState.Migration
sealed interface ContextMenu {
data object Hidden : ContextMenu
@ -70,3 +66,15 @@ enum class SecurityBannerState {
SessionVerification,
RecoveryKeyConfirmation,
}
@Immutable
sealed interface RoomListContentState {
data object Migration : RoomListContentState
data class Skeleton(val count: Int) : RoomListContentState
data class Empty(val invitesState: InvitesState) : RoomListContentState
data class Rooms(
val invitesState: InvitesState,
val securityBannerState: SecurityBannerState,
val summaries: ImmutableList<RoomListRoomSummary>,
) : RoomListContentState
}

View file

@ -19,14 +19,12 @@ package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -43,15 +41,15 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false),
aRoomListState(invitesState = InvitesState.SeenInvites),
aRoomListState(invitesState = InvitesState.NewInvites),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
aRoomListState(roomList = AsyncData.Success(persistentListOf())),
aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState(matrixUser = null, displayMigrationStatus = true),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = null, contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
)
@ -60,30 +58,24 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
internal fun aRoomListState(
matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false,
roomList: AsyncData<ImmutableList<RoomListRoomSummary>> = AsyncData.Success(aRoomListRoomSummaryList()),
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
invitesState: InvitesState = InvitesState.NoInvites,
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
displayMigrationStatus: Boolean = false,
contentState: RoomListContentState = aRoomsContentState(),
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
securityBannerState = securityBannerState,
invitesState = invitesState,
contextMenu = contextMenu,
leaveRoomState = leaveRoomState,
searchState = searchState,
filtersState = filtersState,
displayMigrationStatus = displayMigrationStatus,
searchState = searchState,
contentState = contentState,
eventSink = eventSink,
)

View file

@ -17,62 +17,36 @@
package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
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.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.ConfirmRecoveryKeyBanner
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListContentView
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.filters.RoomListFiltersView
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
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.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomListView(
@ -108,7 +82,7 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState)
RoomListContent(
RoomListScaffold(
modifier = Modifier.padding(top = topPadding),
state = state,
onVerifyClicked = onVerifyClicked,
@ -135,43 +109,9 @@ fun RoomListView(
}
}
@Composable
private fun EmptyRoomListView(
onCreateRoomClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.screen_roomlist_empty_title),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.screen_roomlist_empty_message),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClicked,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomListContent(
private fun RoomListScaffold(
state: RoomListState,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
@ -188,111 +128,43 @@ private fun RoomListContent(
}
val appBarState = rememberTopAppBarState()
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
RoomListTopBar(
matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = state.searchState.isSearchActive,
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
displayMenuItems = !state.displayMigrationStatus,
)
if (state.displayFilters) {
RoomListFiltersView(state = state.filtersState)
}
}
RoomListTopBar(
matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = state.searchState.isSearchActive,
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
displayMenuItems = state.displayActions,
displayFilters = state.displayFilters,
filtersState = state.filtersState,
)
},
content = { padding ->
LazyColumn(
RoomListContentView(
contentState = state.contentState,
filtersState = state.filtersState,
eventSink = state.eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
when {
state.displayEmptyState -> Unit
state.securityBannerState == SecurityBannerState.SessionVerification -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
state.securityBannerState == SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClicked = onConfirmRecoveryKeyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
}
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
}
val roomList = state.roomList.dataOrNull().orEmpty()
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
itemsIndexed(
items = roomList,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != roomList.lastIndex) {
HorizontalDivider()
}
}
}
if (state.displayEmptyState) {
if (state.filtersState.hasAnyFilterSelected) {
// TODO add empty state for filtered rooms
} else {
EmptyRoomListView(onCreateRoomClicked)
}
}
MigrationScreenView(isMigrating = state.displayMigrationStatus)
)
},
floatingActionButton = {
if (!state.displayMigrationStatus) {
if (state.displayActions) {
FloatingActionButton(
// FIXME align on Design system theme
containerColor = MaterialTheme.colorScheme.primary,

View file

@ -0,0 +1,325 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
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.features.roomlist.impl.InvitesEntryPointView
import io.element.android.features.roomlist.impl.InvitesState
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListContentState
import io.element.android.features.roomlist.impl.RoomListContentStateProvider
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.SecurityBannerState
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import io.element.android.features.roomlist.impl.filters.RoomListFiltersEmptyStateResources
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (contentState) {
is RoomListContentState.Migration -> {
MigrationScreenView(isMigrating = true)
}
is RoomListContentState.Skeleton -> {
SkeletonView(
count = contentState.count,
)
}
is RoomListContentState.Empty -> {
EmptyView(
state = contentState,
onInvitesClicked = onInvitesClicked,
onCreateRoomClicked = onCreateRoomClicked,
)
}
is RoomListContentState.Rooms -> {
RoomsView(
state = contentState,
filtersState = filtersState,
eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
)
}
}
}
}
@Composable
private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
repeat(count) { index ->
item {
RoomSummaryPlaceholderRow()
if (index != count - 1) {
HorizontalDivider()
}
}
}
}
}
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxSize(),
) {
if (state.invitesState != InvitesState.NoInvites) {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClicked,
)
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun RoomsView(
state: RoomListContentState.Rooms,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
EmptyViewForFilterStates(
selectedFilters = filtersState.selectedFilters(),
modifier = modifier.fillMaxSize()
)
} else {
RoomsViewList(
state = state,
eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
modifier = modifier.fillMaxSize(),
)
}
}
@Composable
private fun RoomsViewList(
state: RoomListContentState.Rooms,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
LazyColumn(
state = lazyListState,
modifier = modifier.nestedScroll(nestedScrollConnection),
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
when (state.securityBannerState) {
SecurityBannerState.SessionVerification -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClicked = onConfirmRecoveryKeyClicked,
onDismissClicked = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
else -> Unit
}
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
}
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
itemsIndexed(
items = state.summaries,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != state.summaries.lastIndex) {
HorizontalDivider()
}
}
}
}
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>,
modifier: Modifier = Modifier,
) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return
EmptyScaffold(
title = emptyStateResources.title,
subtitle = emptyStateResources.subtitle,
modifier = modifier,
)
}
@Composable
private fun EmptyScaffold(
@StringRes title: Int,
@StringRes subtitle: Int,
modifier: Modifier = Modifier,
action: @Composable (ColumnScope.() -> Unit)? = null,
) {
Column(
modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(subtitle),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
action?.invoke(this)
}
}
@PreviewsDayNight
@Composable
internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStateProvider::class) state: RoomListContentState) = ElementPreview {
RoomListContentView(
contentState = state,
filtersState = aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
eventSink = {},
onVerifyClicked = { },
onConfirmRecoveryKeyClicked = { },
onRoomClicked = {},
onRoomLongClicked = {},
onCreateRoomClicked = { },
onInvitesClicked = { })
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -40,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
@ -52,8 +52,12 @@ import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersView
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -91,6 +95,8 @@ fun RoomListTopBar(
onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
modifier: Modifier = Modifier,
) {
DefaultRoomListTopBar(
@ -102,6 +108,8 @@ fun RoomListTopBar(
onMenuActionClicked = onMenuActionClicked,
scrollBehavior = scrollBehavior,
displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
modifier = modifier,
)
}
@ -117,6 +125,8 @@ private fun DefaultRoomListTopBar(
onSearchClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
@ -153,12 +163,11 @@ private fun DefaultRoomListTopBar(
titleLarge = collapsedTitleTextStyle
),
) {
MediumTopAppBar(
Column(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
.nestedScroll(scrollBehavior.nestedScrollConnection)
.avatarBloom(
avatarData = avatarData,
background = if (ElementTheme.isLightTheme) {
@ -178,113 +187,104 @@ private fun DefaultRoomListTopBar(
DpSize.Unspecified
},
bottomSoftEdgeColor = ElementTheme.materialColors.background,
bottomSoftEdgeAlpha = 1f - collapsedFraction,
bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
1f - collapsedFraction
},
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
title = {
Text(text = stringResource(id = R.string.screen_roomlist_main_space_title))
},
navigationIcon = {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
if (avatarData != null) {
Avatar(
avatarData = avatarData!!,
contentDescription = stringResource(CommonStrings.common_settings),
)
} else {
// Placeholder avatar until the avatarData is available
Surface(
modifier = Modifier.size(AvatarSize.CurrentUserTopBar.dp),
shape = CircleShape,
color = ElementTheme.colors.iconSecondary,
content = {}
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier
.padding(4.5.dp)
.align(Alignment.TopEnd)
)
}
}
},
actions = {
if (displayMenuItems) {
IconButton(
onClick = onSearchClicked,
) {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search),
)
}
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
) {
MediumTopAppBar(
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
title = {
Text(text = stringResource(id = R.string.screen_roomlist_main_space_title))
},
navigationIcon = {
NavigationIcon(
avatarData = avatarData,
showAvatarIndicator = showAvatarIndicator,
onClick = onOpenSettings,
)
},
actions = {
if (displayMenuItems) {
IconButton(
onClick = { showMenu = !showMenu }
onClick = onSearchClicked,
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
}
}
}
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
if (displayFilters) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
HorizontalDivider(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.alpha(collapsedFraction)
.align(Alignment.BottomCenter),
@ -293,6 +293,40 @@ private fun DefaultRoomListTopBar(
}
}
@Composable
private fun NavigationIcon(
avatarData: AvatarData?,
showAvatarIndicator: Boolean,
onClick: () -> Unit,
) {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onClick,
) {
Box {
if (avatarData != null) {
Avatar(
avatarData = avatarData,
contentDescription = stringResource(CommonStrings.common_settings),
)
} else {
// Placeholder avatar until the avatarData is available
Surface(
modifier = Modifier.size(AvatarSize.CurrentUserTopBar.dp),
shape = CircleShape,
color = ElementTheme.colors.iconSecondary,
content = {}
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@ -305,6 +339,8 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
onOpenSettings = {},
onSearchClicked = {},
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
onMenuActionClicked = {},
)
}
@ -321,6 +357,8 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
onOpenSettings = {},
onSearchClicked = {},
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
onMenuActionClicked = {},
)
}

View file

@ -27,12 +27,11 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -62,19 +61,15 @@ class RoomListDataSource @Inject constructor(
roomListService
.allRooms
.summaries
.onStart {
// If we have no cached results, display a placeholder loading state
if (diffCache.isEmpty()) {
_allRooms.emit(RoomListRoomSummaryFactory.createFakeList())
}
}
.onEach { roomSummaries ->
replaceWith(roomSummaries)
}
.launchIn(coroutineScope)
}
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms
val loadingState = roomListService.allRooms.loadingState
@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {

View file

@ -24,8 +24,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor(
@ -52,12 +50,6 @@ class RoomListRoomSummaryFactory @Inject constructor(
isFavorite = false,
)
}
fun createFakeList(): ImmutableList<RoomListRoomSummary> {
return List(16) {
createPlaceholder("!fakeRoom$it:domain")
}.toImmutableList()
}
}
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {

View file

@ -21,6 +21,8 @@ import dagger.Binds
import dagger.Module
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.Presenter
@ -34,4 +36,7 @@ interface RoomListModule {
@Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
@Binds
fun bindMigrationScreenPresenter(presenter: MigrationScreenPresenter): Presenter<MigrationScreenState>
}

View file

@ -20,12 +20,12 @@ import io.element.android.features.roomlist.impl.R
/**
* Enum class representing the different filters that can be applied to the room list.
* Order is important.
* Order is important, it'll be used as initial order in the UI.
*/
enum class RoomListFilter(val stringResource: Int) {
Rooms(R.string.screen_roomlist_filter_rooms),
People(R.string.screen_roomlist_filter_people),
Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms),
Favourites(R.string.screen_roomlist_filter_favourites);
val oppositeFilter: RoomListFilter?

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters
import androidx.annotation.StringRes
import io.element.android.features.roomlist.impl.R
/**
* Holds the resources for the empty state when filters are applied to the room list.
* @param title the title of the empty state
* @param subtitle the subtitle of the empty state
*/
data class RoomListFiltersEmptyStateResources(
@StringRes val title: Int,
@StringRes val subtitle: Int,
) {
companion object {
/**
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.
*/
fun fromSelectedFilters(selectedFilters: List<RoomListFilter>): RoomListFiltersEmptyStateResources? {
return when {
selectedFilters.isEmpty() -> null
selectedFilters.size == 1 -> {
when (selectedFilters.first()) {
RoomListFilter.Unread -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_unreads_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.People -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_people_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.Rooms -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_rooms_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.Favourites -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_favourites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle
)
}
}
else -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_mixed_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
}
}
}
}

View file

@ -17,6 +17,6 @@
package io.element.android.features.roomlist.impl.filters
sealed interface RoomListFiltersEvents {
data object ClearSelectedFilters : RoomListFiltersEvents
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
data object ClearSelectedFilters : RoomListFiltersEvents
}

View file

@ -20,9 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@ -34,50 +32,36 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as Matrix
class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService,
private val featureFlagService: FeatureFlagService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
@Composable
override fun present(): RoomListFiltersState {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
var unselectedFilters: Set<RoomListFilter> by rememberSaveable {
mutableStateOf(RoomListFilter.entries.toSet())
}
var selectedFilters: Set<RoomListFilter> by rememberSaveable {
mutableStateOf(emptySet())
}
fun updateFilters(newSelectedFilters: Set<RoomListFilter>) {
selectedFilters = newSelectedFilters
unselectedFilters = RoomListFilter.entries.toSet() -
selectedFilters -
selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
}
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
fun handleEvents(event: RoomListFiltersEvents) {
when (event) {
is RoomListFiltersEvents.ToggleFilter -> {
val newSelectedFilters = if (selectedFilters.contains(event.filter)) {
selectedFilters - event.filter
} else {
selectedFilters + event.filter
}
updateFilters(newSelectedFilters)
}
RoomListFiltersEvents.ClearSelectedFilters -> {
updateFilters(newSelectedFilters = emptySet())
filterSelectionStrategy.clear()
}
is RoomListFiltersEvents.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
}
}
}
LaunchedEffect(isFeatureEnabled) {
if (!isFeatureEnabled) {
updateFilters(emptySet())
filterSelectionStrategy.clear()
}
}
LaunchedEffect(selectedFilters) {
LaunchedEffect(filters) {
val allRoomsFilter = MatrixRoomListFilter.All(
selectedFilters.map { roomListFilter ->
when (roomListFilter) {
filters
.filter { it.isSelected }
.map { roomListFilter ->
when (roomListFilter.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
@ -89,8 +73,7 @@ class RoomListFiltersPresenter @Inject constructor(
}
return RoomListFiltersState(
unselectedFilters = unselectedFilters.toPersistentList(),
selectedFilters = selectedFilters.toPersistentList(),
filterSelectionStates = filters.toPersistentList(),
isFeatureEnabled = isFeatureEnabled,
eventSink = ::handleEvents
)

View file

@ -16,13 +16,21 @@
package io.element.android.features.roomlist.impl.filters
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class RoomListFiltersState(
val unselectedFilters: ImmutableList<RoomListFilter>,
val selectedFilters: ImmutableList<RoomListFilter>,
val filterSelectionStates: ImmutableList<FilterSelectionState>,
val isFeatureEnabled: Boolean,
val eventSink: (RoomListFiltersEvents) -> Unit,
) {
val hasAnyFilterSelected = selectedFilters.isNotEmpty()
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
fun selectedFilters(): ImmutableList<RoomListFilter> {
return filterSelectionStates
.filter { it.isSelected }
.map { it.filter }
.toPersistentList()
}
}

View file

@ -17,8 +17,7 @@
package io.element.android.features.roomlist.impl.filters
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.toImmutableList
class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersState> {
@ -26,20 +25,17 @@ class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersSta
get() = sequenceOf(
aRoomListFiltersState(),
aRoomListFiltersState(
selectedFilters = persistentListOf(RoomListFilter.Rooms, RoomListFilter.Favourites),
unselectedFilters = persistentListOf(RoomListFilter.Unread),
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
)
}
fun aRoomListFiltersState(
unselectedFilters: ImmutableList<RoomListFilter> = RoomListFilter.entries.toImmutableList(),
selectedFilters: ImmutableList<RoomListFilter> = persistentListOf(),
filterSelectionStates: List<FilterSelectionState> = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
isFeatureEnabled: Boolean = true,
eventSink: (RoomListFiltersEvents) -> Unit = {},
) = RoomListFiltersState(
unselectedFilters = unselectedFilters,
selectedFilters = selectedFilters,
filterSelectionStates = filterSelectionStates.toImmutableList(),
isFeatureEnabled = isFeatureEnabled,
eventSink = eventSink,
)

View file

@ -16,24 +16,22 @@
package io.element.android.features.roomlist.impl.filters
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -42,17 +40,14 @@ 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.modifiers.fadingEdge
import io.element.android.libraries.designsystem.modifiers.horizontalFadingEdgesBrush
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RoomListFiltersView(
state: RoomListFiltersState,
@ -62,53 +57,45 @@ fun RoomListFiltersView(
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
}
fun onFilterClicked(filter: RoomListFilter) {
fun onToggleFilter(filter: RoomListFilter) {
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
}
val startPadding = if (state.hasAnyFilterSelected) 4.dp else 16.dp
Row(
modifier = modifier.padding(start = startPadding, end = 16.dp),
val lazyListState = rememberLazyListState()
LazyRow(
contentPadding = PaddingValues(start = 8.dp, end = 16.dp),
modifier = modifier.fillMaxWidth(),
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(visible = state.hasAnyFilterSelected) {
RoomListClearFiltersButton(
modifier = Modifier.testTag(TestTags.homeScreenClearFilters),
onClick = ::onClearFiltersClicked
)
item("clear_filters") {
if (state.hasAnyFilterSelected) {
RoomListClearFiltersButton(
modifier = Modifier
.padding(start = 8.dp)
.testTag(TestTags.homeScreenClearFilters),
onClick = ::onClearFiltersClicked
)
}
}
val lazyListState = rememberLazyListState()
val fadingEdgesBrush = horizontalFadingEdgesBrush(
showLeft = lazyListState.canScrollBackward,
showRight = lazyListState.canScrollForward
)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.fadingEdge(fadingEdgesBrush),
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
roomListFilters(state.selectedFilters, selected = true, onClick = ::onFilterClicked)
roomListFilters(state.unselectedFilters, selected = false, onClick = ::onFilterClicked)
for (filterWithSelection in state.filterSelectionStates) {
item(filterWithSelection.filter) {
RoomListFilterView(
modifier = Modifier.animateItemPlacement(),
roomListFilter = filterWithSelection.filter,
selected = filterWithSelection.isSelected,
onClick = ::onToggleFilter,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.roomListFilters(
filters: ImmutableList<RoomListFilter>,
selected: Boolean,
onClick: (RoomListFilter) -> Unit,
) {
items(
items = filters,
) { filter ->
RoomListFilterView(
roomListFilter = filter,
selected = selected,
onClick = onClick,
)
LaunchedEffect(state.filterSelectionStates) {
// Checking for canScrollBackward is necessary for the itemPlacementAnimation to work correctly.
// We don't want the itemPlacementAnimation to be triggered when clearing the filters.
if (!state.hasAnyFilterSelected || lazyListState.canScrollBackward) {
lazyListState.animateScrollToItem(0)
}
}
}
@ -117,22 +104,18 @@ private fun RoomListClearFiltersButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
modifier = modifier,
onClick = onClick,
Box(
modifier = modifier
.clip(CircleShape)
.background(ElementTheme.colors.bgActionPrimaryRest)
.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(ElementTheme.colors.bgActionPrimaryRest)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Close(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(id = io.element.android.libraries.ui.strings.R.string.action_clear),
)
}
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Close(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(id = io.element.android.libraries.ui.strings.R.string.action_clear),
)
}
}
@ -146,9 +129,7 @@ private fun RoomListFilterView(
FilterChip(
selected = selected,
onClick = { onClick(roomListFilter) },
modifier = modifier
.minimumInteractiveComponentSize()
.height(36.dp),
modifier = modifier.height(36.dp),
shape = CircleShape,
colors = FilterChipDefaults.filterChipColors(
containerColor = ElementTheme.colors.bgCanvasDefault,

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet<RoomListFilter>()
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
override fun deselect(filter: RoomListFilter) {
selectedFilters.remove(filter)
filterSelectionStates.value = buildFilters()
}
override fun isSelected(filter: RoomListFilter): Boolean {
return selectedFilters.contains(filter)
}
override fun clear() {
selectedFilters.clear()
filterSelectionStates.value = buildFilters()
}
private fun buildFilters(): Set<FilterSelectionState> {
val selectedFilterStates = selectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = true
)
}
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = false
)
}
return (selectedFilterStates + unselectedFilterStates).toSet()
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import io.element.android.features.roomlist.impl.filters.RoomListFilter
data class FilterSelectionState(
val filter: RoomListFilter,
val isSelected: Boolean,
)

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
fun select(filter: RoomListFilter)
fun deselect(filter: RoomListFilter)
fun isSelected(filter: RoomListFilter): Boolean
fun clear()
fun toggle(filter: RoomListFilter) {
if (isSelected(filter)) {
deselect(filter)
} else {
select(filter)
}
}
}

View file

@ -33,8 +33,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
@ -54,13 +53,12 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -75,6 +73,7 @@ import io.element.android.libraries.matrix.test.verification.FakeSessionVerifica
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.MutablePresenter
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -86,7 +85,6 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class RoomListPresenterTests {
@get:Rule
@ -127,7 +125,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false)
@ -169,11 +166,9 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last()
// Room list is loaded with 16 placeholders
val initialItems = initialState.roomList.dataOrNull().orEmpty()
assertThat(initialItems.size).isEqualTo(16)
assertThat(initialItems.all { it.isPlaceholder }).isTrue()
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(
listOf(
aRoomSummaryFilled(
@ -182,10 +177,10 @@ class RoomListPresenterTests {
)
)
)
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 1 }.last()
val withRoomStateItems = withRoomState.roomList.dataOrNull().orEmpty()
assertThat(withRoomStateItems.size).isEqualTo(1)
assertThat(withRoomStateItems.first()).isEqualTo(
val withRoomsState =
consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && state.contentAsRooms().summaries.isNotEmpty() }.last()
assertThat(withRoomsState.contentAsRooms().summaries).hasSize(1)
assertThat(withRoomsState.contentAsRooms().summaries.first()).isEqualTo(
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
@ -241,23 +236,28 @@ class RoomListPresenterTests {
@Test
fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
}
},
roomListService = roomListService
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val eventSink = awaitItem().eventSink
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@ -265,16 +265,22 @@ class RoomListPresenterTests {
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService),
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = awaitItem().eventSink
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@ -282,7 +288,11 @@ class RoomListPresenterTests {
@Test
fun `present - handle DismissRecoveryKeyPrompt`() = runTest {
val encryptionService = FakeEncryptionService()
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply {
givenCanVerifySession(false)
@ -297,15 +307,16 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.securityBannerState).isEqualTo(SecurityBannerState.None)
val initialState = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
assertThat(initialState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem()
assertThat(nextState.securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
assertThat(nextState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
val finalState = awaitItem()
assertThat(finalState.securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@ -314,22 +325,30 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
val roomListService = FakeRoomListService()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource, coroutineScope = scope)
val presenter = createRoomListPresenter(
inviteStateDataSource = inviteStateDataSource,
coroutineScope = scope,
client = FakeMatrixClient(roomListService = roomListService),
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
inviteStateFlow.value = InvitesState.NewInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
scope.cancel()
}
}
@ -477,6 +496,7 @@ class RoomListPresenterTests {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
@ -488,12 +508,13 @@ class RoomListPresenterTests {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
state.roomList.dataOrNull().orEmpty().any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode }
(state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary ->
summary.id == A_ROOM_ID.value && summary.userDefinedNotificationMode == userDefinedMode
}
}.last()
val room = updatedState.roomList.dataOrNull()?.find { it.id == A_ROOM_ID.value }
val room = updatedState.contentAsRooms().summaries.find { it.id == A_ROOM_ID.value }
assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents()
scope.cancel()
@ -526,30 +547,46 @@ class RoomListPresenterTests {
}
}
fun `present - change in migration presenter state modifies isMigrating`() = runTest {
val client = FakeMatrixClient(sessionId = A_SESSION_ID)
val migrationStore = InMemoryMigrationScreenStore()
val migrationScreenPresenter = MigrationScreenPresenter(client, migrationStore)
@Test
fun `present - change in migration presenter state modifies contentState`() = runTest {
val migrationScreenPresenter = MutablePresenter(MigrationScreenState(true))
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
client = client,
coroutineScope = scope,
migrationScreenPresenter = migrationScreenPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
// The migration screen is shown if the migration screen has not been shown before
assertThat(initialState.displayMigrationStatus).isTrue()
skipItems(2)
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java)
// Set migration as done and set the room list service as running to trigger a refresh of the presenter value
(client.roomListService as FakeRoomListService).postState(RoomListService.State.Running)
migrationStore.setMigrationScreenShown(A_SESSION_ID)
migrationScreenPresenter.updateState(MigrationScreenState(false))
// The migration screen is not shown anymore
assertThat(awaitItem().displayMigrationStatus).isFalse()
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
scope.cancel()
}
}
@Test
fun `present - when room service returns no room, then contentState is Empty `() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
scope.cancel()
}
}
@ -609,10 +646,7 @@ class RoomListPresenterTests {
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
coroutineScope: CoroutineScope,
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
matrixClient = client,
migrationScreenStore = InMemoryMigrationScreenStore(),
),
migrationScreenPresenter: Presenter<MigrationScreenState> = Presenter { MigrationScreenState(false) },
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
internal fun RoomListState.contentAsRooms() = contentState as RoomListContentState.Rooms

View file

@ -26,7 +26,6 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@ -35,7 +34,6 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -50,7 +48,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
)
)
@ -65,7 +63,7 @@ class RoomListViewTest {
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
),
onVerifyClicked = callback,
@ -79,7 +77,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
)
)
@ -94,7 +92,7 @@ class RoomListViewTest {
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
),
onConfirmRecoveryKeyClicked = callback,
@ -110,7 +108,7 @@ class RoomListViewTest {
rule.setRoomListView(
state = aRoomListState(
eventSink = eventsRecorder,
roomList = AsyncData.Success(persistentListOf()),
contentState = anEmptyContentState(),
),
onCreateRoomClicked = callback,
)
@ -124,7 +122,7 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.roomList.dataOrNull()!!.first()
val room0 = state.contentAsRooms().summaries.first()
ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView(
state = state,
@ -140,7 +138,7 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.roomList.dataOrNull()!!.first()
val room0 = state.contentAsRooms().summaries.first()
rule.setRoomListView(
state = state,
)
@ -170,7 +168,7 @@ class RoomListViewTest {
fun `clicking on invites invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
invitesState = InvitesState.NewInvites,
contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
eventSink = eventsRecorder,
)
ensureCalledOnce { callback ->

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.R
import org.junit.Test
class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return null when selectedFilters is empty`() {
val selectedFilters = emptyList<RoomListFilter>()
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNull()
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() {
val selectedFilters = listOf(RoomListFilter.Unread)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() {
val selectedFilters = listOf(RoomListFilter.People)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() {
val selectedFilters = listOf(RoomListFilter.Rooms)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() {
val selectedFilters = listOf(RoomListFilter.Favourites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
}

View file

@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
@ -37,13 +39,12 @@ class RoomListFiltersPresenterTests {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Rooms,
RoomListFilter.People,
RoomListFilter.Unread,
RoomListFilter.Favourites,
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
)
}
cancelAndIgnoreRemainingEvents()
@ -58,32 +59,33 @@ class RoomListFiltersPresenterTests {
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).containsExactly(RoomListFilter.Rooms)
assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Unread,
RoomListFilter.Favourites,
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms,
)
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
}
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Rooms,
RoomListFilter.People,
RoomListFilter.Unread,
RoomListFilter.Favourites,
)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
}
@ -99,24 +101,28 @@ class RoomListFiltersPresenterTests {
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isNotEmpty()
assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters)
}
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
}
}
}
}
fun createRoomListFiltersPresenter(
private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = FilterSelectionState(
filter = filter,
isSelected = selected,
)
private fun createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListFiltersPresenter {
return RoomListFiltersPresenter(
roomListService = roomListService,
featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
)
}

View file

@ -20,12 +20,11 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -56,8 +55,7 @@ class RoomListFiltersViewTests {
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(
unselectedFilters = persistentListOf(),
selectedFilters = RoomListFilter.entries.toImmutableList(),
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
eventSink = eventsRecorder
),
)

View file

@ -29,6 +29,7 @@ import io.element.android.features.roomlist.impl.datasource.DefaultInviteStateDa
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource
@ -127,6 +128,7 @@ class RoomListScreen(
filtersPresenter = RoomListFiltersPresenter(
roomListService = matrixClient.roomListService,
featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
),
analyticsService = NoopAnalyticsService(),
)

View file

@ -30,6 +30,7 @@ dependencies {
implementation(libs.test.junit)
implementation(libs.test.truth)
implementation(libs.coroutines.test)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.test.turbine)

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.tests.testutils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.MutableStateFlow
class MutablePresenter<State>(initialState: State) : Presenter<State> {
private val stateFlow = MutableStateFlow(initialState)
fun updateState(state: State) {
stateFlow.value = state
}
@Composable
override fun present(): State {
return stateFlow.collectAsState().value
}
}

View file

@ -22,6 +22,7 @@ import app.cash.turbine.withTurbineTimeout
import io.element.android.libraries.core.bool.orFalse
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Consume all items until timeout is reached waiting for an event or we receive terminal event.
@ -48,7 +49,7 @@ suspend fun <T : Any> ReceiveTurbine<T>.awaitLastSequentialItem(): T {
* @return the list of consumed items.
*/
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate(
timeout: Duration = 100.milliseconds,
timeout: Duration = 3.seconds,
ignoreTimeoutError: Boolean = false,
predicate: (T) -> Boolean,
): List<T> {

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f29850d64823d7243ab2b6ec3f885fae2cf88aa763b18f96735094ebef9f19cd
size 36421

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d429c0ce8f4f34e7155775689bf8bc2efa61e83db6704e225592a2ea2c494b6
size 48004

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f783da840d0b70462c9361cf5cd89f0c5636758925b99a4095ab82bab53fe10
size 42000

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:855f3544c544966b8ff7a4a83f02feb9326e9dc80dd792f6749575045fe6c951
size 52543

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b036518dc3f7d67d97a747f98a2909ff39461b4219a184200abc4e49d83e38b
size 36041

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ec97a339d25fb7d0f928209567a81e13d65c2817ac21b372ff8573dc6d2f184
size 47603

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4743f2334377effb907c28b0bf0f550cd06f47f59ae6b71bf46de1299cf7c890
size 41609

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad5dee1ca507cbc94f4ba21141afb75c074766dc7ef25eaa13ea7f296bcd6e32
size 52194

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9097d88f12cd44680cdcef752d45b12884217c65bac846d3007f16393b3d7233
size 28036

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f99dc73a1c501c8f3e2a61cce1f479b35cdf75fa3909a8a282f396d9ac93237
size 24013

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01c07ac21d9ac4d9d916a4a15b27edc3d06f0f6206a736bc2ee5f0206c786523
size 16006

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7dd6178952670e12aa6f581793102afe44854457360bff654d7ca6089c8450ec
size 20112

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4e503d1c883b1b1b5a98f979afc04a2bc65aa23ba674ca3b96bcab3d7888773
size 27993

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30f05250641da8458b7891f881e6d50cd65f9e8d519672094df0ef7ed6c1afba
size 22795

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ce4c893f2a0aa99384d16c531776604cbf2eab9040467f64b42a6e625fdb217
size 15264

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c2301c797291b295a3433e2d26e2ec485f9e3b0ea825dea8794bb06fe608c48
size 19117

Some files were not shown because too many files have changed in this diff Show more