Browse Source

Add an empty state to the room list.

- Make `RoomListDataSource.allRooms` a `SharedFlow` so we can know when we don't have a value yet.
- Map its output in `RoomListPresenter` to `AsyncData`.
- Display the new empty state when the room list has loaded and has no items.
pull/2342/head
Jorge Martín 8 months ago
parent
commit
49646f2bef
  1. 1
      changelog.d/2330.feature
  2. 6
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
  3. 3
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
  4. 5
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
  5. 126
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
  6. 6
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
  7. 20
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt

1
changelog.d/2330.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add empty state to the room list.

6
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt

@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState @@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -32,6 +33,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor @@ -32,6 +33,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
@ -68,7 +70,9 @@ class RoomListPresenter @Inject constructor( @@ -68,7 +70,9 @@ class RoomListPresenter @Inject constructor(
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
mutableStateOf(null)
}
val roomList by roomListDataSource.allRooms.collectAsState()
val roomList by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val filteredRoomList by roomListDataSource.filteredRooms.collectAsState()
val filter by roomListDataSource.filter.collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()

3
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt

@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl @@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
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
@ -28,7 +29,7 @@ import kotlinx.collections.immutable.ImmutableList @@ -28,7 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomListState(
val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean,
val roomList: ImmutableList<RoomListRoomSummary>,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean,

5
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -49,13 +50,15 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> { @@ -49,13 +50,15 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
)
),
aRoomListState().copy(displayRecoveryKeyPrompt = true),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading()),
)
}
internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator = false,
roomList = aRoomListRoomSummaryList(),
roomList = AsyncData.Success(aRoomListRoomSummaryList()),
filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true,

126
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt

@ -17,7 +17,9 @@ @@ -17,7 +17,9 @@
package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@ -35,6 +37,7 @@ import androidx.compose.runtime.Composable @@ -35,6 +37,7 @@ 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
@ -42,6 +45,7 @@ import androidx.compose.ui.res.stringResource @@ -42,6 +45,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.ConfirmRecoveryKeyBanner
@ -51,17 +55,22 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar @@ -51,17 +55,22 @@ 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.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomListView(
@ -122,6 +131,35 @@ fun RoomListView( @@ -122,6 +131,35 @@ fun RoomListView(
}
}
@Composable
private fun EmptyRoomListView(
onCreateRoomClicked: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.screen_roomlist_empty_title),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.screen_roomlist_empty_message),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Resource(CommonDrawables.ic_new_message),
onClick = onCreateRoomClicked,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomListContent(
@ -182,56 +220,62 @@ private fun RoomListContent( @@ -182,56 +220,62 @@ private fun RoomListContent(
)
},
content = { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
when {
state.displayVerificationPrompt -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
println(state.roomList)
if (state.roomList is AsyncData.Success && state.roomList.data.isEmpty()) {
EmptyRoomListView(onCreateRoomClicked)
} else {
LazyColumn(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
when {
state.displayVerificationPrompt -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
state.displayRecoveryKeyPrompt -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClicked = onOpenSettings,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
}
state.displayRecoveryKeyPrompt -> {
if (state.invitesState != InvitesState.NoInvites) {
item {
ConfirmRecoveryKeyBanner(
onContinueClicked = onOpenSettings,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
}
}
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
val roomList = state.roomList.dataOrNull().orEmpty()
itemsIndexed(
items = roomList,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != roomList.lastIndex) {
HorizontalDivider()
}
}
}
itemsIndexed(
items = state.roomList,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked,
)
if (index != state.roomList.lastIndex) {
HorizontalDivider()
// Add a last Spacer item to ensure that the FAB does not hide the last room item
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
// Add a last Spacer item to ensure that the FAB does not hide the last room item
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
},
floatingActionButton = {

6
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt

@ -28,7 +28,9 @@ import kotlinx.collections.immutable.persistentListOf @@ -28,7 +28,9 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
@ -52,7 +54,7 @@ class RoomListDataSource @Inject constructor( @@ -52,7 +54,7 @@ class RoomListDataSource @Inject constructor(
}
private val _filter = MutableStateFlow("")
private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
private val lock = Mutex()
@ -90,7 +92,7 @@ class RoomListDataSource @Inject constructor( @@ -90,7 +92,7 @@ class RoomListDataSource @Inject constructor(
}
val filter: StateFlow<String> = _filter
val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
@OptIn(FlowPreview::class)

20
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt

@ -166,14 +166,16 @@ class RoomListPresenterTests { @@ -166,14 +166,16 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate { state -> state.roomList.size == 16 }.last()
val initialState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 16 }.last()
// Room list is loaded with 16 placeholders
assertThat(initialState.roomList.size).isEqualTo(16)
assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue()
val initialItems = initialState.roomList.dataOrNull().orEmpty()
assertThat(initialItems.size).isEqualTo(16)
assertThat(initialItems.all { it.isPlaceholder }).isTrue()
roomListService.postAllRooms(listOf(aRoomSummaryFilled()))
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last()
assertThat(withRoomState.roomList.size).isEqualTo(1)
assertThat(withRoomState.roomList.first())
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 1 }.last()
val withRoomStateItems = withRoomState.roomList.dataOrNull().orEmpty()
assertThat(withRoomStateItems.size).isEqualTo(1)
assertThat(withRoomStateItems.first())
.isEqualTo(aRoomListRoomSummary)
scope.cancel()
}
@ -194,7 +196,7 @@ class RoomListPresenterTests { @@ -194,7 +196,7 @@ class RoomListPresenterTests {
skipItems(3)
val loadedState = awaitItem()
// Test filtering with result
assertThat(loadedState.roomList.size).isEqualTo(1)
assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1)
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
skipItems(1)
val withFilteredRoomState = awaitItem()
@ -384,10 +386,10 @@ class RoomListPresenterTests { @@ -384,10 +386,10 @@ class RoomListPresenterTests {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
state.roomList.any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode }
state.roomList.dataOrNull().orEmpty().any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode }
}.last()
val room = updatedState.roomList.find { it.id == A_ROOM_ID.value }
val room = updatedState.roomList.dataOrNull()?.find { it.id == A_ROOM_ID.value }
assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents()
scope.cancel()

Loading…
Cancel
Save