From 33ed44c789870dc2e18d0961aa992d575b779577 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 8 Aug 2024 21:36:39 +0200 Subject: [PATCH] Pinned events : try sharing pinned events timeline instance --- .../messages/impl/MessagesFlowNode.kt | 5 + .../pinned/PinnedEventsTimelineProvider.kt | 84 +++++++++++++ .../banner/PinnedMessagesBannerPresenter.kt | 111 ++++++++--------- .../list/PinnedMessagesListPresenter.kt | 89 +++++++++++-- .../pinned/list/PinnedMessagesListState.kt | 39 ++++-- .../pinned/list/PinnedMessagesListView.kt | 117 +++++++++++++----- .../PinnedMessagesTimelineListProvider.kt | 17 ++- .../impl/timeline/TimelinePresenter.kt | 3 +- .../factories/TimelineItemsFactory.kt | 18 ++- 9 files changed, 352 insertions(+), 131 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 3b30d11a7f..c244f73d35 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -41,6 +41,7 @@ import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.forward.ForwardMessagesNode +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode @@ -96,6 +97,7 @@ class MessagesFlowNode @AssistedInject constructor( private val room: MatrixRoom, private val roomMemberProfilesCache: RoomMemberProfilesCache, private val mentionSpanTheme: MentionSpanTheme, + private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -163,6 +165,9 @@ class MessagesFlowNode @AssistedInject constructor( roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) } .launchIn(lifecycleScope) + + pinnedEventsTimelineProvider.launchIn(lifecycleScope) + } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt new file mode 100644 index 0000000000..24060a6a80 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt @@ -0,0 +1,84 @@ +/* + * 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 + * + * https://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.messages.impl.pinned + +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class PinnedEventsTimelineProvider @Inject constructor( + private val room: MatrixRoom, + private val networkMonitor: NetworkMonitor, + private val featureFlagService: FeatureFlagService, +) { + private val _timelineStateFlow: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) + + val timelineStateFlow = _timelineStateFlow + + fun launchIn(scope: CoroutineScope) { + combine( + featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents), + networkMonitor.connectivity + ) { isEnabled, _ -> isEnabled } + .onEach { isFeatureEnabled -> + if (isFeatureEnabled) { + loadTimelineIfNeeded() + } else { + _timelineStateFlow.value = AsyncData.Uninitialized + } + } + .onCompletion { + invokeOnTimeline { it.close() } + } + .launchIn(scope) + } + + suspend fun invokeOnTimeline(action: suspend (Timeline) -> Unit) { + when (val asyncTimeline = timelineStateFlow.value) { + is AsyncData.Success -> action(asyncTimeline.data) + else -> Unit + } + } + + private suspend fun loadTimelineIfNeeded() { + when (timelineStateFlow.value) { + is AsyncData.Uninitialized, is AsyncData.Failure -> { + timelineStateFlow.emit(AsyncData.Loading()) + room.pinnedEventsTimeline() + .fold( + { timelineStateFlow.emit(AsyncData.Success(it)) }, + { timelineStateFlow.emit(AsyncData.Failure(it)) } + ) + } + else -> Unit + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt index 13c45e0092..b577995acb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt @@ -26,18 +26,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled -import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -45,46 +46,38 @@ import kotlin.time.Duration.Companion.milliseconds class PinnedMessagesBannerPresenter @Inject constructor( private val room: MatrixRoom, private val itemFactory: PinnedMessagesBannerItemFactory, - private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled, - private val networkMonitor: NetworkMonitor, + private val timelineController: PinnedEventsTimelineProvider, ) : Presenter { - private val pinnedItems = mutableStateOf>(persistentListOf()) + private val pinnedItems = mutableStateOf>>(AsyncData.Uninitialized) @Composable override fun present(): PinnedMessagesBannerState { - val isFeatureEnabled = isFeatureEnabled() val expectedPinnedMessagesCount by remember { room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size } }.collectAsState(initial = 0) - var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) } var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) } PinnedMessagesBannerItemsEffect( - isFeatureEnabled = isFeatureEnabled, onItemsChange = { newItems -> - val pinnedMessageCount = newItems.size + val pinnedMessageCount = newItems.dataOrNull().orEmpty().size if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) { currentPinnedMessageIndex = pinnedMessageCount - 1 } pinnedItems.value = newItems }, - onTimelineFail = { hasTimelineFailed -> - hasTimelineFailedToLoad = hasTimelineFailed - } ) fun handleEvent(event: PinnedMessagesBannerEvents) { when (event) { is PinnedMessagesBannerEvents.MoveToNextPinned -> { - currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size) + val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size + currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount) } } } return pinnedMessagesBannerState( - isFeatureEnabled = isFeatureEnabled, - hasTimelineFailed = hasTimelineFailedToLoad, expectedPinnedMessagesCount = expectedPinnedMessagesCount, pinnedItems = pinnedItems.value, currentPinnedMessageIndex = currentPinnedMessageIndex, @@ -94,63 +87,65 @@ class PinnedMessagesBannerPresenter @Inject constructor( @Composable private fun pinnedMessagesBannerState( - isFeatureEnabled: Boolean, - hasTimelineFailed: Boolean, expectedPinnedMessagesCount: Int, - pinnedItems: ImmutableList, + pinnedItems: AsyncData>, currentPinnedMessageIndex: Int, eventSink: (PinnedMessagesBannerEvents) -> Unit ): PinnedMessagesBannerState { - val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex) - return when { - !isFeatureEnabled -> PinnedMessagesBannerState.Hidden - hasTimelineFailed -> PinnedMessagesBannerState.Hidden - currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded( - currentPinnedMessage = currentPinnedMessage, - currentPinnedMessageIndex = currentPinnedMessageIndex, - loadedPinnedMessagesCount = pinnedItems.size, - eventSink = eventSink - ) - expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden - else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount) + return when (pinnedItems) { + is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden + is AsyncData.Loading -> { + if (expectedPinnedMessagesCount == 0) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount) + } + } + is AsyncData.Success -> { + val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex) + if (currentPinnedMessage == null) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loaded( + loadedPinnedMessagesCount = pinnedItems.data.size, + currentPinnedMessageIndex = currentPinnedMessageIndex, + currentPinnedMessage = currentPinnedMessage, + eventSink = eventSink + ) + } + } } } - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable private fun PinnedMessagesBannerItemsEffect( - isFeatureEnabled: Boolean, - onItemsChange: (ImmutableList) -> Unit, - onTimelineFail: (Boolean) -> Unit, + onItemsChange: (AsyncData>) -> Unit, ) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) - val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail) - val networkStatus by networkMonitor.connectivity.collectAsState() + LaunchedEffect(Unit) { + timelineController.timelineStateFlow + .flatMapLatest { asyncTimeline -> + when (asyncTimeline) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + asyncTimeline.data.timelineItems + .debounce(300.milliseconds) + .map { timelineItems -> + val pinnedItems = timelineItems.mapNotNull { timelineItem -> + itemFactory.create(timelineItem) + }.toImmutableList() - LaunchedEffect(isFeatureEnabled, networkStatus) { - if (!isFeatureEnabled) { - updatedOnItemsChange(persistentListOf()) - return@LaunchedEffect - } - val pinnedEventsTimeline = room.pinnedEventsTimeline() - .onFailure { updatedOnTimelineFail(true) } - .onSuccess { updatedOnTimelineFail(false) } - .getOrNull() - ?: return@LaunchedEffect - - pinnedEventsTimeline.timelineItems - .debounce(300.milliseconds) - .map { timelineItems -> - timelineItems.mapNotNull { timelineItem -> - itemFactory.create(timelineItem) - }.toImmutableList() + AsyncData.Success(pinnedItems) + } + } + } } .onEach { newItems -> updatedOnItemsChange(newItems) } - .onCompletion { - pinnedEventsTimeline.close() - } .launchIn(this) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 35d6c984f4..94a7a32cc1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -18,53 +18,118 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import javax.inject.Inject class PinnedMessagesListPresenter @Inject constructor( private val room: MatrixRoom, private val timelineItemsFactory: TimelineItemsFactory, + private val timelineProvider: PinnedEventsTimelineProvider, ) : Presenter { @Composable override fun present(): PinnedMessagesListState { - val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineRoomInfo = remember { TimelineRoomInfo( isDm = room.isDm, name = room.displayName, + // We don't need to compute those values userHasPermissionToSendMessage = false, userHasPermissionToSendReaction = false, isCallOngoing = false, ) } - LaunchedEffect(Unit) { - val timeline = room.pinnedEventsTimeline().getOrNull() ?: return@LaunchedEffect - combine(timeline.timelineItems, room.membersStateFlow) { items, membersState -> - timelineItemsFactory.replaceWith( - timelineItems = items, - roomMembers = membersState.roomMembers().orEmpty() - ) - items - }.launchIn(this) + + var pinnedMessageItems by remember { + mutableStateOf>>(AsyncData.Uninitialized) } + PinnedMessagesListEffect( + onItemsChange = { newItems -> + pinnedMessageItems = newItems + } + ) + fun handleEvents(event: PinnedMessagesListEvents) { } - return PinnedMessagesListState( + return pinnedMessagesListState( timelineRoomInfo = timelineRoomInfo, - timelineItems = timelineItems, + timelineItems = pinnedMessageItems, eventSink = ::handleEvents ) } + + @Composable + private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { + val updatedOnItemsChange by rememberUpdatedState(onItemsChange) + + val timelineState by timelineProvider.timelineStateFlow.collectAsState() + + LaunchedEffect(timelineState) { + when (val asyncTimeline = timelineState) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + combine(asyncTimeline.data.timelineItems, room.membersStateFlow) { items, membersState -> + timelineItemsFactory.replaceWith( + timelineItems = items, + roomMembers = membersState.roomMembers().orEmpty() + ) + }.launchIn(this) + + timelineItemsFactory.timelineItems.map { timelineItems -> + AsyncData.Success(timelineItems) + } + } + } + .onEach { items -> + updatedOnItemsChange(items) + } + .launchIn(this) + } + } + + private fun pinnedMessagesListState( + timelineRoomInfo: TimelineRoomInfo, + timelineItems: AsyncData>, + eventSink: (PinnedMessagesListEvents) -> Unit + ): PinnedMessagesListState { + return when (timelineItems) { + AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading + is AsyncData.Failure -> PinnedMessagesListState.Failed + is AsyncData.Success -> { + if (timelineItems.data.isEmpty()) { + PinnedMessagesListState.Empty + } else { + PinnedMessagesListState.Filled( + timelineRoomInfo = timelineRoomInfo, + timelineItems = timelineItems.data, + eventSink = eventSink + ) + } + } + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index 80a3578e74..2e0083842f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -16,19 +16,36 @@ package io.element.android.features.messages.impl.pinned.list +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -data class PinnedMessagesListState( - val timelineRoomInfo: TimelineRoomInfo, - val timelineItems: ImmutableList, - val eventSink: (PinnedMessagesListEvents) -> Unit, +@Immutable +sealed interface PinnedMessagesListState { + data object Failed : PinnedMessagesListState + data object Loading : PinnedMessagesListState + data object Empty : PinnedMessagesListState + data class Filled( + val timelineRoomInfo: TimelineRoomInfo, + val timelineItems: ImmutableList, + val eventSink: (PinnedMessagesListEvents) -> Unit, + ) : PinnedMessagesListState { + val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } + } - val pinnedMessagesCount: String = - timelineItems - .count { timelineItem -> timelineItem is TimelineItem.Event } - .takeIf { it > 0 } - ?.toString() - ?: "" -) + @Composable + fun title(): String { + return when (this) { + is Filled -> { + pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount) + } + else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index c86b14a042..fbb3586f49 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -33,9 +34,12 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -63,8 +67,8 @@ fun PinnedMessagesListView( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding), + .padding(padding) + .consumeWindowInsets(padding), ) } ) @@ -80,7 +84,7 @@ private fun PinnedMessagesListTopBar( TopAppBar( title = { Text( - text = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, state.pinnedMessagesCount), + text = state.title(), style = ElementTheme.typography.fontBodyLgMedium ) }, @@ -97,42 +101,87 @@ private fun PinnedMessagesListContent( onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = rememberLazyListState(), - reverseLayout = true, - contentPadding = PaddingValues(vertical = 8.dp), - ) { - items( - items = state.timelineItems, - contentType = { timelineItem -> timelineItem.contentType() }, - key = { timelineItem -> timelineItem.identifier() }, - ) { timelineItem -> - TimelineItemRow( - timelineItem = timelineItem, - timelineRoomInfo = state.timelineRoomInfo, - renderReadReceipts = false, - isLastOutgoingMessage = false, - focusedEventId = null, - onClick = onEventClick, - onLongClick = {}, - onUserDataClick = onUserDataClick, - onLinkClick = onLinkClick, - inReplyToClick = {}, - onReactionClick = { _, _ -> }, - onReactionLongClick = { _, _ -> }, - onMoreReactionsClick = {}, - onReadReceiptClick = {}, - eventSink = {}, - onSwipeToReply = {}, - onJoinCallClick = {}, - ) + Box(modifier.fillMaxSize()) { + when (state) { + PinnedMessagesListState.Failed -> Unit + PinnedMessagesListState.Empty -> PinnedMessagesListEmpty() + is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded( + state = state, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + ) + PinnedMessagesListState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } } } } +@Composable +private fun PinnedMessagesListEmpty( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 16.dp, + vertical = 48.dp + ) + ) { + val pinActionText = stringResource(id = CommonStrings.action_pin) + IconTitleSubtitleMolecule( + title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline), + subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText), + iconResourceId = CompoundDrawables.ic_compound_pin, + modifier = modifier, + ) + } +} + +@Composable +private fun PinnedMessagesListLoaded( + state: PinnedMessagesListState.Filled, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = rememberLazyListState(), + reverseLayout = true, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineRoomInfo = state.timelineRoomInfo, + renderReadReceipts = false, + isLastOutgoingMessage = false, + focusedEventId = null, + onClick = onEventClick, + onLongClick = {}, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + eventSink = {}, + onSwipeToReply = {}, + onJoinCallClick = {}, + ) + } + } +} + @PreviewsDayNight @Composable fun PinnedMessagesTimelineViewPreview(@PreviewParameter(PinnedMessagesTimelineStateProvider::class) state: PinnedMessagesListState) = diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt index b7f3124406..bd35407a93 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesTimelineListProvider.kt @@ -25,15 +25,24 @@ import kotlinx.collections.immutable.toImmutableList open class PinnedMessagesTimelineStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - pinnedMessagesListState(), + aFailedPinnedMessagesListState(), + aLoadingPinnedMessagesListState(), + anEmptyPinnedMessagesListState(), + aLoadedPinnedMessagesListState() ) } -fun pinnedMessagesListState( +fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed + +fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading + +fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty + +fun aLoadedPinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineItems: List = emptyList(), -) = PinnedMessagesListState( +) = PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, timelineItems = timelineItems.toImmutableList(), - eventSink = {} + eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index c7e3dc6e98..5fcf24cdbc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -87,7 +88,7 @@ class TimelinePresenter @AssistedInject constructor( val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } - val timelineItems by timelineItemsFactory.collectItemsAsState() + val timelineItems by timelineItemsFactory.timelineItems.collectAsState(initial = persistentListOf()) val roomInfo by room.roomInfoFlow.collectAsState(initial = null) val syncUpdateFlow = room.syncUpdateFlow.collectAsState() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index d3e21d0042..1a3d1bf566 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -16,9 +16,6 @@ package io.element.android.features.messages.impl.timeline.factories -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory @@ -31,9 +28,11 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -46,7 +45,7 @@ class TimelineItemsFactory @Inject constructor( private val timelineItemGrouper: TimelineItemGrouper, private val timelineItemIndexer: TimelineItemIndexer, ) { - private val timelineItems = MutableStateFlow(persistentListOf()) + private val _timelineItems = MutableSharedFlow>(replay = 1) private val lock = Mutex() private val diffCache = MutableListDiffCache() private val diffCacheUpdater = DiffCacheUpdater( @@ -61,10 +60,7 @@ class TimelineItemsFactory @Inject constructor( } } - @Composable - fun collectItemsAsState(): State> { - return timelineItems.collectAsState() - } + val timelineItems: Flow> = _timelineItems.distinctUntilChanged() suspend fun replaceWith( timelineItems: List, @@ -102,7 +98,7 @@ class TimelineItemsFactory @Inject constructor( } val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() timelineItemIndexer.process(result) - this.timelineItems.emit(result) + this._timelineItems.emit(result) } private suspend fun buildAndCacheItem(