Skip to content

Commit 4b124e9

Browse files
authored
Merge pull request #868 from vector-im/feature/fga/better_timeline_scroll
Feature/fga/better timeline scroll
2 parents 9ec0c88 + a74278c commit 4b124e9

File tree

53 files changed

+426
-210
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+426
-210
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
2222
import androidx.compose.runtime.collectAsState
2323
import androidx.compose.runtime.getValue
2424
import androidx.compose.runtime.mutableStateOf
25+
import androidx.compose.runtime.remember
2526
import androidx.compose.runtime.rememberCoroutineScope
2627
import androidx.compose.runtime.saveable.rememberSaveable
27-
import androidx.compose.runtime.setValue
2828
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
2929
import io.element.android.features.messages.impl.timeline.model.TimelineItem
3030
import io.element.android.libraries.architecture.Presenter
31+
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
3132
import io.element.android.libraries.matrix.api.core.EventId
3233
import io.element.android.libraries.matrix.api.room.MatrixRoom
3334
import io.element.android.libraries.matrix.api.room.MessageEventType
35+
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
3436
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
3537
import kotlinx.collections.immutable.ImmutableList
3638
import kotlinx.coroutines.CoroutineScope
3739
import kotlinx.coroutines.flow.launchIn
3840
import kotlinx.coroutines.flow.onEach
3941
import kotlinx.coroutines.launch
42+
import kotlinx.coroutines.withContext
4043
import javax.inject.Inject
4144

4245
private const val backPaginationEventLimit = 20
@@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
4548
class TimelinePresenter @Inject constructor(
4649
private val timelineItemsFactory: TimelineItemsFactory,
4750
private val room: MatrixRoom,
51+
private val dispatchers: CoroutineDispatchers,
52+
private val appScope: CoroutineScope,
4853
) : Presenter<TimelineState> {
4954

5055
private val timeline = room.timeline
5156

5257
@Composable
5358
override fun present(): TimelineState {
54-
val localCoroutineScope = rememberCoroutineScope()
59+
val localScope = rememberCoroutineScope()
5560
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
5661
mutableStateOf(null)
5762
}
5863

59-
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
60-
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
64+
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
65+
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
6166

6267
val timelineItems by timelineItemsFactory.collectItemsAsState()
6368
val paginationState by timeline.paginationState.collectAsState()
64-
6569
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
6670
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
6771

72+
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
73+
val hasNewItems = remember { mutableStateOf(false) }
74+
6875
fun handleEvents(event: TimelineEvents) {
6976
when (event) {
70-
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
77+
TimelineEvents.LoadMore -> localScope.paginateBackwards()
7178
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
7279
is TimelineEvents.OnScrollFinished -> {
73-
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
74-
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
75-
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
76-
lastReadMarkerIndex = event.firstIndex
77-
lastReadMarkerId = eventId
78-
localCoroutineScope.sendReadReceipt(eventId)
80+
if (event.firstIndex == 0) {
81+
hasNewItems.value = false
7982
}
83+
appScope.sendReadReceiptIfNeeded(
84+
firstVisibleIndex = event.firstIndex,
85+
timelineItems = timelineItems,
86+
lastReadReceiptIndex = lastReadReceiptIndex,
87+
lastReadReceiptId = lastReadReceiptId
88+
)
8089
}
8190
}
8291
}
8392

93+
LaunchedEffect(timelineItems.size) {
94+
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
95+
}
96+
8497
LaunchedEffect(Unit) {
8598
timeline
8699
.timelineItems
@@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
98111
canReply = userHasPermissionToSendMessage,
99112
paginationState = paginationState,
100113
timelineItems = timelineItems,
114+
hasNewItems = hasNewItems.value,
101115
eventSink = ::handleEvents
102116
)
103117
}
104118

119+
/**
120+
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
121+
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
122+
* The state never goes back to false from this method, but need to be reset from somewhere else.
123+
*/
124+
private suspend fun computeHasNewItems(
125+
timelineItems: ImmutableList<TimelineItem>,
126+
prevMostRecentItemId: MutableState<String?>,
127+
hasNewItemsState: MutableState<Boolean>
128+
) = withContext(dispatchers.computation) {
129+
val newMostRecentItem = timelineItems.firstOrNull()
130+
val prevMostRecentItemIdValue = prevMostRecentItemId.value
131+
val newMostRecentItemId = newMostRecentItem?.identifier()
132+
val hasNewItems = prevMostRecentItemIdValue != null &&
133+
newMostRecentItem is TimelineItem.Event &&
134+
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
135+
newMostRecentItemId != prevMostRecentItemIdValue
136+
if (hasNewItems) {
137+
hasNewItemsState.value = true
138+
}
139+
prevMostRecentItemId.value = newMostRecentItemId
140+
}
141+
142+
private fun CoroutineScope.sendReadReceiptIfNeeded(
143+
firstVisibleIndex: Int,
144+
timelineItems: ImmutableList<TimelineItem>,
145+
lastReadReceiptIndex: MutableState<Int>,
146+
lastReadReceiptId: MutableState<EventId?>,
147+
) = launch(dispatchers.computation) {
148+
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
149+
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
150+
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
151+
lastReadReceiptIndex.value = firstVisibleIndex
152+
lastReadReceiptId.value = eventId
153+
timeline.sendReadReceipt(eventId)
154+
}
155+
}
156+
105157
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
106158
for (item in items.subList(index, items.count())) {
107159
if (item is TimelineItem.Event) {
@@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
114166
private fun CoroutineScope.paginateBackwards() = launch {
115167
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
116168
}
117-
118-
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
119-
timeline.sendReadReceipt(eventId)
120-
}
121169
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ data class TimelineState(
2828
val highlightedEventId: EventId?,
2929
val canReply: Boolean,
3030
val paginationState: MatrixTimeline.PaginationState,
31+
val hasNewItems: Boolean,
3132
val eventSink: (TimelineEvents) -> Unit
3233
)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
3131
import io.element.android.libraries.matrix.api.core.UserId
3232
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
3333
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
34-
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
3534
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
35+
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
3636
import kotlinx.collections.immutable.ImmutableList
3737
import kotlinx.collections.immutable.persistentListOf
3838
import kotlinx.collections.immutable.toImmutableList
@@ -45,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
4545
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
4646
highlightedEventId = null,
4747
canReply = true,
48-
eventSink = {}
48+
hasNewItems = false,
49+
eventSink = {},
4950
)
5051

5152
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
@@ -127,6 +128,7 @@ internal fun aTimelineItemEvent(
127128
localSendState = sendState,
128129
inReplyTo = inReplyTo,
129130
debugInfo = debugInfo,
131+
origin = null
130132
)
131133
}
132134

@@ -153,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
153155
model, originalJson, latestEditedJson
154156
)
155157

156-
fun aGroupedEvents(): TimelineItem.GroupedEvents {
158+
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
157159
val event = aTimelineItemEvent(
158160
isMine = true,
159161
content = aTimelineItemStateEventContent(),
160162
groupPosition = TimelineItemGroupPosition.None
161163
)
162164
return TimelineItem.GroupedEvents(
165+
id = id.toString(),
163166
events = listOf(
164167
event,
165168
event,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt

Lines changed: 43 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline
2121
import androidx.compose.animation.AnimatedVisibility
2222
import androidx.compose.animation.ExperimentalAnimationApi
2323
import androidx.compose.animation.animateContentSize
24+
import androidx.compose.animation.core.tween
2425
import androidx.compose.animation.scaleIn
2526
import androidx.compose.animation.scaleOut
2627
import androidx.compose.foundation.layout.Box
@@ -48,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
4849
import androidx.compose.runtime.saveable.rememberSaveable
4950
import androidx.compose.ui.Alignment
5051
import androidx.compose.ui.Modifier
51-
import androidx.compose.ui.draw.shadow
5252
import androidx.compose.ui.platform.LocalInspectionMode
5353
import androidx.compose.ui.res.pluralStringResource
5454
import androidx.compose.ui.tooling.preview.Preview
@@ -64,17 +64,13 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
6464
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
6565
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
6666
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
67-
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
6867
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
6968
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
7069
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
7170
import io.element.android.libraries.designsystem.theme.components.Icon
7271
import io.element.android.libraries.matrix.api.core.EventId
7372
import io.element.android.libraries.matrix.api.core.UserId
7473
import io.element.android.libraries.theme.ElementTheme
75-
import kotlinx.collections.immutable.ImmutableList
76-
import kotlinx.collections.immutable.persistentListOf
77-
import kotlinx.collections.immutable.toPersistentList
7874
import kotlinx.coroutines.launch
7975

8076
@Composable
@@ -103,13 +99,6 @@ fun TimelineView(
10399
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
104100
}
105101

106-
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
107-
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
108-
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
109-
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
110-
if (!isScrollFinished) return@LaunchedEffect
111-
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
112-
}
113102

114103
Box(modifier = modifier) {
115104
LazyColumn(
@@ -150,8 +139,8 @@ fun TimelineView(
150139

151140
TimelineScrollHelper(
152141
lazyListState = lazyListState,
153-
timelineItems = state.timelineItems,
154-
onScrollFinishedAt = ::onScrollFinishedAt,
142+
hasNewItems = state.hasNewItems,
143+
onScrollFinishedAt = ::onScrollFinishedAt
155144
)
156145
}
157146
}
@@ -247,63 +236,66 @@ fun TimelineItemRow(
247236
}
248237

249238
@Composable
250-
internal fun BoxScope.TimelineScrollHelper(
239+
private fun BoxScope.TimelineScrollHelper(
251240
lazyListState: LazyListState,
252-
timelineItems: ImmutableList<TimelineItem>,
253-
onScrollFinishedAt: (Int) -> Unit = {},
241+
hasNewItems: Boolean,
242+
onScrollFinishedAt: (Int) -> Unit,
254243
) {
255244
val coroutineScope = rememberCoroutineScope()
256-
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
257245
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
258-
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
259-
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
246+
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
260247

261-
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
262-
if (!isScrollFinished) return@LaunchedEffect
263-
264-
// Auto-scroll when new timeline items appear
265-
if (shouldAutoScrollToBottom) {
248+
LaunchedEffect(canAutoScroll, hasNewItems) {
249+
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
250+
if (shouldAutoScroll) {
266251
coroutineScope.launch {
267252
lazyListState.animateScrollToItem(0)
268253
}
269254
}
270255
}
271-
LaunchedEffect(isScrollFinished) {
272-
if (!isScrollFinished) return@LaunchedEffect
273256

274-
// Notify the parent composable about the first visible item index when scrolling finishes
275-
onScrollFinishedAt(firstVisibleItemIndex)
257+
LaunchedEffect(isScrollFinished) {
258+
if (isScrollFinished) {
259+
// Notify the parent composable about the first visible item index when scrolling finishes
260+
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
261+
}
276262
}
277263

278-
// Jump to bottom button (display also in previews)
279-
AnimatedVisibility(
264+
JumpToBottomButton(
265+
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
266+
isVisible = !canAutoScroll,
280267
modifier = Modifier
281268
.align(Alignment.BottomEnd)
282269
.padding(end = 24.dp, bottom = 12.dp),
283-
visible = showScrollToBottomButton || LocalInspectionMode.current,
284-
enter = scaleIn(),
285-
exit = scaleOut(),
270+
onClick = {
271+
coroutineScope.launch {
272+
if (lazyListState.firstVisibleItemIndex > 10) {
273+
lazyListState.scrollToItem(0)
274+
} else {
275+
lazyListState.animateScrollToItem(0)
276+
}
277+
}
278+
}
279+
)
280+
}
281+
282+
@Composable
283+
private fun JumpToBottomButton(
284+
isVisible: Boolean,
285+
onClick: () -> Unit,
286+
modifier: Modifier = Modifier,
287+
) {
288+
AnimatedVisibility(
289+
modifier = modifier,
290+
visible = isVisible || LocalInspectionMode.current,
291+
enter = scaleIn(animationSpec = tween(100)),
292+
exit = scaleOut(animationSpec = tween(100)),
286293
) {
287294
FloatingActionButton(
288-
onClick = {
289-
coroutineScope.launch {
290-
if (firstVisibleItemIndex > 10) {
291-
lazyListState.scrollToItem(0)
292-
} else {
293-
lazyListState.animateScrollToItem(0)
294-
}
295-
}
296-
},
295+
onClick = onClick,
297296
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
298297
shape = CircleShape,
299-
modifier = Modifier
300-
.shadow(
301-
elevation = 4.dp,
302-
shape = CircleShape,
303-
ambientColor = ElementTheme.materialColors.primary,
304-
spotColor = ElementTheme.materialColors.primary,
305-
)
306-
.size(36.dp),
298+
modifier = Modifier.size(36.dp),
307299
containerColor = ElementTheme.colors.bgSubtleSecondary,
308300
contentColor = ElementTheme.colors.iconSecondary
309301
) {

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
8585
localSendState = currentTimelineItem.event.localSendState,
8686
inReplyTo = currentTimelineItem.event.inReplyTo(),
8787
debugInfo = currentTimelineItem.event.debugInfo,
88+
origin = currentTimelineItem.event.origin,
8889
)
8990
}
9091

0 commit comments

Comments
 (0)