diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 245065a52f..4bd26a62cf 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -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 = 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 { + 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) = launch { val initialState = RoomListState.ContextMenu.Shown( diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index c4581732eb..58b36636c8 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -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>, - 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, + ) : RoomListContentState +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index bfc155f2d2..a47baf4bbc 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -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 { override val values: Sequence @@ -50,7 +50,7 @@ open class RoomListStateProvider : PreviewParameterProvider { 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, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index ab261c0cd5..08accccc67 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -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, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt new file mode 100644 index 0000000000..b58b8ebeb1 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt @@ -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() + } +} + diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt index cb61701342..5d92a55ba7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -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> = _allRooms + val allRooms: Flow> = _allRooms + + val loadingState = roomListService.allRooms.loadingState @OptIn(FlowPreview::class) private fun observeNotificationSettings() { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt index 02f87de745..aea57d32ab 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt @@ -53,11 +53,6 @@ class RoomListRoomSummaryFactory @Inject constructor( ) } - fun createFakeList(): ImmutableList { - return List(16) { - createPlaceholder("!fakeRoom$it:domain") - }.toImmutableList() - } } fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {