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 ff92551472
commit 0d7cffe400
25 changed files with 599 additions and 218 deletions

View file

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

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

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(
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<TimelineState> {
@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<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val focusRequestState: MutableState<FocusRequestState> = remember {
mutableStateOf(FocusRequestState.None)
}
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(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<String?>(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()
}
}

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.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,

View file

@ -46,17 +46,18 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = 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,
)

View file

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

View file

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

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.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()
)
}
}

View file

@ -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<TimelineItem>())
private val lock = Mutex()
private val diffCache = MutableListDiffCache<TimelineItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
@ -60,6 +62,10 @@ class TimelineItemsFactory @Inject constructor(
}
}
fun items(): StateFlow<ImmutableList<TimelineItem>> {
return timelineItems
}
@Composable
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
return timelineItems.collectAsState()
@ -80,6 +86,7 @@ class TimelineItemsFactory @Inject constructor(
roomMembers: List<RoomMember>,
) {
val newTimelineItemStates = ArrayList<TimelineItem>()
val newTimelineById = mutableMapOf<String, TimelineItem>()
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<RoomMember>,
): 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
}
}

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.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
}
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.model.virtual
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
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"

View file

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