RoomList : refactor how we manage state so it's easier to reason about.
This commit is contained in:
parent
39ec6aae53
commit
c03dc07dcf
7 changed files with 368 additions and 169 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue