RoomList : refactor how we manage state so it's easier to reason about.

This commit is contained in:
ganfra 2024-03-09 16:53:48 +01:00
parent 39ec6aae53
commit c03dc07dcf
7 changed files with 368 additions and 169 deletions

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
@ -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
@ -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 showSkeleton by remember {
derivedStateOf {
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
val showEmpty by remember {
derivedStateOf {
(loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
}
}
return when {
showMigration -> RoomListContentState.Migration
showSkeleton -> RoomListContentState.Skeleton(count = 16)
showEmpty -> {
val invitesState = inviteStateDataSource.inviteState()
RoomListContentState.Empty(invitesState)
}
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,14 @@ enum class SecurityBannerState {
SessionVerification, SessionVerification,
RecoveryKeyConfirmation, RecoveryKeyConfirmation,
} }
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,7 +19,6 @@ 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
@ -36,6 +35,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> { open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState> override val values: Sequence<RoomListState>
@ -50,7 +50,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification), aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
aRoomListState(roomList = AsyncData.Success(persistentListOf())), aRoomListState(roomList = AsyncData.Success(persistentListOf())),
aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), //aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState(matrixUser = null, displayMigrationStatus = true), aRoomListState(matrixUser = null, displayMigrationStatus = true),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)), aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
@ -74,16 +74,17 @@ internal fun aRoomListState(
) = 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 = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = roomList.dataOrNull().orEmpty().toPersistentList(),
),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -53,6 +53,7 @@ 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.ConfirmRecoveryKeyBanner
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListContentView
import io.element.android.features.roomlist.impl.components.RoomListMenuAction import io.element.android.features.roomlist.impl.components.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.components.RoomSummaryRow
@ -108,7 +109,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 +136,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,26 +155,7 @@ 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(
@ -222,7 +170,7 @@ private fun RoomListContent(
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
displayMenuItems = !state.displayMigrationStatus, displayMenuItems = !state.displayActions,
) )
if (state.displayFilters) { if (state.displayFilters) {
RoomListFiltersView(state = state.filtersState) RoomListFiltersView(state = state.filtersState)
@ -230,69 +178,22 @@ private fun RoomListContent(
} }
}, },
content = { padding -> content = { padding ->
LazyColumn( RoomListContentView(
contentState = state.contentState,
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,255 @@
/*
* 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.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.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.RoomListEvents
import io.element.android.features.roomlist.impl.SecurityBannerState
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
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
@Composable
fun RoomListContentView(
contentState: RoomListContentState,
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,
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 = {
Text(
text = stringResource(R.string.screen_roomlist_empty_title),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
},
subtitle = {
Text(
text = stringResource(R.string.screen_roomlist_empty_message),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
},
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClicked,
)
},
modifier = Modifier,
)
}
}
@Composable
private fun RoomsView(
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),
verticalArrangement = Arrangement.spacedBy(8.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 EmptyScaffold(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
subtitle: @Composable (() -> Unit)? = null,
action: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
title()
Spacer(modifier = Modifier.height(4.dp))
subtitle?.invoke()
Spacer(modifier = Modifier.height(16.dp))
action?.invoke()
}
}

View file

@ -27,8 +27,10 @@ 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.SharedFlow
import kotlinx.coroutines.flow.StateFlow
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
@ -62,19 +64,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

@ -53,11 +53,6 @@ class RoomListRoomSummaryFactory @Inject constructor(
) )
} }
fun createFakeList(): ImmutableList<RoomListRoomSummary> {
return List(16) {
createPlaceholder("!fakeRoom$it:domain")
}.toImmutableList()
}
} }
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary { fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {