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 eba6db5ce2
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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow 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.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource 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.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.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData 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.core.RoomId
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -85,7 +89,7 @@ class RoomListPresenter @Inject constructor(
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val filtersPresenter: Presenter<RoomListFiltersState>, private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>, private val searchPresenter: Presenter<RoomListSearchState>,
private val migrationScreenPresenter: MigrationScreenPresenter, private val migrationScreenPresenter: Presenter<MigrationScreenState>,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
@ -100,11 +104,7 @@ class RoomListPresenter @Inject constructor(
val matrixUser: MutableState<MatrixUser?> = rememberSaveable { val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
val roomList by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
@ -113,28 +113,7 @@ class RoomListPresenter @Inject constructor(
initialLoad(matrixUser) initialLoad(matrixUser)
} }
val isMigrating = migrationScreenPresenter.present().isMigrating
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } 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 // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
@ -162,19 +141,18 @@ class RoomListPresenter @Inject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val contentState = roomListContentState(securityBannerDismissed)
return RoomListState( return RoomListState(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
securityBannerState = securityBannerState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(),
contextMenu = contextMenu.value, contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
searchState = searchState, searchState = searchState,
displayMigrationStatus = isMigrating, contentState = contentState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -183,6 +161,70 @@ class RoomListPresenter @Inject constructor(
matrixUser.value = client.getCurrentUser() 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) @OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch { private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown( 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.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState 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.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -31,20 +30,17 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomListState( data class RoomListState(
val matrixUser: MatrixUser?, val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean, val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val securityBannerState: SecurityBannerState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState,
val contextMenu: ContextMenu, val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState, val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState, val searchState: RoomListSearchState,
val displayMigrationStatus: Boolean, val contentState: RoomListContentState,
val eventSink: (RoomListEvents) -> Unit, val eventSink: (RoomListEvents) -> Unit,
) { ) {
val displayFilters = filtersState.isFeatureEnabled && !displayMigrationStatus val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
val displayEmptyState = roomList is AsyncData.Success && roomList.data.isEmpty() val displayActions = contentState !is RoomListContentState.Migration
sealed interface ContextMenu { sealed interface ContextMenu {
data object Hidden : ContextMenu data object Hidden : ContextMenu
@ -70,3 +66,15 @@ enum class SecurityBannerState {
SessionVerification, SessionVerification,
RecoveryKeyConfirmation, 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState 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.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState 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.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary 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.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -43,15 +41,15 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(), aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false), aRoomListState(hasNetworkConnection = false),
aRoomListState(invitesState = InvitesState.SeenInvites), aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
aRoomListState(invitesState = InvitesState.NewInvites), aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(roomList = AsyncData.Success(persistentListOf())), aRoomListState(contentState = anEmptyContentState()),
aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = null, displayMigrationStatus = true), aRoomListState(matrixUser = null, contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)), aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
) )
@ -60,30 +58,24 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
internal fun aRoomListState( internal fun aRoomListState(
matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false, showAvatarIndicator: Boolean = false,
roomList: AsyncData<ImmutableList<RoomListRoomSummary>> = AsyncData.Success(aRoomListRoomSummaryList()),
hasNetworkConnection: Boolean = true, hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
invitesState: InvitesState = InvitesState.NoInvites,
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden, contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(), searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false), filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
displayMigrationStatus: Boolean = false, contentState: RoomListContentState = aRoomsContentState(),
eventSink: (RoomListEvents) -> Unit = {} eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState( ) = RoomListState(
matrixUser = matrixUser, matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
hasNetworkConnection = hasNetworkConnection, hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
securityBannerState = securityBannerState,
invitesState = invitesState,
contextMenu = contextMenu, contextMenu = contextMenu,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
searchState = searchState,
filtersState = filtersState, filtersState = filtersState,
displayMigrationStatus = displayMigrationStatus, searchState = searchState,
contentState = contentState,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -17,62 +17,36 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer 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.RoomListContentView
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction 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.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.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchView import io.element.android.features.roomlist.impl.search.RoomListSearchView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.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.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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun RoomListView( fun RoomListView(
@ -108,7 +82,7 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState) LeaveRoomView(state = state.leaveRoomState)
RoomListContent( RoomListScaffold(
modifier = Modifier.padding(top = topPadding), modifier = Modifier.padding(top = topPadding),
state = state, state = state,
onVerifyClicked = onVerifyClicked, 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomListContent( private fun RoomListScaffold(
state: RoomListState, state: RoomListState,
onVerifyClicked: () -> Unit, onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
@ -188,111 +128,43 @@ private fun RoomListContent(
} }
val appBarState = rememberTopAppBarState() 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 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) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
Column { RoomListTopBar(
RoomListTopBar( matrixUser = state.matrixUser,
matrixUser = state.matrixUser, showAvatarIndicator = state.showAvatarIndicator,
showAvatarIndicator = state.showAvatarIndicator, areSearchResultsDisplayed = state.searchState.isSearchActive,
areSearchResultsDisplayed = state.searchState.isSearchActive, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, onMenuActionClicked = onMenuActionClicked,
onMenuActionClicked = onMenuActionClicked, onOpenSettings = onOpenSettings,
onOpenSettings = onOpenSettings, scrollBehavior = scrollBehavior,
scrollBehavior = scrollBehavior, displayMenuItems = state.displayActions,
displayMenuItems = !state.displayMigrationStatus, displayFilters = state.displayFilters,
) filtersState = state.filtersState,
if (state.displayFilters) { )
RoomListFiltersView(state = state.filtersState)
}
}
}, },
content = { padding -> 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 modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(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 = { floatingActionButton = {
if (!state.displayMigrationStatus) { if (state.displayActions) {
FloatingActionButton( FloatingActionButton(
// FIXME align on Design system theme // FIXME align on Design system theme
containerColor = MaterialTheme.colorScheme.primary, 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 package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -40,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.R 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.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatarBloom import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -91,6 +95,8 @@ fun RoomListTopBar(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
displayMenuItems: Boolean, displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DefaultRoomListTopBar( DefaultRoomListTopBar(
@ -102,6 +108,8 @@ fun RoomListTopBar(
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
displayMenuItems = displayMenuItems, displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
modifier = modifier, modifier = modifier,
) )
} }
@ -117,6 +125,8 @@ private fun DefaultRoomListTopBar(
onSearchClicked: () -> Unit, onSearchClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit, onMenuActionClicked: (RoomListMenuAction) -> Unit,
displayMenuItems: Boolean, displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// We need this to manually clip the top app bar in preview mode // We need this to manually clip the top app bar in preview mode
@ -153,12 +163,11 @@ private fun DefaultRoomListTopBar(
titleLarge = collapsedTitleTextStyle titleLarge = collapsedTitleTextStyle
), ),
) { ) {
MediumTopAppBar( Column(
modifier = Modifier modifier = Modifier
.onSizeChanged { .onSizeChanged {
appBarHeight = it.height appBarHeight = it.height
} }
.nestedScroll(scrollBehavior.nestedScrollConnection)
.avatarBloom( .avatarBloom(
avatarData = avatarData, avatarData = avatarData,
background = if (ElementTheme.isLightTheme) { background = if (ElementTheme.isLightTheme) {
@ -178,113 +187,104 @@ private fun DefaultRoomListTopBar(
DpSize.Unspecified DpSize.Unspecified
}, },
bottomSoftEdgeColor = ElementTheme.materialColors.background, bottomSoftEdgeColor = ElementTheme.materialColors.background,
bottomSoftEdgeAlpha = 1f - collapsedFraction, bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
1f - collapsedFraction
},
alpha = if (areSearchResultsDisplayed) 0f else 1f, alpha = if (areSearchResultsDisplayed) 0f else 1f,
) )
.statusBarsPadding(), .statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors( ) {
containerColor = Color.Transparent, MediumTopAppBar(
scrolledContainerColor = Color.Transparent, colors = TopAppBarDefaults.mediumTopAppBarColors(
), containerColor = Color.Transparent,
title = { scrolledContainerColor = Color.Transparent,
Text(text = stringResource(id = R.string.screen_roomlist_main_space_title)) ),
}, title = {
navigationIcon = { Text(text = stringResource(id = R.string.screen_roomlist_main_space_title))
IconButton( },
modifier = Modifier.testTag(TestTags.homeScreenSettings), navigationIcon = {
onClick = onOpenSettings NavigationIcon(
) { avatarData = avatarData,
if (avatarData != null) { showAvatarIndicator = showAvatarIndicator,
Avatar( onClick = onOpenSettings,
avatarData = avatarData!!, )
contentDescription = stringResource(CommonStrings.common_settings), },
) actions = {
} else { if (displayMenuItems) {
// 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) }
IconButton( IconButton(
onClick = { showMenu = !showMenu } onClick = onSearchClicked,
) { ) {
Icon( Icon(
imageVector = CompoundIcons.OverflowVertical(), imageVector = CompoundIcons.Search(),
contentDescription = null, contentDescription = stringResource(CommonStrings.action_search),
) )
} }
DropdownMenu( if (RoomListConfig.HAS_DROP_DOWN_MENU) {
expanded = showMenu, var showMenu by remember { mutableStateOf(false) }
onDismissRequest = { showMenu = false } IconButton(
) { onClick = { showMenu = !showMenu }
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) { ) {
DropdownMenuItem( Icon(
onClick = { imageVector = CompoundIcons.OverflowVertical(),
showMenu = false contentDescription = null,
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) { DropdownMenu(
DropdownMenuItem( expanded = showMenu,
onClick = { onDismissRequest = { showMenu = false }
showMenu = false ) {
onMenuActionClicked(RoomListMenuAction.ReportBug) if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
}, DropdownMenuItem(
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, onClick = {
leadingIcon = { showMenu = false
Icon( onMenuActionClicked(RoomListMenuAction.InviteFriends)
imageVector = CompoundIcons.ChatProblem(), },
tint = ElementTheme.materialColors.secondary, text = { Text(stringResource(id = CommonStrings.action_invite)) },
contentDescription = null, 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,
scrollBehavior = scrollBehavior, windowInsets = WindowInsets(0.dp),
windowInsets = WindowInsets(0.dp), )
) if (displayFilters) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
} }
HorizontalDivider( HorizontalDivider(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.alpha(collapsedFraction) .alpha(collapsedFraction)
.align(Alignment.BottomCenter), .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) @OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@ -305,6 +339,8 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
onOpenSettings = {}, onOpenSettings = {},
onSearchClicked = {}, onSearchClicked = {},
displayMenuItems = true, displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
onMenuActionClicked = {}, onMenuActionClicked = {},
) )
} }
@ -321,6 +357,8 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
onOpenSettings = {}, onOpenSettings = {},
onSearchClicked = {}, onSearchClicked = {},
displayMenuItems = true, displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
onMenuActionClicked = {}, onMenuActionClicked = {},
) )
} }

View file

@ -27,12 +27,11 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -62,19 +61,15 @@ class RoomListDataSource @Inject constructor(
roomListService roomListService
.allRooms .allRooms
.summaries .summaries
.onStart {
// If we have no cached results, display a placeholder loading state
if (diffCache.isEmpty()) {
_allRooms.emit(RoomListRoomSummaryFactory.createFakeList())
}
}
.onEach { roomSummaries -> .onEach { roomSummaries ->
replaceWith(roomSummaries) replaceWith(roomSummaries)
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms
val loadingState = roomListService.allRooms.loadingState
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun observeNotificationSettings() { 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.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor( class RoomListRoomSummaryFactory @Inject constructor(
@ -52,12 +50,6 @@ class RoomListRoomSummaryFactory @Inject constructor(
isFavorite = false, isFavorite = false,
) )
} }
fun createFakeList(): ImmutableList<RoomListRoomSummary> {
return List(16) {
createPlaceholder("!fakeRoom$it:domain")
}.toImmutableList()
}
} }
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary { fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {

View file

@ -21,6 +21,8 @@ import dagger.Binds
import dagger.Module import dagger.Module
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter 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.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.RoomListSearchPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -34,4 +36,7 @@ interface RoomListModule {
@Binds @Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState> 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. * 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) { 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), 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); Favourites(R.string.screen_roomlist_filter_favourites);
val oppositeFilter: RoomListFilter? 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 package io.element.android.features.roomlist.impl.filters
sealed interface RoomListFiltersEvents { sealed interface RoomListFiltersEvents {
data object ClearSelectedFilters : RoomListFiltersEvents
data class ToggleFilter(val filter: RoomListFilter) : 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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( class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> { ) : Presenter<RoomListFiltersState> {
@Composable @Composable
override fun present(): RoomListFiltersState { override fun present(): RoomListFiltersState {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false) val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
var unselectedFilters: Set<RoomListFilter> by rememberSaveable { val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
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()
}
fun handleEvents(event: RoomListFiltersEvents) { fun handleEvents(event: RoomListFiltersEvents) {
when (event) { when (event) {
is RoomListFiltersEvents.ToggleFilter -> {
val newSelectedFilters = if (selectedFilters.contains(event.filter)) {
selectedFilters - event.filter
} else {
selectedFilters + event.filter
}
updateFilters(newSelectedFilters)
}
RoomListFiltersEvents.ClearSelectedFilters -> { RoomListFiltersEvents.ClearSelectedFilters -> {
updateFilters(newSelectedFilters = emptySet()) filterSelectionStrategy.clear()
}
is RoomListFiltersEvents.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
} }
} }
} }
LaunchedEffect(isFeatureEnabled) { LaunchedEffect(isFeatureEnabled) {
if (!isFeatureEnabled) { if (!isFeatureEnabled) {
updateFilters(emptySet()) filterSelectionStrategy.clear()
} }
} }
LaunchedEffect(selectedFilters) { LaunchedEffect(filters) {
val allRoomsFilter = MatrixRoomListFilter.All( val allRoomsFilter = MatrixRoomListFilter.All(
selectedFilters.map { roomListFilter -> filters
when (roomListFilter) { .filter { it.isSelected }
.map { roomListFilter ->
when (roomListFilter.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread RoomListFilter.Unread -> MatrixRoomListFilter.Unread
@ -89,8 +73,7 @@ class RoomListFiltersPresenter @Inject constructor(
} }
return RoomListFiltersState( return RoomListFiltersState(
unselectedFilters = unselectedFilters.toPersistentList(), filterSelectionStates = filters.toPersistentList(),
selectedFilters = selectedFilters.toPersistentList(),
isFeatureEnabled = isFeatureEnabled, isFeatureEnabled = isFeatureEnabled,
eventSink = ::handleEvents eventSink = ::handleEvents
) )

View file

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

View file

@ -16,24 +16,22 @@
package io.element.android.features.roomlist.impl.filters package io.element.android.features.roomlist.impl.filters
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -42,17 +40,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RoomListFiltersView( fun RoomListFiltersView(
state: RoomListFiltersState, state: RoomListFiltersState,
@ -62,53 +57,45 @@ fun RoomListFiltersView(
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters) state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
} }
fun onFilterClicked(filter: RoomListFilter) { fun onToggleFilter(filter: RoomListFilter) {
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter)) state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
} }
val startPadding = if (state.hasAnyFilterSelected) 4.dp else 16.dp val lazyListState = rememberLazyListState()
Row( LazyRow(
modifier = modifier.padding(start = startPadding, end = 16.dp), contentPadding = PaddingValues(start = 8.dp, end = 16.dp),
modifier = modifier.fillMaxWidth(),
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AnimatedVisibility(visible = state.hasAnyFilterSelected) { item("clear_filters") {
RoomListClearFiltersButton( if (state.hasAnyFilterSelected) {
modifier = Modifier.testTag(TestTags.homeScreenClearFilters), RoomListClearFiltersButton(
onClick = ::onClearFiltersClicked modifier = Modifier
) .padding(start = 8.dp)
.testTag(TestTags.homeScreenClearFilters),
onClick = ::onClearFiltersClicked
)
}
} }
val lazyListState = rememberLazyListState() for (filterWithSelection in state.filterSelectionStates) {
val fadingEdgesBrush = horizontalFadingEdgesBrush( item(filterWithSelection.filter) {
showLeft = lazyListState.canScrollBackward, RoomListFilterView(
showRight = lazyListState.canScrollForward modifier = Modifier.animateItemPlacement(),
) roomListFilter = filterWithSelection.filter,
LazyRow( selected = filterWithSelection.isSelected,
modifier = Modifier onClick = ::onToggleFilter,
.fillMaxWidth() )
.fadingEdge(fadingEdgesBrush), }
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
roomListFilters(state.selectedFilters, selected = true, onClick = ::onFilterClicked)
roomListFilters(state.unselectedFilters, selected = false, onClick = ::onFilterClicked)
} }
} }
} LaunchedEffect(state.filterSelectionStates) {
// Checking for canScrollBackward is necessary for the itemPlacementAnimation to work correctly.
@OptIn(ExperimentalFoundationApi::class) // We don't want the itemPlacementAnimation to be triggered when clearing the filters.
private fun LazyListScope.roomListFilters( if (!state.hasAnyFilterSelected || lazyListState.canScrollBackward) {
filters: ImmutableList<RoomListFilter>, lazyListState.animateScrollToItem(0)
selected: Boolean, }
onClick: (RoomListFilter) -> Unit,
) {
items(
items = filters,
) { filter ->
RoomListFilterView(
roomListFilter = filter,
selected = selected,
onClick = onClick,
)
} }
} }
@ -117,22 +104,18 @@ private fun RoomListClearFiltersButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
IconButton( Box(
modifier = modifier, modifier = modifier
onClick = onClick, .clip(CircleShape)
.background(ElementTheme.colors.bgActionPrimaryRest)
.clickable(onClick = onClick)
) { ) {
Box( Icon(
modifier = Modifier modifier = Modifier.align(Alignment.Center),
.clip(CircleShape) imageVector = CompoundIcons.Close(),
.background(ElementTheme.colors.bgActionPrimaryRest) 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( FilterChip(
selected = selected, selected = selected,
onClick = { onClick(roomListFilter) }, onClick = { onClick(roomListFilter) },
modifier = modifier modifier = modifier.height(36.dp),
.minimumInteractiveComponentSize()
.height(36.dp),
shape = CircleShape, shape = CircleShape,
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
containerColor = ElementTheme.colors.bgCanvasDefault, 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.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState 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.aRoomListFiltersState
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary 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.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState 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.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState 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.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.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
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_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder 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.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -86,7 +85,6 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class RoomListPresenterTests { class RoomListPresenterTests {
@get:Rule @get:Rule
@ -127,7 +125,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue() assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false) sessionVerificationService.givenCanVerifySession(false)
@ -169,11 +166,9 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last() val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
// Room list is loaded with 16 placeholders assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
val initialItems = initialState.roomList.dataOrNull().orEmpty() roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
assertThat(initialItems.size).isEqualTo(16)
assertThat(initialItems.all { it.isPlaceholder }).isTrue()
roomListService.postAllRooms( roomListService.postAllRooms(
listOf( listOf(
aRoomSummaryFilled( aRoomSummaryFilled(
@ -182,10 +177,10 @@ class RoomListPresenterTests {
) )
) )
) )
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 1 }.last() val withRoomsState =
val withRoomStateItems = withRoomState.roomList.dataOrNull().orEmpty() consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && state.contentAsRooms().summaries.isNotEmpty() }.last()
assertThat(withRoomStateItems.size).isEqualTo(1) assertThat(withRoomsState.contentAsRooms().summaries).hasSize(1)
assertThat(withRoomStateItems.first()).isEqualTo( assertThat(withRoomsState.contentAsRooms().summaries.first()).isEqualTo(
createRoomListRoomSummary( createRoomListRoomSummary(
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2, numberOfUnreadMessages = 2,
@ -241,23 +236,28 @@ class RoomListPresenterTests {
@Test @Test
fun `present - handle RecoveryKeyConfirmation last session`() = runTest { fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
coroutineScope = scope, coroutineScope = scope,
client = FakeMatrixClient( client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply { encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true) emitIsLastDevice(true)
} },
roomListService = roomListService
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) val eventSink = consumeItemsUntilPredicate {
val eventSink = awaitItem().eventSink it.contentState is RoomListContentState.Rooms
}.last().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation // 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) eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
} }
} }
@ -265,16 +265,22 @@ class RoomListPresenterTests {
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService),
coroutineScope = scope, coroutineScope = scope,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val eventSink = awaitItem().eventSink val eventSink = consumeItemsUntilPredicate {
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification) it.contentState is RoomListContentState.Rooms
}.last().eventSink
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
eventSink(RoomListEvents.DismissRequestVerificationPrompt) eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
} }
} }
@ -282,7 +288,11 @@ class RoomListPresenterTests {
@Test @Test
fun `present - handle DismissRecoveryKeyPrompt`() = runTest { fun `present - handle DismissRecoveryKeyPrompt`() = runTest {
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService, encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply { sessionVerificationService = FakeSessionVerificationService().apply {
givenCanVerifySession(false) givenCanVerifySession(false)
@ -297,15 +307,16 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) val initialState = consumeItemsUntilPredicate {
val initialState = awaitItem() it.contentState is RoomListContentState.Rooms
assertThat(initialState.securityBannerState).isEqualTo(SecurityBannerState.None) }.last()
assertThat(initialState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem() val nextState = awaitItem()
assertThat(nextState.securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) assertThat(nextState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel() scope.cancel()
} }
} }
@ -314,22 +325,30 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest { fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow) val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
val roomListService = FakeRoomListService()
val scope = CoroutineScope(coroutineContext + SupervisorJob()) 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) consumeItemsUntilPredicate {
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites) it.contentState is RoomListContentState.Rooms
}
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites) assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
inviteStateFlow.value = InvitesState.NewInvites inviteStateFlow.value = InvitesState.NewInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites) assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites inviteStateFlow.value = InvitesState.NoInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites) assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
scope.cancel() scope.cancel()
} }
} }
@ -477,6 +496,7 @@ class RoomListPresenterTests {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode))) roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
@ -488,12 +508,13 @@ class RoomListPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode) notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state -> 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() }.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) assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
scope.cancel() scope.cancel()
@ -526,30 +547,46 @@ class RoomListPresenterTests {
} }
} }
fun `present - change in migration presenter state modifies isMigrating`() = runTest { @Test
val client = FakeMatrixClient(sessionId = A_SESSION_ID) fun `present - change in migration presenter state modifies contentState`() = runTest {
val migrationStore = InMemoryMigrationScreenStore() val migrationScreenPresenter = MutablePresenter(MigrationScreenState(true))
val migrationScreenPresenter = MigrationScreenPresenter(client, migrationStore)
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = client,
coroutineScope = scope, coroutineScope = scope,
migrationScreenPresenter = migrationScreenPresenter, migrationScreenPresenter = migrationScreenPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// The migration screen is shown if the migration screen has not been shown before // The migration screen is shown if the migration screen has not been shown before
assertThat(initialState.displayMigrationStatus).isTrue() assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java)
skipItems(2)
// Set migration as done and set the room list service as running to trigger a refresh of the presenter value // 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) migrationScreenPresenter.updateState(MigrationScreenState(false))
migrationStore.setMigrationScreenShown(A_SESSION_ID)
// The migration screen is not shown anymore // 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() scope.cancel()
} }
} }
@ -609,10 +646,7 @@ class RoomListPresenterTests {
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( migrationScreenPresenter: Presenter<MigrationScreenState> = Presenter { MigrationScreenState(false) },
matrixClient = client,
migrationScreenStore = InMemoryMigrationScreenStore(),
),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() }, filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() }, 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.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction 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.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled 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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -50,7 +48,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification, contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
@ -65,7 +63,7 @@ class RoomListViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification, contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onVerifyClicked = callback, onVerifyClicked = callback,
@ -79,7 +77,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation, contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
@ -94,7 +92,7 @@ class RoomListViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation, contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onConfirmRecoveryKeyClicked = callback, onConfirmRecoveryKeyClicked = callback,
@ -110,7 +108,7 @@ class RoomListViewTest {
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
roomList = AsyncData.Success(persistentListOf()), contentState = anEmptyContentState(),
), ),
onCreateRoomClicked = callback, onCreateRoomClicked = callback,
) )
@ -124,7 +122,7 @@ class RoomListViewTest {
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val room0 = state.roomList.dataOrNull()!!.first() val room0 = state.contentAsRooms().summaries.first()
ensureCalledOnceWithParam(room0.roomId) { callback -> ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView( rule.setRoomListView(
state = state, state = state,
@ -140,7 +138,7 @@ class RoomListViewTest {
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val room0 = state.roomList.dataOrNull()!!.first() val room0 = state.contentAsRooms().summaries.first()
rule.setRoomListView( rule.setRoomListView(
state = state, state = state,
) )
@ -170,7 +168,7 @@ class RoomListViewTest {
fun `clicking on invites invokes the expected callback`() { fun `clicking on invites invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState( val state = aRoomListState(
invitesState = InvitesState.NewInvites, contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
ensureCalledOnce { callback -> 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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
@ -37,13 +39,12 @@ class RoomListFiltersPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
RoomListFilter.Rooms, filterSelectionState(RoomListFilter.Unread, false),
RoomListFilter.People, filterSelectionState(RoomListFilter.People, false),
RoomListFilter.Unread, filterSelectionState(RoomListFilter.Rooms, false),
RoomListFilter.Favourites, filterSelectionState(RoomListFilter.Favourites, false),
) )
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -58,32 +59,33 @@ class RoomListFiltersPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).containsExactly(RoomListFilter.Rooms)
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.unselectedFilters).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
RoomListFilter.Unread, filterSelectionState(RoomListFilter.Rooms, true),
RoomListFilter.Favourites, filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms,
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly( assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group, MatrixRoomListFilter.Category.Group,
) )
state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
} }
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
RoomListFilter.Rooms, filterSelectionState(RoomListFilter.Unread, false),
RoomListFilter.People, filterSelectionState(RoomListFilter.People, false),
RoomListFilter.Unread, filterSelectionState(RoomListFilter.Rooms, false),
RoomListFilter.Favourites, filterSelectionState(RoomListFilter.Favourites, false),
) ).inOrder()
assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty() assertThat(roomListCurrentFilter.filters).isEmpty()
} }
@ -99,24 +101,28 @@ class RoomListFiltersPresenterTests {
}.test { }.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isNotEmpty()
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters) state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters)
} }
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
} }
} }
} }
} }
fun createRoomListFiltersPresenter( private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = FilterSelectionState(
filter = filter,
isSelected = selected,
)
private fun createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListFiltersPresenter { ): RoomListFiltersPresenter {
return RoomListFiltersPresenter( return RoomListFiltersPresenter(
roomListService = roomListService, roomListService = roomListService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
) )
} }

View file

@ -20,12 +20,11 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.R 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.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -56,8 +55,7 @@ class RoomListFiltersViewTests {
rule.setContent { rule.setContent {
RoomListFiltersView( RoomListFiltersView(
state = aRoomListFiltersState( state = aRoomListFiltersState(
unselectedFilters = persistentListOf(), filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
selectedFilters = RoomListFilter.entries.toImmutableList(),
eventSink = eventsRecorder 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.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory 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.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.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource
@ -127,6 +128,7 @@ class RoomListScreen(
filtersPresenter = RoomListFiltersPresenter( filtersPresenter = RoomListFiltersPresenter(
roomListService = matrixClient.roomListService, roomListService = matrixClient.roomListService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
), ),
analyticsService = NoopAnalyticsService(), analyticsService = NoopAnalyticsService(),
) )

View file

@ -30,6 +30,7 @@ dependencies {
implementation(libs.test.junit) implementation(libs.test.junit)
implementation(libs.test.truth) implementation(libs.test.truth)
implementation(libs.coroutines.test) implementation(libs.coroutines.test)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.test.turbine) 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 io.element.android.libraries.core.bool.orFalse
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds 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. * 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. * @return the list of consumed items.
*/ */
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate( suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate(
timeout: Duration = 100.milliseconds, timeout: Duration = 3.seconds,
ignoreTimeoutError: Boolean = false, ignoreTimeoutError: Boolean = false,
predicate: (T) -> Boolean, predicate: (T) -> Boolean,
): List<T> { ): 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