diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 88472956c0..70e077eb04 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -30,6 +30,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -116,6 +117,7 @@ class MessagesNode @AssistedInject constructor( private fun onLinkClicked( context: Context, url: String, + eventSink: (TimelineEvents) -> Unit, ) { when (val permalink = permalinkParser.parse(url)) { is PermalinkData.UserLink -> { @@ -124,7 +126,7 @@ class MessagesNode @AssistedInject constructor( callback?.onUserDataClicked(permalink.userId) } is PermalinkData.RoomLink -> { - handleRoomLinkClicked(permalink) + handleRoomLinkClicked(permalink, eventSink) } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { @@ -133,11 +135,11 @@ class MessagesNode @AssistedInject constructor( } } - private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) { + private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { if (room.matches(roomLink.roomIdOrAlias)) { - if (roomLink.eventId != null) { - // TODO Handle navigation to the Event - context.toast("TODO Handle navigation to the Event ${roomLink.eventId}") + val eventId = roomLink.eventId + if (eventId != null) { + eventSink(TimelineEvents.FocusOnEvent(eventId)) } else { // Click on the same room, ignore context.toast("Already viewing this room!") @@ -189,7 +191,7 @@ class MessagesNode @AssistedInject constructor( onEventClicked = this::onEventClicked, onPreviewAttachments = this::onPreviewAttachments, onUserDataClicked = this::onUserDataClicked, - onLinkClicked = { onLinkClicked(context, it) }, + onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) }, onSendLocationClicked = this::onSendLocationClicked, onCreatePollClicked = this::onCreatePollClicked, onJoinCallClicked = this::onJoinCallClicked, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt new file mode 100644 index 0000000000..e4ae628263 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -0,0 +1,135 @@ +/* + * 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.messages.impl.timeline + +import androidx.compose.runtime.MutableState +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.util.Optional +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +@SingleIn(RoomScope::class) +class TimelineController @Inject constructor( + private val room: MatrixRoom, +) { + + private val liveTimeline = MutableStateFlow(room.liveTimeline) + private val detachedTimeline = MutableStateFlow>(Optional.empty()) + + @OptIn(ExperimentalCoroutinesApi::class) + fun timelineItems(): Flow> { + return currentTimelineFlow().flatMapLatest { it.timelineItems } + } + + fun isLive(): Flow { + return detachedTimeline.map { !it.isPresent } + } + + suspend fun focusOnEvent(eventId: EventId): Result { + return try { + val newDetachedTimeline = room.timelineFocusedOnEvent(eventId) + detachedTimeline.getAndUpdate { current -> + if (current.isPresent) { + current.get().close() + } + Optional.of(newDetachedTimeline) + } + Result.success(Unit) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (exception: Exception) { + Result.failure(exception) + } + } + + /** + * Makes sure the controller is focused on the live timeline. + * This does close the detached timeline if any. + */ + fun focusOnLive() { + detachedTimeline.getAndUpdate { + when { + it.isPresent -> { + it.get().close() + Optional.empty() + } + else -> Optional.empty() + } + } + } + + suspend fun paginate(direction: Timeline.PaginationDirection): Result { + return currentTimelineFlow().first().paginate(direction) + } + + private fun currentTimelineFlow() = combine(liveTimeline, detachedTimeline) { live, detached -> + when { + detached.isPresent -> detached.get() + else -> live + } + } + + suspend fun sendReadReceiptIfNeeded( + firstVisibleIndex: Int, + timelineItems: ImmutableList, + lastReadReceiptId: MutableState, + readReceiptType: ReceiptType, + ) { + // If we are at the bottom of timeline, we mark the room as read. + if (firstVisibleIndex == 0) { + room.markAsRead(receiptType = readReceiptType) + } else { + // Get last valid EventId seen by the user, as the first index might refer to a Virtual item + val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) + if (eventId != null && eventId != lastReadReceiptId.value) { + lastReadReceiptId.value = eventId + currentTimelineFlow() + .filterIsInstance(Timeline::class) + .first() + .sendReadReceipt(eventId = eventId, receiptType = readReceiptType) + } + } + } + + private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList): EventId? { + for (i in index until items.count()) { + val item = items[i] + if (item is TimelineItem.Event) { + return item.eventId + } + } + return null + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index 62ec074bea..834b80489b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -17,17 +17,21 @@ package io.element.android.features.messages.impl.timeline import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline sealed interface TimelineEvents { data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class OnScrollFinished(val firstIndex: Int) : TimelineEvents + data class FocusOnEvent(val eventId: EventId) : TimelineEvents + data object ClearFocusRequestState: TimelineEvents + data object JumpToLive : TimelineEvents /** * Events coming from a timeline item. */ sealed interface EventFromTimelineItem : TimelineEvents - data class LoadMore(val backwards: Boolean) : EventFromTimelineItem + data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem /** * Events coming from a poll item. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt new file mode 100644 index 0000000000..4507dea99d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt @@ -0,0 +1,65 @@ +/* + * 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.messages.impl.timeline + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import timber.log.Timber +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class TimelineItemIndexer @Inject constructor() { + + private val timelineEventsIndexes = mutableMapOf() + + fun isKnown(eventId: EventId): Boolean { + return timelineEventsIndexes.containsKey(eventId).also { + Timber.d("$eventId isKnown = $it") + } + } + + fun indexOf(eventId: EventId): Int { + return (timelineEventsIndexes[eventId] ?: -1).also { + Timber.d("indexOf $eventId= $it") + } + } + + fun process(timelineItems: List) { + Timber.d("process ${timelineItems.size} items") + timelineEventsIndexes.clear() + timelineItems.forEachIndexed { index, timelineItem -> + when (timelineItem) { + is TimelineItem.Event -> { + processEvent(timelineItem, index) + } + is TimelineItem.GroupedEvents -> { + timelineItem.events.forEach { event -> + processEvent(event, index) + } + } + else -> Unit + } + } + } + + private fun processEvent(event: TimelineItem.Event, index: Int) { + if (event.eventId == null) return + timelineEventsIndexes[event.eventId] = index + } +} 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 eede76dae9..8b8f73eb1a 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 @@ -56,6 +56,7 @@ import kotlinx.coroutines.withContext class TimelinePresenter @AssistedInject constructor( private val timelineItemsFactory: TimelineItemsFactory, + private val timelineItemIndexer: TimelineItemIndexer, private val room: MatrixRoom, private val dispatchers: CoroutineDispatchers, private val appScope: CoroutineScope, @@ -64,14 +65,13 @@ class TimelinePresenter @AssistedInject constructor( private val sendPollResponseAction: SendPollResponseAction, private val endPollAction: EndPollAction, private val sessionPreferencesStore: SessionPreferencesStore, + private val timelineController: TimelineController, ) : Presenter { @AssistedFactory interface Factory { fun create(navigator: MessagesNavigator): TimelinePresenter } - private val timeline = room.liveTimeline - @Composable override fun present(): TimelineState { val localScope = rememberCoroutineScope() @@ -79,41 +79,52 @@ class TimelinePresenter @AssistedInject constructor( mutableStateOf(null) } + val focusedEventId: MutableState = rememberSaveable { + mutableStateOf(null) + } + val focusRequestState: MutableState = remember { + mutableStateOf(FocusRequestState.None) + } + val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } val timelineItems by timelineItemsFactory.collectItemsAsState() - val paginationState by timeline.backPaginationStatus.collectAsState() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value) val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } - val newItemState = remember { mutableStateOf(NewEventState.None) } + + val newEventState = remember { mutableStateOf(NewEventState.None) } val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) + val isLive by timelineController.isLive().collectAsState(initial = true) fun handleEvents(event: TimelineEvents) { when (event) { is TimelineEvents.LoadMore -> { - if(event.backwards) { - localScope.paginateBackwards() - }else{ - //TODO implement pagination forward + localScope.launch { + timelineController.paginate(direction = event.direction) } } is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.OnScrollFinished -> { - if (event.firstIndex == 0) { - newItemState.value = NewEventState.None + if (isLive) { + if (event.firstIndex == 0) { + newEventState.value = NewEventState.None + } + appScope.sendReadReceiptIfNeeded( + firstVisibleIndex = event.firstIndex, + timelineItems = timelineItems, + lastReadReceiptId = lastReadReceiptId, + readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE, + ) + } else { + newEventState.value = NewEventState.None } - appScope.sendReadReceiptIfNeeded( - firstVisibleIndex = event.firstIndex, - timelineItems = timelineItems, - lastReadReceiptId = lastReadReceiptId, - readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE, - ) } is TimelineEvents.PollAnswerSelected -> appScope.launch { sendPollResponseAction.execute( @@ -126,28 +137,55 @@ class TimelinePresenter @AssistedInject constructor( pollStartId = event.pollStartId, ) } - is TimelineEvents.PollEditClicked -> + is TimelineEvents.PollEditClicked -> { navigator.onEditPollClicked(event.pollStartId) + } + is TimelineEvents.FocusOnEvent -> localScope.launch { + focusedEventId.value = event.eventId + if (timelineItemIndexer.isKnown(event.eventId)) { + val index = timelineItemIndexer.indexOf(event.eventId) + focusRequestState.value = FocusRequestState.Cached(index) + } else { + focusRequestState.value = FocusRequestState.Fetching + timelineController.focusOnEvent(event.eventId) + .fold( + onSuccess = { + focusRequestState.value = FocusRequestState.None + }, + onFailure = { + focusRequestState.value = FocusRequestState.Failure(it) + } + ) + } + } + is TimelineEvents.ClearFocusRequestState -> { + focusRequestState.value = FocusRequestState.None + } + is TimelineEvents.JumpToLive -> { + localScope.launch { + timelineController.focusOnLive() + } + } } } + // Makes sure to get back to live when there is nothing more to load forwards + LaunchedEffect(isLive) { + + } + LaunchedEffect(timelineItems.size) { - computeNewItemState(timelineItems, prevMostRecentItemId, newItemState) + computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } LaunchedEffect(Unit) { - combine(timeline.timelineItems, room.membersStateFlow) { items, membersState -> + combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> timelineItemsFactory.replaceWith( timelineItems = items, roomMembers = membersState.roomMembers().orEmpty() ) items } - .onEach { timelineItems -> - if (timelineItems.isEmpty()) { - paginateBackwards() - } - } .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) .launchIn(this) } @@ -165,10 +203,12 @@ class TimelinePresenter @AssistedInject constructor( return TimelineState( timelineRoomInfo = timelineRoomInfo, highlightedEventId = highlightedEventId.value, - backPaginationStatus = paginationState, timelineItems = timelineItems, renderReadReceipts = renderReadReceipts, - newEventState = newItemState.value, + newEventState = newEventState.value, + isLive = isLive, + focusedEventId = focusedEventId.value, + focusRequestState = focusRequestState.value, eventSink = { handleEvents(it) } ) } @@ -194,6 +234,7 @@ class TimelinePresenter @AssistedInject constructor( newMostRecentItem is TimelineItem.Event && newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && newMostRecentItemId != prevMostRecentItemIdValue + if (hasNewEvent) { val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event // Scroll to bottom if the new event is from me, even if sent from another device @@ -221,7 +262,7 @@ class TimelinePresenter @AssistedInject constructor( val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) if (eventId != null && eventId != lastReadReceiptId.value) { lastReadReceiptId.value = eventId - timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType) + //timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType) } } } @@ -235,8 +276,4 @@ class TimelinePresenter @AssistedInject constructor( } return null } - - private fun CoroutineScope.paginateBackwards() = launch { - timeline.paginateBackwards() - } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 650b1874b8..c7a077a721 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.collections.immutable.ImmutableList @Immutable @@ -29,11 +28,20 @@ data class TimelineState( val timelineRoomInfo: TimelineRoomInfo, val renderReadReceipts: Boolean, val highlightedEventId: EventId?, - val backPaginationStatus: Timeline.PaginationStatus, val newEventState: NewEventState, - val eventSink: (TimelineEvents) -> Unit + val isLive: Boolean, + val focusedEventId : EventId?, + val focusRequestState: FocusRequestState, + val eventSink: (TimelineEvents) -> Unit, ) +sealed interface FocusRequestState { + data object None : FocusRequestState + data class Cached(val index: Int): FocusRequestState + data object Fetching : FocusRequestState + data class Failure(val throwable: Throwable) : FocusRequestState +} + @Immutable data class TimelineRoomInfo( val isDm: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 19513f08fe..201c12837a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -46,17 +46,18 @@ import kotlin.random.Random fun aTimelineState( timelineItems: ImmutableList = persistentListOf(), - paginationState: Timeline.PaginationStatus = aPaginationStatus(), renderReadReceipts: Boolean = false, timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), eventSink: (TimelineEvents) -> Unit = {}, ) = TimelineState( timelineItems = timelineItems, timelineRoomInfo = timelineRoomInfo, - backPaginationStatus = paginationState, renderReadReceipts = renderReadReceipts, highlightedEventId = null, newEventState = NewEventState.None, + isLive = true, + focusedEventId = null, + focusRequestState = FocusRequestState.None, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 1eab2fd2a3..e3dfa248ad 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline import android.view.accessibility.AccessibilityManager +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween @@ -55,7 +56,6 @@ 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.messages.impl.timeline.components.TimelineItemRow -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.NewEventState @@ -63,8 +63,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.typing.TypingNotificationState -import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.aTypingNotificationState +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton @@ -73,6 +74,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.math.abs @Composable fun TimelineView( @@ -92,6 +95,10 @@ fun TimelineView( forceJumpToBottomVisibility: Boolean = false ) { + fun clearFocusRequestState() { + state.eventSink(TimelineEvents.ClearFocusRequestState) + } + fun onScrollFinishedAt(firstVisibleIndex: Int) { state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) } @@ -109,6 +116,10 @@ fun TimelineView( // TODO implement this logic once we have support to 'jump to event X' in sliding sync } + LaunchedEffect(key1 = state.timelineItems) { + Timber.d("TimelineView - timelineItem identifiers: ${state.timelineItems.joinToString(", ") { it.identifier() }}") + } + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { Box(modifier) { @@ -118,9 +129,12 @@ fun TimelineView( reverseLayout = useReverseLayout, contentPadding = PaddingValues(vertical = 8.dp), ) { - item { + /* + item(key = UUID.randomUUID()) { TypingNotificationView(state = typingNotificationState) } + + */ items( items = state.timelineItems, contentType = { timelineItem -> timelineItem.contentType() }, @@ -149,30 +163,68 @@ fun TimelineView( } } + FocusRequestStateView( + focusRequestState = state.focusRequestState, + onClearFocusRequestState = ::clearFocusRequestState + ) + TimelineScrollHelper( isTimelineEmpty = state.timelineItems.isEmpty(), lazyListState = lazyListState, forceJumpToBottomVisibility = forceJumpToBottomVisibility, newEventState = state.newEventState, - onScrollFinishedAt = ::onScrollFinishedAt + isLive = state.isLive, + focusRequestState = state.focusRequestState, + onScrollFinishedAt = ::onScrollFinishedAt, + onClearFocusRequestState = ::clearFocusRequestState, + onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) }, ) } } } +@Composable +private fun FocusRequestStateView( + focusRequestState: FocusRequestState, + onClearFocusRequestState: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = focusRequestState is FocusRequestState.Fetching) { + onClearFocusRequestState() + } + + when (focusRequestState) { + is FocusRequestState.Failure -> { + ErrorDialog( + content = stringResource(id = CommonStrings.common_failed), + onDismiss = onClearFocusRequestState, + modifier = modifier, + ) + } + FocusRequestState.Fetching -> { + ProgressDialog(modifier = modifier) + } + is FocusRequestState.Cached, FocusRequestState.None -> Unit + } +} + @Composable private fun BoxScope.TimelineScrollHelper( isTimelineEmpty: Boolean, lazyListState: LazyListState, newEventState: NewEventState, + isLive: Boolean, forceJumpToBottomVisibility: Boolean, + focusRequestState: FocusRequestState, + onClearFocusRequestState: () -> Unit, onScrollFinishedAt: (Int) -> Unit, + onJumpToLive: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val canAutoScroll by remember { derivedStateOf { - lazyListState.firstVisibleItemIndex < 3 + lazyListState.firstVisibleItemIndex < 3 && isLive } } @@ -186,9 +238,29 @@ private fun BoxScope.TimelineScrollHelper( } } + fun jumpToBottom() { + if (isLive) { + scrollToBottom() + } else { + onJumpToLive() + } + } + + LaunchedEffect(key1 = focusRequestState) { + if (focusRequestState is FocusRequestState.Cached) { + if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) { + lazyListState.animateScrollToItem(focusRequestState.index) + } else { + lazyListState.scrollToItem(focusRequestState.index) + } + onClearFocusRequestState() + } + } + LaunchedEffect(canAutoScroll, newEventState) { - val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe) - if (shouldAutoScroll) { + Timber.d("TimelineScrollHelper - canAutoScroll: $canAutoScroll, newEventState: $newEventState") + val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe) + if (shouldScrollToBottom) { scrollToBottom() } } @@ -203,11 +275,11 @@ private fun BoxScope.TimelineScrollHelper( JumpToBottomButton( // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered - isVisible = !canAutoScroll || forceJumpToBottomVisibility, + isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 12.dp), - onClick = ::scrollToBottom, + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 12.dp), + onClick = { jumpToBottom() }, ) } @@ -233,8 +305,8 @@ private fun JumpToBottomButton( ) { Icon( modifier = Modifier - .size(24.dp) - .rotate(90f), + .size(24.dp) + .rotate(90f), imageVector = CompoundIcons.ArrowRight(), contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt index 9edc7a9ad4..4235c7e065 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.timeline.components +import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.components.virtual.Tim import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemInvisibleIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel @@ -46,10 +48,11 @@ fun TimelineItemVirtualRow( is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier) TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name, modifier = modifier) is TimelineItemLoadingIndicatorModel -> { - TimelineLoadingMoreIndicator() + TimelineLoadingMoreIndicator(modifier) LaunchedEffect(key1 = virtual.model.timestamp) { - eventSink(TimelineEvents.LoadMore(virtual.model.backwards)) + eventSink(TimelineEvents.LoadMore(virtual.model.direction)) } } + TimelineItemInvisibleIndicatorModel -> Spacer(modifier = modifier) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt index 23cc516156..cf5c54f3d4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components.virtual import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable @@ -26,19 +27,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp 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.LinearProgressIndicator @Composable internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) { Box( - modifier + modifier = modifier .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp), + .padding(2.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator( - strokeWidth = 2.dp, + LinearProgressIndicator(modifier = Modifier + .height(1.dp) + .fillMaxWidth() ) } } 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 29ea3abcfc..34049fbaf7 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 @@ -19,6 +19,7 @@ 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 import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory @@ -33,6 +34,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -43,9 +45,9 @@ class TimelineItemsFactory @Inject constructor( private val eventItemFactory: TimelineItemEventFactory, private val virtualItemFactory: TimelineItemVirtualFactory, private val timelineItemGrouper: TimelineItemGrouper, + private val timelineItemIndexer: TimelineItemIndexer, ) { private val timelineItems = MutableStateFlow(persistentListOf()) - private val lock = Mutex() private val diffCache = MutableListDiffCache() private val diffCacheUpdater = DiffCacheUpdater( @@ -60,6 +62,10 @@ class TimelineItemsFactory @Inject constructor( } } + fun items(): StateFlow> { + return timelineItems + } + @Composable fun collectItemsAsState(): State> { return timelineItems.collectAsState() @@ -80,6 +86,7 @@ class TimelineItemsFactory @Inject constructor( roomMembers: List, ) { val newTimelineItemStates = ArrayList() + val newTimelineById = mutableMapOf() for (index in diffCache.indices().reversed()) { val cacheItem = diffCache.get(index) if (cacheItem == null) { @@ -96,10 +103,12 @@ class TimelineItemsFactory @Inject constructor( } else { cacheItem } + newTimelineById[updatedItem.identifier()] = updatedItem newTimelineItemStates.add(updatedItem) } } val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() + timelineItemIndexer.process(result) this.timelineItems.emit(result) } @@ -108,13 +117,13 @@ class TimelineItemsFactory @Inject constructor( index: Int, roomMembers: List, ): TimelineItem? { - val timelineItemState = + val timelineItem = when (val currentTimelineItem = timelineItems[index]) { is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers) is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) MatrixTimelineItem.Other -> null } - diffCache[index] = timelineItemState - return timelineItemState + diffCache[index] = timelineItem + return timelineItem } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt index 2efe98e5b3..e6df9e889d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.virtual import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemInvisibleIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel @@ -45,9 +46,10 @@ class TimelineItemVirtualFactory @Inject constructor( is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel( - backwards = inner.backwards, + direction = inner.direction, timestamp = inner.timestamp ) + VirtualTimelineItem.LatestKnownEventIndicator -> TimelineItemInvisibleIndicatorModel } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustDetachedTimeline.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemInvisibleIndicatorModel.kt similarity index 73% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustDetachedTimeline.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemInvisibleIndicatorModel.kt index 5d45f5d44f..2f4a118746 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustDetachedTimeline.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemInvisibleIndicatorModel.kt @@ -14,7 +14,8 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.impl.timeline +package io.element.android.features.messages.impl.timeline.model.virtual -class RustDetachedTimeline { +data object TimelineItemInvisibleIndicatorModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemInvisibleIndicatorModel" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt index 7007c56e0f..da022d6d59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt @@ -16,8 +16,10 @@ package io.element.android.features.messages.impl.timeline.model.virtual +import io.element.android.libraries.matrix.api.timeline.Timeline + data class TimelineItemLoadingIndicatorModel( - val backwards: Boolean, + val direction: Timeline.PaginationDirection, val timestamp: Long, ) : TimelineItemVirtualModel { override val type: String = "TimelineItemLoadingIndicatorModel" diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 339f02af31..b36359aa25 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -50,7 +50,7 @@ class PollHistoryPresenter @Inject constructor( override fun present(): PollHistoryState { // TODO use room.rememberPollHistory() when working properly? val timeline = room.liveTimeline - val paginationState by timeline.backPaginationStatus.collectAsState() + val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState() val pollHistoryItemsFlow = remember { timeline.timelineItems.map { items -> pollHistoryItemFactory.create(items) @@ -96,6 +96,6 @@ class PollHistoryPresenter @Inject constructor( } private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch { - pollHistory.paginateBackwards() + pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 8e32a51a5c..a2ca077bb8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange -import io.element.android.libraries.matrix.api.timeline.LiveTimeline +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings @@ -98,7 +98,9 @@ interface MatrixRoom : Closeable { val syncUpdateFlow: StateFlow - val liveTimeline: LiveTimeline + val liveTimeline: Timeline + + suspend fun timelineFocusedOnEvent(eventId: EventId): Timeline fun destroy() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/DetachedTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/DetachedTimeline.kt deleted file mode 100644 index d6abdc515f..0000000000 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/DetachedTimeline.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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.libraries.matrix.api.timeline - -import kotlinx.coroutines.flow.StateFlow - -interface DetachedTimeline : Timeline { - suspend fun paginateForwards(): Result - val forwardPaginationStatus: StateFlow -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/LiveTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/LiveTimeline.kt deleted file mode 100644 index 643b690eee..0000000000 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/LiveTimeline.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.libraries.matrix.api.timeline - -import io.element.android.libraries.matrix.api.core.EventId -import kotlinx.coroutines.flow.Flow - -interface LiveTimeline: Timeline { - val membershipChangeEventReceived: Flow - suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index c0f8110e56..05b1549ae1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.api.timeline +import io.element.android.libraries.matrix.api.core.EventId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +29,14 @@ interface Timeline : AutoCloseable { val canPaginate: Boolean = !isPaginating && hasMoreToLoad } - suspend fun paginateBackwards(): Result - val backPaginationStatus: StateFlow + enum class PaginationDirection { + BACKWARDS, + FORWARDS + } + + val membershipChangeEventReceived: Flow + suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result + suspend fun paginate(direction: PaginationDirection): Result + fun paginationStatus(direction: PaginationDirection): StateFlow val timelineItems: Flow> } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt index fbe2a09716..2879225553 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -16,6 +16,8 @@ package io.element.android.libraries.matrix.api.timeline.item.virtual +import io.element.android.libraries.matrix.api.timeline.Timeline + sealed interface VirtualTimelineItem { data class DayDivider( val timestamp: Long @@ -27,8 +29,10 @@ sealed interface VirtualTimelineItem { data object RoomBeginning: VirtualTimelineItem + data object LatestKnownEventIndicator: VirtualTimelineItem + data class LoadingIndicator( - val backwards: Boolean, + val direction: Timeline.PaginationDirection, val timestamp: Long, ): VirtualTimelineItem } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 03f14eef7a..147ca3f2da 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -43,7 +43,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.roomNotificationSettings -import io.element.android.libraries.matrix.api.timeline.LiveTimeline +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings @@ -57,7 +57,7 @@ import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper -import io.element.android.libraries.matrix.impl.timeline.RustLiveTimeline +import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver @@ -160,7 +160,7 @@ class RustMatrixRoom( private val _roomNotificationSettingsStateFlow = MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown) override val roomNotificationSettingsStateFlow: StateFlow = _roomNotificationSettingsStateFlow - override val liveTimeline = createLiveTimeline(innerTimeline){ + override val liveTimeline = createTimeline(innerTimeline, isLive = true){ _syncUpdateFlow.value = systemClock.epochMillis() } @@ -183,6 +183,12 @@ class RustMatrixRoom( override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId) + override suspend fun timelineFocusedOnEvent(eventId: EventId): Timeline { + return innerRoom.timelineFocusedOnEvent(eventId.value, numContextEvents = 50u).let {inner -> + createTimeline(inner, isLive = false){} + } + } + override fun destroy() { roomCoroutineScope.cancel() liveTimeline.close() @@ -745,12 +751,14 @@ class RustMatrixRoom( } } - private fun createLiveTimeline( + private fun createTimeline( timeline: InnerTimeline, + isLive: Boolean = true, onNewSyncedEvent: () -> Unit = {}, - ): LiveTimeline { - return RustLiveTimeline( + ): Timeline { + return RustTimeline( isKeyBackupEnabled = isKeyBackupEnabled, + isLive = isLive, matrixRoom = this, systemClock = systemClock, roomCoroutineScope = roomCoroutineScope, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt deleted file mode 100644 index b6f4284bc8..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2023 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.libraries.matrix.impl.timeline - -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.api.timeline.ReceiptType -import io.element.android.libraries.matrix.api.timeline.TimelineException -import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper -import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper -import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper -import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper -import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor -import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.PaginationOptions -import org.matrix.rustcomponents.sdk.Timeline -import org.matrix.rustcomponents.sdk.TimelineDiff -import org.matrix.rustcomponents.sdk.TimelineItem -import timber.log.Timber -import uniffi.matrix_sdk_ui.EventItemOrigin -import java.util.Date -import java.util.concurrent.atomic.AtomicBoolean - -private const val INITIAL_MAX_SIZE = 50 - diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustLiveTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt similarity index 73% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustLiveTimeline.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 5ff770b053..13a8e40161 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustLiveTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.matrix.impl.timeline import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.timeline.LiveTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline @@ -27,6 +26,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessage import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import io.element.android.libraries.matrix.impl.timeline.postprocessor.InvisibleIndicatorPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -58,8 +59,9 @@ import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline private const val INITIAL_MAX_SIZE = 50 -class RustLiveTimeline( +class RustTimeline( private val inner: InnerTimeline, + private val isLive: Boolean, private val systemClock: SystemClock, private val roomCoroutineScope: CoroutineScope, private val isKeyBackupEnabled: Boolean, @@ -68,7 +70,7 @@ class RustLiveTimeline( private val lastLoginTimestamp: Date?, private val fetchDetailsForEvent: suspend (EventId) -> Result, private val onNewSyncedEvent: () -> Unit, -) : LiveTimeline { +) : Timeline { private val initLatch = CompletableDeferred() private val isInit = AtomicBoolean(false) @@ -85,6 +87,7 @@ class RustLiveTimeline( private val roomBeginningPostProcessor = RoomBeginningPostProcessor() private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) + private val invisibleIndicatorPostProcessor = InvisibleIndicatorPostProcessor(isLive) private val timelineItemFactory = MatrixTimelineItemMapper( fetchDetailsForEvent = fetchDetailsForEvent, @@ -127,35 +130,59 @@ class RustLiveTimeline( } } - override suspend fun paginateBackwards(): Result { + override suspend fun paginate(direction: Timeline.PaginationDirection): Result { initLatch.await() return runCatching { - if (!canBackPaginate()) throw TimelineException.CannotPaginate - inner.paginateBackwards() + if (!canPaginate(direction)) throw TimelineException.CannotPaginate + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(50u) + Timeline.PaginationDirection.FORWARDS -> inner.paginateForwards(50u) + } }.onFailure { error -> if (error is TimelineException.CannotPaginate) { - Timber.d("Can't paginate backwards on room ${matrixRoom.roomId} with backPaginationStatus: ${backPaginationStatus.value}") + Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") } else { - Timber.e(error, "Error paginating backwards on room ${matrixRoom.roomId}") + Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") } }.onSuccess { - Timber.v("Success back paginating for room ${matrixRoom.roomId}") + Timber.v("Success paginating $direction for room ${matrixRoom.roomId}") } } - private fun canBackPaginate(): Boolean { - return isInit.get() && backPaginationStatus.value.canPaginate + private fun canPaginate(direction: Timeline.PaginationDirection): Boolean { + if (!isInit.get()) return false + return when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate + Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate + } } - override val backPaginationStatus: StateFlow = inner + override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow { + return when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus + Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus + } + } + + private val backPaginationStatus: StateFlow = inner .backPaginationStatusFlow() .map() .stateIn(roomCoroutineScope, SharingStarted.Eagerly, Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)) + private val forwardPaginationStatus: StateFlow = + when (isLive) { + true -> MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = false)) + false -> inner + .forwardPaginationStatusFlow() + .map() + .stateIn(roomCoroutineScope, SharingStarted.Eagerly, Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)) + } + override val timelineItems: Flow> = combine( _timelineItems, - backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged() - ) { timelineItems, hasMoreToLoadBackward -> + backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), + forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), + ) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward -> timelineItems .let { items -> encryptedHistoryPostProcessor.process(items) } .let { items -> @@ -164,7 +191,8 @@ class RustLiveTimeline( isDm = matrixRoom.isDm, hasMoreToLoadBackwards = hasMoreToLoadBackward ) - }.let {items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward)} + }.let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) } + .let { items -> invisibleIndicatorPostProcessor.process(items) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/InvisibleIndicatorPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/InvisibleIndicatorPostProcessor.kt new file mode 100644 index 0000000000..7c7f819ed7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/InvisibleIndicatorPostProcessor.kt @@ -0,0 +1,79 @@ +/* + * 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.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +class InvisibleIndicatorPostProcessor( + private val isLive: Boolean, +) { + private val latestEventIdentifiers: MutableSet = HashSet() + + fun process( + items: List, + ): List { + if (isLive) { + return items + } else { + return buildList { + items.forEach { item -> + add(item) + if (item is MatrixTimelineItem.Event) { + if (latestEventIdentifiers.contains(item.uniqueId)) { + add(createLatestKnownEventIndicator(item.uniqueId)) + } + } + } + items.latestEventIdentifier()?.let { latestEventIdentifier -> + if (latestEventIdentifiers.add(latestEventIdentifier)) { + add(createLatestKnownEventIndicator(latestEventIdentifier)) + } + } + } + } + } + + private fun createLatestKnownEventIndicator(identifier: String): MatrixTimelineItem { + return MatrixTimelineItem.Virtual( + uniqueId = "latest_known_event_$identifier", + virtual = VirtualTimelineItem.LatestKnownEventIndicator + ) + } + + private fun List.latestEventIdentifier(): String? { + return findLast { + when (it) { + is MatrixTimelineItem.Event -> true + else -> false + } + }?.let { + (it as MatrixTimelineItem.Event).uniqueId + } + } + + private fun List.indexOf(identifier: String): Int { + return indexOfLast { + when (it) { + is MatrixTimelineItem.Event -> { + it.uniqueId == identifier + } + else -> false + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt index 2d08c8e5b8..c2db96730b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -24,21 +25,34 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) { fun process( items: List, - hasMoreToLoadBackwards: Boolean, + hasMoreToLoadBackward: Boolean, + hasMoreToLoadForward: Boolean, ): List { - return if (hasMoreToLoadBackwards && !items.hasEncryptionHistoryBanner()){ - listOf( - MatrixTimelineItem.Virtual( - uniqueId = "BackwardLoadingIndicator", - virtual = VirtualTimelineItem.LoadingIndicator( - backwards = true, - timestamp = systemClock.epochMillis() - ) + val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner() + val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty() + val currentTimestamp = systemClock.epochMillis() + return buildList { + if (shouldAddBackwardLoadingIndicator) { + val backwardLoadingIndicator = MatrixTimelineItem.Virtual( + uniqueId = "BackwardLoadingIndicator", + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = currentTimestamp + ) ) - ) + items - }else { - items - } + add(backwardLoadingIndicator) + } + addAll(items) + if (shouldAddForwardLoadingIndicator) { + val forwardLoadingIndicator = MatrixTimelineItem.Virtual( + uniqueId = "ForwardLoadingIndicator", + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = currentTimestamp + ) + ) + add(forwardLoadingIndicator) + } + } } - }