RoomList : refactor how we manage state so it's easier to reason about.
This commit is contained in:
@@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -52,6 +54,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
@@ -60,6 +63,7 @@ import io.element.android.libraries.matrix.api.user.getCurrentUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@@ -100,11 +104,7 @@ class RoomListPresenter @Inject constructor(
|
||||
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val roomList by produceState(initialValue = AsyncData.Loading()) {
|
||||
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
||||
}
|
||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||
|
||||
val filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
|
||||
@@ -113,28 +113,7 @@ class RoomListPresenter @Inject constructor(
|
||||
initialLoad(matrixUser)
|
||||
}
|
||||
|
||||
val isMigrating = migrationScreenPresenter.present().isMigrating
|
||||
|
||||
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
|
||||
val isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
val syncState by syncService.syncState.collectAsState()
|
||||
val securityBannerState by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
securityBannerDismissed -> SecurityBannerState.None
|
||||
canVerifySession -> if (isLastDevice) {
|
||||
SecurityBannerState.RecoveryKeyConfirmation
|
||||
} else {
|
||||
SecurityBannerState.SessionVerification
|
||||
}
|
||||
recoveryState == RecoveryState.INCOMPLETE &&
|
||||
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
|
||||
else -> SecurityBannerState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar indicator
|
||||
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
||||
@@ -162,19 +141,18 @@ class RoomListPresenter @Inject constructor(
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
val contentState = roomListContentState(securityBannerDismissed)
|
||||
|
||||
return RoomListState(
|
||||
matrixUser = matrixUser.value,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
roomList = roomList,
|
||||
securityBannerState = securityBannerState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||
invitesState = inviteStateDataSource.inviteState(),
|
||||
contextMenu = contextMenu.value,
|
||||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
displayMigrationStatus = isMigrating,
|
||||
contentState = contentState,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -183,6 +161,70 @@ class RoomListPresenter @Inject constructor(
|
||||
matrixUser.value = client.getCurrentUser()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun securityBannerState(
|
||||
securityBannerDismissed: Boolean,
|
||||
): State<SecurityBannerState> {
|
||||
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
|
||||
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
|
||||
val isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
val syncState by syncService.syncState.collectAsState()
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
currentSecurityBannerDismissed -> SecurityBannerState.None
|
||||
canVerifySession -> if (isLastDevice) {
|
||||
SecurityBannerState.RecoveryKeyConfirmation
|
||||
} else {
|
||||
SecurityBannerState.SessionVerification
|
||||
}
|
||||
recoveryState == RecoveryState.INCOMPLETE &&
|
||||
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
|
||||
else -> SecurityBannerState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun roomListContentState(
|
||||
securityBannerDismissed: Boolean,
|
||||
): RoomListContentState {
|
||||
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
|
||||
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
||||
}
|
||||
val loadingState by roomListDataSource.loadingState.collectAsState()
|
||||
val showMigration = migrationScreenPresenter.present().isMigrating
|
||||
val 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)
|
||||
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
|
||||
val initialState = RoomListState.ContextMenu.Shown(
|
||||
|
||||
@@ -21,7 +21,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -31,20 +30,17 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
data class RoomListState(
|
||||
val matrixUser: MatrixUser?,
|
||||
val showAvatarIndicator: Boolean,
|
||||
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
|
||||
val securityBannerState: SecurityBannerState,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val invitesState: InvitesState,
|
||||
val contextMenu: ContextMenu,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val displayMigrationStatus: Boolean,
|
||||
val contentState: RoomListContentState,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
val displayFilters = filtersState.isFeatureEnabled && !displayMigrationStatus
|
||||
val displayEmptyState = roomList is AsyncData.Success && roomList.data.isEmpty()
|
||||
val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
|
||||
val displayActions = contentState !is RoomListContentState.Migration
|
||||
|
||||
sealed interface ContextMenu {
|
||||
data object Hidden : ContextMenu
|
||||
@@ -70,3 +66,14 @@ enum class SecurityBannerState {
|
||||
SessionVerification,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.roomlist.impl
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
@@ -36,6 +35,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
override val values: Sequence<RoomListState>
|
||||
@@ -50,7 +50,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
|
||||
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
|
||||
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(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
|
||||
@@ -74,16 +74,17 @@ internal fun aRoomListState(
|
||||
) = RoomListState(
|
||||
matrixUser = matrixUser,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
roomList = roomList,
|
||||
hasNetworkConnection = hasNetworkConnection,
|
||||
snackbarMessage = snackbarMessage,
|
||||
securityBannerState = securityBannerState,
|
||||
invitesState = invitesState,
|
||||
contextMenu = contextMenu,
|
||||
leaveRoomState = leaveRoomState,
|
||||
searchState = searchState,
|
||||
filtersState = filtersState,
|
||||
displayMigrationStatus = displayMigrationStatus,
|
||||
searchState = searchState,
|
||||
contentState = RoomListContentState.Rooms(
|
||||
invitesState = invitesState,
|
||||
securityBannerState = securityBannerState,
|
||||
summaries = roomList.dataOrNull().orEmpty().toPersistentList(),
|
||||
),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.roomlist.impl.components.ConfirmRecoveryKeyBanner
|
||||
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
|
||||
import io.element.android.features.roomlist.impl.components.RoomListContentView
|
||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
@@ -108,7 +109,7 @@ fun RoomListView(
|
||||
|
||||
LeaveRoomView(state = state.leaveRoomState)
|
||||
|
||||
RoomListContent(
|
||||
RoomListScaffold(
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
state = state,
|
||||
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)
|
||||
@Composable
|
||||
private fun RoomListContent(
|
||||
private fun RoomListScaffold(
|
||||
state: RoomListState,
|
||||
onVerifyClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
@@ -188,26 +155,7 @@ private fun RoomListContent(
|
||||
}
|
||||
|
||||
val appBarState = rememberTopAppBarState()
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val visibleRange by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
|
||||
val size = layoutInfo.visibleItemsInfo.size
|
||||
firstItemIndex until firstItemIndex + size
|
||||
}
|
||||
}
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
|
||||
return super.onPostFling(consumed, available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
Scaffold(
|
||||
@@ -222,7 +170,7 @@ private fun RoomListContent(
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onOpenSettings = onOpenSettings,
|
||||
scrollBehavior = scrollBehavior,
|
||||
displayMenuItems = !state.displayMigrationStatus,
|
||||
displayMenuItems = !state.displayActions,
|
||||
)
|
||||
if (state.displayFilters) {
|
||||
RoomListFiltersView(state = state.filtersState)
|
||||
@@ -230,69 +178,22 @@ private fun RoomListContent(
|
||||
}
|
||||
},
|
||||
content = { padding ->
|
||||
LazyColumn(
|
||||
RoomListContentView(
|
||||
contentState = state.contentState,
|
||||
eventSink = state.eventSink,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = ::onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = lazyListState,
|
||||
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
|
||||
contentPadding = PaddingValues(bottom = 80.dp)
|
||||
) {
|
||||
when {
|
||||
state.displayEmptyState -> Unit
|
||||
state.securityBannerState == SecurityBannerState.SessionVerification -> {
|
||||
item {
|
||||
RequestVerificationHeader(
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
|
||||
)
|
||||
}
|
||||
}
|
||||
state.securityBannerState == SecurityBannerState.RecoveryKeyConfirmation -> {
|
||||
item {
|
||||
ConfirmRecoveryKeyBanner(
|
||||
onContinueClicked = onConfirmRecoveryKeyClicked,
|
||||
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.invitesState != InvitesState.NoInvites) {
|
||||
item {
|
||||
InvitesEntryPointView(onInvitesClicked, state.invitesState)
|
||||
}
|
||||
}
|
||||
|
||||
val roomList = state.roomList.dataOrNull().orEmpty()
|
||||
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
|
||||
// is moved to the top of the list.
|
||||
itemsIndexed(
|
||||
items = roomList,
|
||||
contentType = { _, room -> room.contentType() },
|
||||
) { index, room ->
|
||||
RoomSummaryRow(
|
||||
room = room,
|
||||
onClick = ::onRoomClicked,
|
||||
onLongClick = onRoomLongClicked,
|
||||
)
|
||||
if (index != roomList.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.displayEmptyState) {
|
||||
if (state.filtersState.hasAnyFilterSelected) {
|
||||
// TODO add empty state for filtered rooms
|
||||
} else {
|
||||
EmptyRoomListView(onCreateRoomClicked)
|
||||
}
|
||||
}
|
||||
MigrationScreenView(isMigrating = state.displayMigrationStatus)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!state.displayMigrationStatus) {
|
||||
if (state.displayActions) {
|
||||
FloatingActionButton(
|
||||
// FIXME align on Design system theme
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -62,19 +64,15 @@ class RoomListDataSource @Inject constructor(
|
||||
roomListService
|
||||
.allRooms
|
||||
.summaries
|
||||
.onStart {
|
||||
// If we have no cached results, display a placeholder loading state
|
||||
if (diffCache.isEmpty()) {
|
||||
_allRooms.emit(RoomListRoomSummaryFactory.createFakeList())
|
||||
}
|
||||
}
|
||||
.onEach { roomSummaries ->
|
||||
replaceWith(roomSummaries)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
|
||||
val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms
|
||||
|
||||
val loadingState = roomListService.allRooms.loadingState
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeNotificationSettings() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user