Merge pull request #868 from vector-im/feature/fga/better_timeline_scroll

Feature/fga/better timeline scroll
This commit is contained in:
ganfra 2023-07-18 08:50:47 +02:00 committed by GitHub
commit cef618e50a
53 changed files with 426 additions and 210 deletions

View file

@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
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.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendEventAsState import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
private const val backPaginationEventLimit = 20 private const val backPaginationEventLimit = 20
@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor( class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val timeline = room.timeline private val timeline = room.timeline
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val highlightedEventId: MutableState<EventId?> = rememberSaveable { val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) } val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState() val paginationState by timeline.paginationState.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val hasNewItems = remember { mutableStateOf(false) }
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> { is TimelineEvents.OnScrollFinished -> {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item if (event.firstIndex == 0) {
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return hasNewItems.value = false
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
lastReadMarkerIndex = event.firstIndex
lastReadMarkerId = eventId
localCoroutineScope.sendReadReceipt(eventId)
} }
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId
)
} }
} }
} }
LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timeline timeline
.timelineItems .timelineItems
@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
canReply = userHasPermissionToSendMessage, canReply = userHasPermissionToSendMessage,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
/**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* 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.
* The state never goes back to false from this method, but need to be reset from somewhere else.
*/
private suspend fun computeHasNewItems(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>,
hasNewItemsState: MutableState<Boolean>
) = withContext(dispatchers.computation) {
val newMostRecentItem = timelineItems.firstOrNull()
val prevMostRecentItemIdValue = prevMostRecentItemId.value
val newMostRecentItemId = newMostRecentItem?.identifier()
val hasNewItems = prevMostRecentItemIdValue != null &&
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewItems) {
hasNewItemsState.value = true
}
prevMostRecentItemId.value = newMostRecentItemId
}
private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>,
) = launch(dispatchers.computation) {
// 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 && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId)
}
}
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? { private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
for (item in items.subList(index, items.count())) { for (item in items.subList(index, items.count())) {
if (item is TimelineItem.Event) { if (item is TimelineItem.Event) {
@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
private fun CoroutineScope.paginateBackwards() = launch { private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
} }
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
timeline.sendReadReceipt(eventId)
}
} }

View file

@ -28,5 +28,6 @@ data class TimelineState(
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val canReply: Boolean, val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit
) )

View file

@ -31,8 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -45,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null, highlightedEventId = null,
canReply = true, canReply = true,
eventSink = {} hasNewItems = false,
eventSink = {},
) )
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> { internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
@ -127,6 +128,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,
origin = null
) )
} }
@ -153,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
fun aGroupedEvents(): TimelineItem.GroupedEvents { fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent( val event = aTimelineItemEvent(
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None groupPosition = TimelineItemGroupPosition.None
) )
return TimelineItem.GroupedEvents( return TimelineItem.GroupedEvents(
id = id.toString(),
events = listOf( events = listOf(
event, event,
event, event,

View file

@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -48,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -64,7 +64,6 @@ 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.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -72,9 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
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.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -103,13 +99,6 @@ 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
} }
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
}
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
@ -150,8 +139,8 @@ fun TimelineView(
TimelineScrollHelper( TimelineScrollHelper(
lazyListState = lazyListState, lazyListState = lazyListState,
timelineItems = state.timelineItems, hasNewItems = state.hasNewItems,
onScrollFinishedAt = ::onScrollFinishedAt, onScrollFinishedAt = ::onScrollFinishedAt
) )
} }
} }
@ -247,63 +236,66 @@ fun TimelineItemRow(
} }
@Composable @Composable
internal fun BoxScope.TimelineScrollHelper( private fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState, lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>, hasNewItems: Boolean,
onScrollFinishedAt: (Int) -> Unit = {}, onScrollFinishedAt: (Int) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } } val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
LaunchedEffect(timelineItems, firstVisibleItemIndex) { LaunchedEffect(canAutoScroll, hasNewItems) {
if (!isScrollFinished) return@LaunchedEffect val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
if (shouldAutoScroll) {
// Auto-scroll when new timeline items appear
if (shouldAutoScrollToBottom) {
coroutineScope.launch { coroutineScope.launch {
lazyListState.animateScrollToItem(0) lazyListState.animateScrollToItem(0)
} }
} }
} }
LaunchedEffect(isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
// Notify the parent composable about the first visible item index when scrolling finishes LaunchedEffect(isScrollFinished) {
onScrollFinishedAt(firstVisibleItemIndex) if (isScrollFinished) {
// Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
}
} }
// Jump to bottom button (display also in previews) JumpToBottomButton(
AnimatedVisibility( // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
visible = showScrollToBottomButton || LocalInspectionMode.current, onClick = {
enter = scaleIn(), coroutineScope.launch {
exit = scaleOut(), if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
)
}
@Composable
private fun JumpToBottomButton(
isVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible || LocalInspectionMode.current,
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = onClick,
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
shape = CircleShape, shape = CircleShape,
modifier = Modifier modifier = Modifier.size(36.dp),
.shadow(
elevation = 4.dp,
shape = CircleShape,
ambientColor = ElementTheme.materialColors.primary,
spotColor = ElementTheme.materialColors.primary,
)
.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary, containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary contentColor = ElementTheme.colors.iconSecondary
) { ) {

View file

@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(), inReplyTo = currentTimelineItem.event.inReplyTo(),
debugInfo = currentTimelineItem.event.debugInfo, debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
) )
} }

View file

@ -16,11 +16,22 @@
package io.element.android.features.messages.impl.timeline.groups package io.element.android.features.messages.impl.timeline.groups
import androidx.annotation.VisibleForTesting
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.di.RoomScope
import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemGrouper @Inject constructor() { class TimelineItemGrouper @Inject constructor() {
/**
* Keys are identifier of items in a group, only one by group will be kept.
* Values are the actual groupIds.
*/
private val groupIds = HashMap<String, String>()
/** /**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/ */
@ -34,14 +45,14 @@ class TimelineItemGrouper @Inject constructor() {
// timelineItem cannot be grouped // timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. // There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup) result.addGroup(groupIds, currentGroup)
currentGroup.clear() currentGroup.clear()
} }
result.add(timelineItem) result.add(timelineItem)
} }
} }
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup) result.addGroup(groupIds, currentGroup)
} }
return result return result
} }
@ -51,16 +62,36 @@ class TimelineItemGrouper @Inject constructor() {
* Will add a group if there is more than 1 item, else add the item to the list. * Will add a group if there is more than 1 item, else add the item to the list.
*/ */
private fun MutableList<TimelineItem>.addGroup( private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event> groupIds: MutableMap<String, String>,
groupOfItems: MutableList<TimelineItem.Event>
) { ) {
if (group.size == 1) { if (groupOfItems.size == 1) {
// Do not create a group with just 1 item, just add the item to the result // Do not create a group with just 1 item, just add the item to the result
add(group.first()) add(groupOfItems.first())
} else { } else {
val groupId = groupIds.getOrPutGroupId(groupOfItems)
add( add(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
events = group.toImmutableList() id = groupId,
events = groupOfItems.toImmutableList()
) )
) )
} }
} }
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
assert(timelineItems.isNotEmpty())
for (item in timelineItems) {
val itemIdentifier = item.identifier()
if (this.contains(itemIdentifier)) {
return this[itemIdentifier]!!
}
}
val timelineItem = timelineItems.first()
return computeGroupIdWith(timelineItem).also { groupId ->
this[timelineItem.identifier()] = groupId
}
}
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
@ -66,6 +67,7 @@ sealed interface TimelineItem {
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?, val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo, val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem { ) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine val showSenderInformation = groupPosition.isNew() && !isMine
@ -81,9 +83,8 @@ sealed interface TimelineItem {
@Immutable @Immutable
data class GroupedEvents( data class GroupedEvents(
val id: String,
val events: ImmutableList<Event>, val events: ImmutableList<Event>,
) : TimelineItem { ) : TimelineItem
// use last id with a suffix. Last will not change in cas of new event from backpagination.
val id = "${events.last().id}_group"
}
} }

View file

@ -574,6 +574,8 @@ class MessagesPresenterTest {
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this
) )
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)

View file

@ -52,4 +52,5 @@ internal fun aMessageEvent(
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,
origin = null
) )

View file

@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
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.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class TimelinePresenterTest { class TimelinePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -51,10 +54,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -73,10 +73,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - set highlighted event`() = runTest { fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -94,70 +91,112 @@ class TimelinePresenterTest {
@Test @Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) MatrixTimelineItem.Event(0, anEventTimelineItem())
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(1) assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) MatrixTimelineItem.Event(0, anEventTimelineItem())
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker))) MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - covers hasNewItems scenarios`() = runTest {
val timeline = FakeMatrixTimeline()
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.timelineItems.size).isEqualTo(0)
timeline.updateTimelineItems {
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
timeline.updateTimelineItems { items ->
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
assertThat(awaitItem().hasNewItems).isTrue()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
assertThat(awaitItem().hasNewItems).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createTimelinePresenter(
timeline: MatrixTimeline = FakeMatrixTimeline(),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this
)
}
} }

View file

@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
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.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper() private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event( private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value, id = "0",
senderId = A_USER_ID, senderId = A_USER_ID,
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderDisplayName = "", senderDisplayName = "",
@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),
origin = null
) )
private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() { fun `test groupables and ensure reordering`() {
val result = sut.group( val result = sut.group(
listOf( listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = "1"),
aGroupableItem, aGroupableItem.copy(id = "0"),
), ),
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem.copy("0"),
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = "1"),
).toImmutableList() ).toImmutableList()
), ),
) )
@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
), ),
aNonGroupableItem, aNonGroupableItem,
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
) )
) )
} }
@Test
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When
val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "2")
)
val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3"))
groupableItems.add(2, aGroupableItem.copy("4"))
groupableItems.add(aGroupableItem.copy("5"))
val actualGroupId = sut.group(groupableItems).first().identifier()
// Then
assertThat(actualGroupId).isEqualTo(expectedGroupId)
}
} }

View file

@ -35,6 +35,7 @@ data class EventTimelineItem(
val timestamp: Long, val timestamp: Long,
val content: EventContent, val content: EventContent,
val debugInfo: TimelineItemDebugInfo, val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) { ) {
fun inReplyTo(): InReplyTo? { fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo return (content as? MessageContent)?.inReplyTo

View file

@ -0,0 +1,21 @@
/*
* 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.api.timeline.item.event
enum class TimelineItemEventOrigin {
LOCAL, SYNC, PAGINATION;
}

View file

@ -20,11 +20,13 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@ -47,6 +49,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
timestamp = it.timestamp().toLong(), timestamp = it.timestamp().toLong(),
content = contentMapper.map(it.content()), content = contentMapper.map(it.content()),
debugInfo = it.debugInfo().map(), debugInfo = it.debugInfo().map(),
origin = it.origin()?.map()
) )
} }
} }
@ -91,3 +94,11 @@ private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo {
latestEditedJson = latestEditJson, latestEditedJson = latestEditJson,
) )
} }
private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
return when (this) {
RustEventItemOrigin.LOCAL -> TimelineItemEventOrigin.LOCAL
RustEventItemOrigin.SYNC -> TimelineItemEventOrigin.SYNC
RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION
}
}

View file

@ -26,14 +26,17 @@ import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@ -115,6 +118,7 @@ fun anEventTimelineItem(
timestamp = timestamp, timestamp = timestamp,
content = content, content = content,
debugInfo = debugInfo, debugInfo = debugInfo,
origin = null,
) )
fun aProfileTimelineDetails( fun aProfileTimelineDetails(
@ -139,6 +143,21 @@ fun aProfileChangeMessageContent(
prevAvatarUrl = prevAvatarUrl, prevAvatarUrl = prevAvatarUrl,
) )
fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
)
) = MessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
type = messageType
)
fun aTimelineItemDebugInfo( fun aTimelineItemDebugInfo(
model: String = "Rust(Model())", model: String = "Rust(Model())",
originalJson: String? = null, originalJson: String? = null,
@ -146,3 +165,4 @@ fun aTimelineItemDebugInfo(
) = TimelineItemDebugInfo( ) = TimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )

View file

@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.test.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.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -36,6 +38,8 @@ class FakeMatrixTimeline(
var sendReadReceiptCount = 0 var sendReadReceiptCount = 0
private set private set
var sendReadReceiptLatch: CompletableDeferred<Unit>? = null
fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) { fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) {
_paginationState.getAndUpdate(update) _paginationState.getAndUpdate(update)
} }
@ -62,13 +66,13 @@ class FakeMatrixTimeline(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask {
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> { Result.success(Unit)
return Result.success(Unit)
} }
override suspend fun sendReadReceipt(eventId: EventId): Result<Unit> { override suspend fun sendReadReceipt(eventId: EventId): Result<Unit> = simulateLongTask {
sendReadReceiptCount++ sendReadReceiptCount++
return Result.success(Unit) sendReadReceiptLatch?.complete(Unit)
Result.success(Unit)
} }
} }

View file

@ -16,7 +16,12 @@
package io.element.android.tests.testutils package io.element.android.tests.testutils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Workaround for https://github.com/cashapp/molecule/issues/249. * Workaround for https://github.com/cashapp/molecule/issues/249.
@ -26,3 +31,18 @@ suspend inline fun <T> simulateLongTask(lambda: () -> T): T {
delay(1) delay(1)
return lambda() return lambda()
} }
/**
* Can be used for testing events in Presenter, where the event does not emit new state.
* If the (virtual) timeout is passed, we release the latch manually.
*/
suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred<Unit>) -> Unit) {
val latch = CompletableDeferred<Unit>()
try {
withTimeout(timeout) {
latch.also(block).await()
}
} catch (exception: TimeoutCancellationException) {
latch.complete(Unit)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:651319df939573207244eb84d9cea2bfac1c216e704f58c27533d3b2d98c4c64 oid sha256:1a3b5bbcdd1593e81384b335045bdcb0b3e01782868993e9f6437a15ca39dbca
size 51933 size 51380

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b0e751aee2e0f3fa735bbbb6b96d561fa485ebb3244dd51e3842c1f685cb1772 oid sha256:7e26db0a0a8d767d6e732c1d2742cba3c3475d761b0c3017ff01cee7e3d362a5
size 63335 size 62773

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3182e3cd2971af26007edfbee2d0ad058eb1bae0fb79d110800f0980c982035d oid sha256:3d5cae7d73178aecc12fbf5f1f27c9e66608b90d462deec862a881b403469e93
size 50045 size 49475

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e836190c744d6a65232c870544d4f1ec1020074c752585966488cd56e7bf6709 oid sha256:fe9456f4446104142221e28167a578a2b5cac772dc35aeb14352c0d422dd6fb0
size 66264 size 65726

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ba5b9161bd13d3185c4233b1c991c377f86ab7d33e61d549f1be0c89879db8f3 oid sha256:cad3b5ce023d890fd4a5ff0f5bbabb678bc6d5c76d3476e8053673c06f360c8b
size 56672 size 56102

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9417f2631c429f047e799eba52183dc4a8be202f0110280332f837dd7b60c3d2 oid sha256:71b3c712fb8e4bca178afed2de6f4184bb717e3415622e97f14bb4fe36aaf9d5
size 229653 size 229097

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:51bfd93f2bd0bf313e675d63a89fddacfb007fbe207ca15871d3f94df14c9d24 oid sha256:f44d772e5b9fe65a79e05aaaa82536b19382f9d821c0412c47a7d330d539ffd2
size 230633 size 230080

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:085091b7cb44ada72a16af6e3c652dd85589455648949db3bf37ef4160a8ebe9 oid sha256:41a6c2dd81698696708802276b615a78ff22cc7cb8ae2d4b8d8cc862bfe44a24
size 71401 size 70856

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1eed02746fd9373275f6d66a283f3c9b0719e19d44194a1ba0cbb701c7c5d729 oid sha256:f729f89dce978bb9e061826389e0db1af0e18835e4cd1ab6cbfaa31f4fd3152f
size 85494 size 84942

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eddf42039c5629333db7d7b56abbd7dba9af8c70bb4787e4101a768e264c3199 oid sha256:03a4b62920af61098e850cfca6a6f43acd7b310094fb70e1c3e4b7e29465e244
size 174401 size 173851

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:147052b5017396f02c0381ba8084b6c22d893e6ccc9e3551ce34ade7c05c61bc oid sha256:ebcbb40acf19fb489f6a8068a22af0b79c304be46863e77fa12b3b333065a1b2
size 165153 size 164622

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7655aa9de18743923bbe410b7ea05fe2d00ce716dc43f9673c8007951ec924ca oid sha256:789ee5ccad8356198cdc0634b4e9a65ed44be2d26e7ce83a8662598c1bd8d4c2
size 53316 size 52765

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8f81af9d9badaa6141390f83675ebeac7b3e9ec7412bb7ce2506e7123a5ec958 oid sha256:7b40a2e5d60a906d7c35c3ece1854671f5b319b00ad077322d094cbf906c07f7
size 65345 size 64803

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9546f65d0347f7c7c07f07e730ec8a344549a43e47a21585b7d9375ac0a1c838 oid sha256:54c12970e3563de958f88e4c538dd368f9810266060627393256a91741f7c6cf
size 53692 size 53340

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8a62fb60461f2f699ea010663f617ad12c949365b3f9125804cc9c674bb1f03 oid sha256:3b7ae3084cb9d1ecee2e4db49c228516bdd7352683e797edf737c8d216922dec
size 65959 size 65601

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c34993f6832f141eeee8bbae9068828d58ebc900a21a658d5b0627096fe563c4 oid sha256:dd034d439c08793e0dfd59f6bd5dcd88c06ded6cbe98172bf3cf296888e6d575
size 51582 size 51244

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0d11d593be6d5ade5417aacef2aff79bc8940c971451669f01de4efa74fd7673 oid sha256:d9d835cb1a420117b4d967181e2ca0fad71ba243d6a17bea08b82cae41f6b8e2
size 69100 size 68760

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3abc3d49852c70fe1d1c50e87cf86e91eb3cd91ff1cecca5ef0f1a30ab8dec9a oid sha256:4ad09278ae2ebb8171adba96d9f6e91d0cc4f120b0b2368087796dadb37eeb87
size 58883 size 58539

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ac58823ba2ae3c457f4199e71167693c6ea42839d912eb9b8a8d079aa26ec303 oid sha256:145856c3a7ff43702403ee5b86a7119b0475f03fdbc0f2e7f84e10350a64b150
size 230198 size 229842

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5af0e9fa17bdf8562c0f4e2f31c0ff0e36e243ec3ae51f23eaca6fe67da9f6a6 oid sha256:c1f0939b0c22ab89466953889e5bb63e11c45f02af79eeba3f377409af61d356
size 231168 size 230809

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a84fdf49767a07d801a745ffaf659f15ead717f8edd6aeea31acf0cf82f1e1e5 oid sha256:082206122d4e6d9e6171b3b2444c576ff7bd47fb3946d8d98e2812654f39cd40
size 73991 size 73641

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f494dc4f4b95e50fec21ee5c33a467711ce96dae8d2e68c091efc7874d682d8d oid sha256:14ecdfd226a25743e3fac8850318216d6ec193755fa7559fd5523236b7835bc6
size 90101 size 89754

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7359910c6bf938e2bb226b1ef7072de3441bd514f1c62286dfa5a1a9b2797d33 oid sha256:d08de923f29ec6c3c2bf735ca0d015ca1097c95484119d1d39bb8c3f93f31100
size 364102 size 363776

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59a85793b104485cf64dc090ce295d21248502d98696c1d41c3906936a7990a6 oid sha256:3e8832da336aea6c7ebb3621668569ff16238042fb9650afcd938594a59fd3ae
size 323105 size 322771

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1841a5b097309e8809b4a0ce8e798a19794e77bdb29d0037a98ff507fd8ba64a oid sha256:9a243d53d10ca2eaa249b22af8a9fddf1a8ccf60db4f3ad9374e31cf494fe878
size 55252 size 54909

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:373ab2d1c52b7fd818be21a3b8ef167b7ec27a0bb9147d040c680316f30ac346 oid sha256:3fdd478be89b47fcaf9725ca14c1e775087f1ccf2a266f3476a537bbc2b29922
size 67816 size 67476

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a6659d49217bb75dd62310ab1e5012466402203f2b9d11597da037f21e1273df oid sha256:7c724bc77185a9ceec2cf092cb1d7865b13718d5320bd7ac4850bb85590f05b2
size 52885 size 52294

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c9bc30e1ff19b87b1ee95e78f9b5c000cda8760a6e84c6364b02e8b725092586 oid sha256:a303894134ed06348b609e41cf109dcafcd994c3dffabc6d9ab436fe92605245
size 54301 size 53710

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:20689b49bd2ef39ea8a9b2d492f1e6ebb7841bacca74a80fdbde33964407e635 oid sha256:7523ec0d6defd7074af0c804fdde64fb8d421f6cfa729bc1c4f9858bd87c42d0
size 53116 size 52554

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c3dd1a28aa9ef018e58435efe308822bfb49a99dc19366328edc6131039614f oid sha256:1f48089147f7e089abfa64254143a80857ccc9f840aa60403e1aabc67e2b6d51
size 55948 size 55458

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:565e8b74a4e88fdd1533c982a7210a0d79a01efb2d1059bc03b40b05555a23bb oid sha256:7ff682ee8363d450bb76db72ea06deea87fa47692ce319b7dff315d2a10dfb6a
size 51661 size 51033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0d449f35be765f1c5f38ff7bc026b34ebeeb46dc2e17ddd11d153b916ac8aa3c oid sha256:e84edf8adf1a89153dd45272d36e04561d66f2ea765ad9edecfd8d750ba99f97
size 54626 size 54237

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7c8f63609634975edd98298264de50da56cd0a805a71e0a10cce846fcb077de oid sha256:0bc9521bd1576d47ca6f643adf43ce3638d40328207d908ec863c72503d34f24
size 56077 size 55682

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2e9579c9292407a7e837871a98785e135559c6584cca54b6ab997974f0ce24ae oid sha256:c2209c3cc4e7de32ed92b3b0da4616b54d19091d43d21c0e4013728450d0d3f7
size 54986 size 54595

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cf176efd967d4e00bae6589cc39cd24849f64f32d4d90e1d6461e29aa9f480f5 oid sha256:d7bdd0ca39534b31c9d421e28337712b1cf2aaf841a766fcc6bdaf996c756bfa
size 57961 size 57524

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:92a4c16a14f645b3db415bad8a69db1d8c3097f30087be45702f6a9c45ee6cd6 oid sha256:ba349f81d5c417c612cae1263504ea5b4e83dc606ec20c3942368e8992b87ad4
size 53055 size 52886