Timeline permalink : continue to iterate (try a strategy to avoid forward insertion to "auto-scroll")

This commit is contained in:
ganfra 2024-04-23 13:30:55 +02:00
parent 6808edda86
commit f56ac869ff
25 changed files with 599 additions and 218 deletions

View file

@ -30,6 +30,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment 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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -116,6 +117,7 @@ class MessagesNode @AssistedInject constructor(
private fun onLinkClicked( private fun onLinkClicked(
context: Context, context: Context,
url: String, url: String,
eventSink: (TimelineEvents) -> Unit,
) { ) {
when (val permalink = permalinkParser.parse(url)) { when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {
@ -124,7 +126,7 @@ class MessagesNode @AssistedInject constructor(
callback?.onUserDataClicked(permalink.userId) callback?.onUserDataClicked(permalink.userId)
} }
is PermalinkData.RoomLink -> { is PermalinkData.RoomLink -> {
handleRoomLinkClicked(permalink) handleRoomLinkClicked(permalink, eventSink)
} }
is PermalinkData.FallbackLink, is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> { 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 (room.matches(roomLink.roomIdOrAlias)) {
if (roomLink.eventId != null) { val eventId = roomLink.eventId
// TODO Handle navigation to the Event if (eventId != null) {
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}") eventSink(TimelineEvents.FocusOnEvent(eventId))
} else { } else {
// Click on the same room, ignore // Click on the same room, ignore
context.toast("Already viewing this room!") context.toast("Already viewing this room!")
@ -189,7 +191,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked, onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments, onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked, onUserDataClicked = this::onUserDataClicked,
onLinkClicked = { onLinkClicked(context, it) }, onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
onSendLocationClicked = this::onSendLocationClicked, onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked, onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked, onJoinCallClicked = this::onJoinCallClicked,

View file

@ -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<Timeline>>(Optional.empty())
@OptIn(ExperimentalCoroutinesApi::class)
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
return currentTimelineFlow().flatMapLatest { it.timelineItems }
}
fun isLive(): Flow<Boolean> {
return detachedTimeline.map { !it.isPresent }
}
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
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<Boolean> {
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<TimelineItem>,
lastReadReceiptId: MutableState<EventId?>,
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<TimelineItem>): EventId? {
for (i in index until items.count()) {
val item = items[i]
if (item is TimelineItem.Event) {
return item.eventId
}
}
return null
}
}

View file

@ -17,17 +17,21 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface TimelineEvents { sealed interface TimelineEvents {
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class OnScrollFinished(val firstIndex: Int) : 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. * Events coming from a timeline item.
*/ */
sealed interface EventFromTimelineItem : TimelineEvents 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. * Events coming from a poll item.

View file

@ -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<EventId, Int>()
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<TimelineItem>) {
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
}
}

View file

@ -56,6 +56,7 @@ import kotlinx.coroutines.withContext
class TimelinePresenter @AssistedInject constructor( class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemIndexer: TimelineItemIndexer,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
@ -64,14 +65,13 @@ class TimelinePresenter @AssistedInject constructor(
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter fun create(navigator: MessagesNavigator): TimelinePresenter
} }
private val timeline = room.liveTimeline
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
@ -79,34 +79,42 @@ class TimelinePresenter @AssistedInject constructor(
mutableStateOf(null) mutableStateOf(null)
} }
val focusedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val focusRequestState: MutableState<FocusRequestState> = remember {
mutableStateOf(FocusRequestState.None)
}
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.backPaginationStatus.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value) val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newItemState = remember { mutableStateOf(NewEventState.None) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val isLive by timelineController.isLive().collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
is TimelineEvents.LoadMore -> { is TimelineEvents.LoadMore -> {
if(event.backwards) { localScope.launch {
localScope.paginateBackwards() timelineController.paginate(direction = event.direction)
}else{
//TODO implement pagination forward
} }
} }
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> { is TimelineEvents.OnScrollFinished -> {
if (isLive) {
if (event.firstIndex == 0) { if (event.firstIndex == 0) {
newItemState.value = NewEventState.None newEventState.value = NewEventState.None
} }
appScope.sendReadReceiptIfNeeded( appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
@ -114,6 +122,9 @@ class TimelinePresenter @AssistedInject constructor(
lastReadReceiptId = lastReadReceiptId, lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE, readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
) )
} else {
newEventState.value = NewEventState.None
}
} }
is TimelineEvents.PollAnswerSelected -> appScope.launch { is TimelineEvents.PollAnswerSelected -> appScope.launch {
sendPollResponseAction.execute( sendPollResponseAction.execute(
@ -126,27 +137,54 @@ class TimelinePresenter @AssistedInject constructor(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
) )
} }
is TimelineEvents.PollEditClicked -> is TimelineEvents.PollEditClicked -> {
navigator.onEditPollClicked(event.pollStartId) 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) { LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState) computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState -> combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith( timelineItemsFactory.replaceWith(
timelineItems = items, timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty() roomMembers = membersState.roomMembers().orEmpty()
) )
items items
}
.onEach { timelineItems ->
if (timelineItems.isEmpty()) {
paginateBackwards()
}
} }
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.launchIn(this) .launchIn(this)
@ -165,10 +203,12 @@ class TimelinePresenter @AssistedInject constructor(
return TimelineState( return TimelineState(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
backPaginationStatus = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value, newEventState = newEventState.value,
isLive = isLive,
focusedEventId = focusedEventId.value,
focusRequestState = focusRequestState.value,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -194,6 +234,7 @@ class TimelinePresenter @AssistedInject constructor(
newMostRecentItem is TimelineItem.Event && newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewEvent) { if (hasNewEvent) {
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
// Scroll to bottom if the new event is from me, even if sent from another device // 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) val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && eventId != lastReadReceiptId.value) { if (eventId != null && eventId != lastReadReceiptId.value) {
lastReadReceiptId.value = eventId 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 return null
} }
private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards()
}
} }

View file

@ -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.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
@ -29,11 +28,20 @@ data class TimelineState(
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
val renderReadReceipts: Boolean, val renderReadReceipts: Boolean,
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val backPaginationStatus: Timeline.PaginationStatus,
val newEventState: NewEventState, 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 @Immutable
data class TimelineRoomInfo( data class TimelineRoomInfo(
val isDm: Boolean, val isDm: Boolean,

View file

@ -46,17 +46,18 @@ import kotlin.random.Random
fun aTimelineState( fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(), timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
paginationState: Timeline.PaginationStatus = aPaginationStatus(),
renderReadReceipts: Boolean = false, renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
eventSink: (TimelineEvents) -> Unit = {}, eventSink: (TimelineEvents) -> Unit = {},
) = TimelineState( ) = TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
backPaginationStatus = paginationState,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
highlightedEventId = null, highlightedEventId = null,
newEventState = NewEventState.None, newEventState = NewEventState.None,
isLive = true,
focusedEventId = null,
focusRequestState = FocusRequestState.None,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -19,6 +19,7 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider 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.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton 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.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.abs
@Composable @Composable
fun TimelineView( fun TimelineView(
@ -92,6 +95,10 @@ fun TimelineView(
forceJumpToBottomVisibility: Boolean = false forceJumpToBottomVisibility: Boolean = false
) { ) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvents.ClearFocusRequestState)
}
fun onScrollFinishedAt(firstVisibleIndex: Int) { fun onScrollFinishedAt(firstVisibleIndex: Int) {
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) 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 // 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
@ -118,9 +129,12 @@ fun TimelineView(
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
item { /*
item(key = UUID.randomUUID()) {
TypingNotificationView(state = typingNotificationState) TypingNotificationView(state = typingNotificationState)
} }
*/
items( items(
items = state.timelineItems, items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() }, contentType = { timelineItem -> timelineItem.contentType() },
@ -149,30 +163,68 @@ fun TimelineView(
} }
} }
FocusRequestStateView(
focusRequestState = state.focusRequestState,
onClearFocusRequestState = ::clearFocusRequestState
)
TimelineScrollHelper( TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(), isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState, lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState, 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 @Composable
private fun BoxScope.TimelineScrollHelper( private fun BoxScope.TimelineScrollHelper(
isTimelineEmpty: Boolean, isTimelineEmpty: Boolean,
lazyListState: LazyListState, lazyListState: LazyListState,
newEventState: NewEventState, newEventState: NewEventState,
isLive: Boolean,
forceJumpToBottomVisibility: Boolean, forceJumpToBottomVisibility: Boolean,
focusRequestState: FocusRequestState,
onClearFocusRequestState: () -> Unit,
onScrollFinishedAt: (Int) -> Unit, onScrollFinishedAt: (Int) -> Unit,
onJumpToLive: () -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val canAutoScroll by remember { val canAutoScroll by remember {
derivedStateOf { 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) { LaunchedEffect(canAutoScroll, newEventState) {
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe) Timber.d("TimelineScrollHelper - canAutoScroll: $canAutoScroll, newEventState: $newEventState")
if (shouldAutoScroll) { val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldScrollToBottom) {
scrollToBottom() scrollToBottom()
} }
} }
@ -203,11 +275,11 @@ private fun BoxScope.TimelineScrollHelper(
JumpToBottomButton( JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered // 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 modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = ::scrollToBottom, onClick = { jumpToBottom() },
) )
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel 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.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.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
@ -46,10 +48,11 @@ fun TimelineItemVirtualRow(
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier) is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name, modifier = modifier) TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name, modifier = modifier)
is TimelineItemLoadingIndicatorModel -> { is TimelineItemLoadingIndicatorModel -> {
TimelineLoadingMoreIndicator() TimelineLoadingMoreIndicator(modifier)
LaunchedEffect(key1 = virtual.model.timestamp) { LaunchedEffect(key1 = virtual.model.timestamp) {
eventSink(TimelineEvents.LoadMore(virtual.model.backwards)) eventSink(TimelineEvents.LoadMore(virtual.model.direction))
} }
} }
TimelineItemInvisibleIndicatorModel -> Spacer(modifier = modifier)
} }
} }

View file

@ -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.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -26,19 +27,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 @Composable
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) { internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
Box( Box(
modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .padding(2.dp),
.padding(8.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator( LinearProgressIndicator(modifier = Modifier
strokeWidth = 2.dp, .height(1.dp)
.fillMaxWidth()
) )
} }
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState 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.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory 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.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -43,9 +45,9 @@ class TimelineItemsFactory @Inject constructor(
private val eventItemFactory: TimelineItemEventFactory, private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory, private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper, private val timelineItemGrouper: TimelineItemGrouper,
private val timelineItemIndexer: TimelineItemIndexer,
) { ) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>()) private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val lock = Mutex() private val lock = Mutex()
private val diffCache = MutableListDiffCache<TimelineItem>() private val diffCache = MutableListDiffCache<TimelineItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>( private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
@ -60,6 +62,10 @@ class TimelineItemsFactory @Inject constructor(
} }
} }
fun items(): StateFlow<ImmutableList<TimelineItem>> {
return timelineItems
}
@Composable @Composable
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> { fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
return timelineItems.collectAsState() return timelineItems.collectAsState()
@ -80,6 +86,7 @@ class TimelineItemsFactory @Inject constructor(
roomMembers: List<RoomMember>, roomMembers: List<RoomMember>,
) { ) {
val newTimelineItemStates = ArrayList<TimelineItem>() val newTimelineItemStates = ArrayList<TimelineItem>()
val newTimelineById = mutableMapOf<String, TimelineItem>()
for (index in diffCache.indices().reversed()) { for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index) val cacheItem = diffCache.get(index)
if (cacheItem == null) { if (cacheItem == null) {
@ -96,10 +103,12 @@ class TimelineItemsFactory @Inject constructor(
} else { } else {
cacheItem cacheItem
} }
newTimelineById[updatedItem.identifier()] = updatedItem
newTimelineItemStates.add(updatedItem) newTimelineItemStates.add(updatedItem)
} }
} }
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
timelineItemIndexer.process(result)
this.timelineItems.emit(result) this.timelineItems.emit(result)
} }
@ -108,13 +117,13 @@ class TimelineItemsFactory @Inject constructor(
index: Int, index: Int,
roomMembers: List<RoomMember>, roomMembers: List<RoomMember>,
): TimelineItem? { ): TimelineItem? {
val timelineItemState = val timelineItem =
when (val currentTimelineItem = timelineItems[index]) { when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers) is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null MatrixTimelineItem.Other -> null
} }
diffCache[index] = timelineItemState diffCache[index] = timelineItem
return timelineItemState return timelineItem
} }
} }

View file

@ -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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel 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.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel 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.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel( is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
backwards = inner.backwards, direction = inner.direction,
timestamp = inner.timestamp timestamp = inner.timestamp
) )
VirtualTimelineItem.LatestKnownEventIndicator -> TimelineItemInvisibleIndicatorModel
} }
} }
} }

View file

@ -14,7 +14,8 @@
* limitations under the License. * 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"
} }

View file

@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
import io.element.android.libraries.matrix.api.timeline.Timeline
data class TimelineItemLoadingIndicatorModel( data class TimelineItemLoadingIndicatorModel(
val backwards: Boolean, val direction: Timeline.PaginationDirection,
val timestamp: Long, val timestamp: Long,
) : TimelineItemVirtualModel { ) : TimelineItemVirtualModel {
override val type: String = "TimelineItemLoadingIndicatorModel" override val type: String = "TimelineItemLoadingIndicatorModel"

View file

@ -50,7 +50,7 @@ class PollHistoryPresenter @Inject constructor(
override fun present(): PollHistoryState { override fun present(): PollHistoryState {
// TODO use room.rememberPollHistory() when working properly? // TODO use room.rememberPollHistory() when working properly?
val timeline = room.liveTimeline val timeline = room.liveTimeline
val paginationState by timeline.backPaginationStatus.collectAsState() val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
val pollHistoryItemsFlow = remember { val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items -> timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items) pollHistoryItemFactory.create(items)
@ -96,6 +96,6 @@ class PollHistoryPresenter @Inject constructor(
} }
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch { private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
pollHistory.paginateBackwards() pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
} }
} }

View file

@ -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.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels 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.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.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
@ -98,7 +98,9 @@ interface MatrixRoom : Closeable {
val syncUpdateFlow: StateFlow<Long> val syncUpdateFlow: StateFlow<Long>
val liveTimeline: LiveTimeline val liveTimeline: Timeline
suspend fun timelineFocusedOnEvent(eventId: EventId): Timeline
fun destroy() fun destroy()

View file

@ -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<Boolean>
val forwardPaginationStatus: StateFlow<Timeline.PaginationStatus>
}

View file

@ -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<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api.timeline 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.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -28,7 +29,14 @@ interface Timeline : AutoCloseable {
val canPaginate: Boolean = !isPaginating && hasMoreToLoad val canPaginate: Boolean = !isPaginating && hasMoreToLoad
} }
suspend fun paginateBackwards(): Result<Boolean> enum class PaginationDirection {
val backPaginationStatus: StateFlow<PaginationStatus> BACKWARDS,
FORWARDS
}
val membershipChangeEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean>
fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus>
val timelineItems: Flow<List<MatrixTimelineItem>> val timelineItems: Flow<List<MatrixTimelineItem>>
} }

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.matrix.api.timeline.item.virtual package io.element.android.libraries.matrix.api.timeline.item.virtual
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface VirtualTimelineItem { sealed interface VirtualTimelineItem {
data class DayDivider( data class DayDivider(
val timestamp: Long val timestamp: Long
@ -27,8 +29,10 @@ sealed interface VirtualTimelineItem {
data object RoomBeginning: VirtualTimelineItem data object RoomBeginning: VirtualTimelineItem
data object LatestKnownEventIndicator: VirtualTimelineItem
data class LoadingIndicator( data class LoadingIndicator(
val backwards: Boolean, val direction: Timeline.PaginationDirection,
val timestamp: Long, val timestamp: Long,
): VirtualTimelineItem ): VirtualTimelineItem
} }

View file

@ -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.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange 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.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.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings 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.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper 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.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.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@ -160,7 +160,7 @@ class RustMatrixRoom(
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown) private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
override val liveTimeline = createLiveTimeline(innerTimeline){ override val liveTimeline = createTimeline(innerTimeline, isLive = true){
_syncUpdateFlow.value = systemClock.epochMillis() _syncUpdateFlow.value = systemClock.epochMillis()
} }
@ -183,6 +183,12 @@ class RustMatrixRoom(
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId) 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() { override fun destroy() {
roomCoroutineScope.cancel() roomCoroutineScope.cancel()
liveTimeline.close() liveTimeline.close()
@ -745,12 +751,14 @@ class RustMatrixRoom(
} }
} }
private fun createLiveTimeline( private fun createTimeline(
timeline: InnerTimeline, timeline: InnerTimeline,
isLive: Boolean = true,
onNewSyncedEvent: () -> Unit = {}, onNewSyncedEvent: () -> Unit = {},
): LiveTimeline { ): Timeline {
return RustLiveTimeline( return RustTimeline(
isKeyBackupEnabled = isKeyBackupEnabled, isKeyBackupEnabled = isKeyBackupEnabled,
isLive = isLive,
matrixRoom = this, matrixRoom = this,
systemClock = systemClock, systemClock = systemClock,
roomCoroutineScope = roomCoroutineScope, roomCoroutineScope = roomCoroutineScope,

View file

@ -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

View file

@ -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.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline 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.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper 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.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.LoadingIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor 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.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -58,8 +59,9 @@ import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
private const val INITIAL_MAX_SIZE = 50 private const val INITIAL_MAX_SIZE = 50
class RustLiveTimeline( class RustTimeline(
private val inner: InnerTimeline, private val inner: InnerTimeline,
private val isLive: Boolean,
private val systemClock: SystemClock, private val systemClock: SystemClock,
private val roomCoroutineScope: CoroutineScope, private val roomCoroutineScope: CoroutineScope,
private val isKeyBackupEnabled: Boolean, private val isKeyBackupEnabled: Boolean,
@ -68,7 +70,7 @@ class RustLiveTimeline(
private val lastLoginTimestamp: Date?, private val lastLoginTimestamp: Date?,
private val fetchDetailsForEvent: suspend (EventId) -> Result<Unit>, private val fetchDetailsForEvent: suspend (EventId) -> Result<Unit>,
private val onNewSyncedEvent: () -> Unit, private val onNewSyncedEvent: () -> Unit,
) : LiveTimeline { ) : Timeline {
private val initLatch = CompletableDeferred<Unit>() private val initLatch = CompletableDeferred<Unit>()
private val isInit = AtomicBoolean(false) private val isInit = AtomicBoolean(false)
@ -85,6 +87,7 @@ class RustLiveTimeline(
private val roomBeginningPostProcessor = RoomBeginningPostProcessor() private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
private val invisibleIndicatorPostProcessor = InvisibleIndicatorPostProcessor(isLive)
private val timelineItemFactory = MatrixTimelineItemMapper( private val timelineItemFactory = MatrixTimelineItemMapper(
fetchDetailsForEvent = fetchDetailsForEvent, fetchDetailsForEvent = fetchDetailsForEvent,
@ -127,35 +130,59 @@ class RustLiveTimeline(
} }
} }
override suspend fun paginateBackwards(): Result<Boolean> { override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
initLatch.await() initLatch.await()
return runCatching { return runCatching {
if (!canBackPaginate()) throw TimelineException.CannotPaginate if (!canPaginate(direction)) throw TimelineException.CannotPaginate
inner.paginateBackwards() when (direction) {
Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(50u)
Timeline.PaginationDirection.FORWARDS -> inner.paginateForwards(50u)
}
}.onFailure { error -> }.onFailure { error ->
if (error is TimelineException.CannotPaginate) { 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 { } else {
Timber.e(error, "Error paginating backwards on room ${matrixRoom.roomId}") Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
} }
}.onSuccess { }.onSuccess {
Timber.v("Success back paginating for room ${matrixRoom.roomId}") Timber.v("Success paginating $direction for room ${matrixRoom.roomId}")
} }
} }
private fun canBackPaginate(): Boolean { private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
return isInit.get() && backPaginationStatus.value.canPaginate if (!isInit.get()) return false
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
}
} }
override val backPaginationStatus: StateFlow<Timeline.PaginationStatus> = inner override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
}
}
private val backPaginationStatus: StateFlow<Timeline.PaginationStatus> = inner
.backPaginationStatusFlow() .backPaginationStatusFlow()
.map() .map()
.stateIn(roomCoroutineScope, SharingStarted.Eagerly, Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)) .stateIn(roomCoroutineScope, SharingStarted.Eagerly, Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
private val forwardPaginationStatus: StateFlow<Timeline.PaginationStatus> =
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<List<MatrixTimelineItem>> = combine( override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
_timelineItems, _timelineItems,
backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged() backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
) { timelineItems, hasMoreToLoadBackward -> forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward ->
timelineItems timelineItems
.let { items -> encryptedHistoryPostProcessor.process(items) } .let { items -> encryptedHistoryPostProcessor.process(items) }
.let { items -> .let { items ->
@ -164,7 +191,8 @@ class RustLiveTimeline(
isDm = matrixRoom.isDm, isDm = matrixRoom.isDm,
hasMoreToLoadBackwards = hasMoreToLoadBackward hasMoreToLoadBackwards = hasMoreToLoadBackward
) )
}.let {items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward)} }.let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) }
.let { items -> invisibleIndicatorPostProcessor.process(items) }
} }

View file

@ -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<String> = HashSet()
fun process(
items: List<MatrixTimelineItem>,
): List<MatrixTimelineItem> {
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<MatrixTimelineItem>.latestEventIdentifier(): String? {
return findLast {
when (it) {
is MatrixTimelineItem.Event -> true
else -> false
}
}?.let {
(it as MatrixTimelineItem.Event).uniqueId
}
}
private fun List<MatrixTimelineItem>.indexOf(identifier: String): Int {
return indexOfLast {
when (it) {
is MatrixTimelineItem.Event -> {
it.uniqueId == identifier
}
else -> false
}
}
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.timeline.postprocessor 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.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.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -24,21 +25,34 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
fun process( fun process(
items: List<MatrixTimelineItem>, items: List<MatrixTimelineItem>,
hasMoreToLoadBackwards: Boolean, hasMoreToLoadBackward: Boolean,
hasMoreToLoadForward: Boolean,
): List<MatrixTimelineItem> { ): List<MatrixTimelineItem> {
return if (hasMoreToLoadBackwards && !items.hasEncryptionHistoryBanner()){ val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner()
listOf( val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
MatrixTimelineItem.Virtual( val currentTimestamp = systemClock.epochMillis()
return buildList {
if (shouldAddBackwardLoadingIndicator) {
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
uniqueId = "BackwardLoadingIndicator", uniqueId = "BackwardLoadingIndicator",
virtual = VirtualTimelineItem.LoadingIndicator( virtual = VirtualTimelineItem.LoadingIndicator(
backwards = true, direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = systemClock.epochMillis() timestamp = currentTimestamp
) )
) )
) + items add(backwardLoadingIndicator)
}else { }
items addAll(items)
if (shouldAddForwardLoadingIndicator) {
val forwardLoadingIndicator = MatrixTimelineItem.Virtual(
uniqueId = "ForwardLoadingIndicator",
virtual = VirtualTimelineItem.LoadingIndicator(
direction = Timeline.PaginationDirection.FORWARDS,
timestamp = currentTimestamp
)
)
add(forwardLoadingIndicator)
}
} }
} }
} }