mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge pull request #6694 from jennaharris7/feature/scroll-to-unread-messages
Timeline: jump-to-unread FAB and mark-as-read shortcut
This commit is contained in:
+7
-4
@@ -502,7 +502,9 @@ private fun MessagesViewContent(
|
||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
|
||||
// Combined height of the banners overlaid above the timeline. Drives the floating
|
||||
// date badge offset so the badge sits below whichever banners are currently showing.
|
||||
var topBannersHeightDp by remember { mutableStateOf(0.dp) }
|
||||
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
@@ -518,14 +520,15 @@ private fun MessagesViewContent(
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
||||
floatingDateTopOffset = topBannersHeightDp,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ sealed interface TimelineEvent {
|
||||
|
||||
data object HideShieldDialog : TimelineEvent
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
|
||||
+63
-8
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
@@ -96,6 +97,7 @@ class TimelinePresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
|
||||
@@ -134,9 +136,15 @@ class TimelinePresenter(
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
|
||||
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
||||
|
||||
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
|
||||
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
|
||||
// the SDK to push a refreshed fully-read marker; the after-await ordering means any
|
||||
// RoomInfo update racing the mark-as-read call has already landed and can't undo this.
|
||||
val suppressJumpToUnread = remember { mutableStateOf(false) }
|
||||
|
||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||
val renderReadReceipts by remember {
|
||||
sessionPreferencesStore.isRenderReadReceiptsEnabled()
|
||||
@@ -151,6 +159,9 @@ class TimelinePresenter(
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
}
|
||||
val displayJumpToUnread by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
@@ -224,6 +235,14 @@ class TimelinePresenter(
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
|
||||
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
|
||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||
Timber.tag(tag).w(it, "Failed to get latest event id to mark as fully read")
|
||||
null
|
||||
} ?: return@launch
|
||||
markAsFullyRead(room.roomId, latestEventId)
|
||||
suppressJumpToUnread.value = true
|
||||
}
|
||||
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
||||
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
|
||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||
@@ -275,6 +294,45 @@ class TimelinePresenter(
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
|
||||
// read marker advances in place — the SDK swaps the marker virtual item to a new position
|
||||
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
|
||||
// bottom of the room.
|
||||
//
|
||||
// The state has three shapes:
|
||||
// - InWindow: the SDK has materialised a virtual ReadMarker item in the loaded window;
|
||||
// tapping the FAB smoothly scrolls to its index.
|
||||
// - OutOfWindow: the marker event is older than the loaded window, so the SDK gives us
|
||||
// only the event id via RoomInfo.fullyReadEventId; tapping triggers a focused-event
|
||||
// load via the existing TimelineEvent.FocusOnEvent path.
|
||||
// - Hidden: feature flag off, no marker, caught-up (marker loaded but no virtual item),
|
||||
// or initial load (no items yet).
|
||||
val jumpToUnread = remember { mutableStateOf<JumpToUnreadState>(JumpToUnreadState.Hidden) }
|
||||
// The SDK is authoritative again once it pushes a new fully-read marker, so drop the
|
||||
// post-mark-as-read suppression and let the recompute below pick up the new value.
|
||||
LaunchedEffect(roomInfo.fullyReadEventId) {
|
||||
suppressJumpToUnread.value = false
|
||||
}
|
||||
LaunchedEffect(timelineItems, displayJumpToUnread, roomInfo.fullyReadEventId, suppressJumpToUnread.value) {
|
||||
if (!displayJumpToUnread || suppressJumpToUnread.value) {
|
||||
jumpToUnread.value = JumpToUnreadState.Hidden
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val items = timelineItems
|
||||
val fullyReadEventId = roomInfo.fullyReadEventId
|
||||
jumpToUnread.value = withContext(dispatchers.computation) {
|
||||
val markerIndex = items.indexOfFirst {
|
||||
(it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel
|
||||
}
|
||||
when {
|
||||
markerIndex >= 0 -> JumpToUnreadState.InWindow(markerIndex)
|
||||
fullyReadEventId != null && items.isNotEmpty() && !timelineItemIndexer.isKnown(fullyReadEventId) ->
|
||||
JumpToUnreadState.OutOfWindow(fullyReadEventId)
|
||||
else -> JumpToUnreadState.Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size, focusRequestState.value) {
|
||||
val currentFocusRequestState = focusRequestState.value
|
||||
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
|
||||
@@ -324,6 +382,8 @@ class TimelinePresenter(
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -385,7 +445,7 @@ class TimelinePresenter(
|
||||
private suspend fun computeNewItemState(
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
prevMostRecentItemId: MutableState<UniqueId?>,
|
||||
newEventState: MutableState<NewEventState>
|
||||
newEventState: MutableState<NewEventState>,
|
||||
) = withContext(dispatchers.computation) {
|
||||
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
|
||||
if (newEventState.value == NewEventState.FromMe) {
|
||||
@@ -404,12 +464,7 @@ class TimelinePresenter(
|
||||
|
||||
if (hasNewEvent) {
|
||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
||||
val fromMe = newMostRecentItem.isMine
|
||||
newEventState.value = if (fromMe) {
|
||||
NewEventState.FromMe
|
||||
} else {
|
||||
NewEventState.FromOther
|
||||
}
|
||||
newEventState.value = if (newMostRecentItem.isMine) NewEventState.FromMe else NewEventState.FromOther
|
||||
}
|
||||
prevMostRecentItemId.value = newMostRecentItemId
|
||||
}
|
||||
|
||||
+17
@@ -35,6 +35,8 @@ data class TimelineState(
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
@@ -83,3 +85,18 @@ data class TimelineRoomInfo(
|
||||
val typingNotificationState: TypingNotificationState,
|
||||
val predecessorRoom: PredecessorRoom?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the jump-to-unread FAB should be shown, and if so, how tapping it
|
||||
* should bring the user to the read marker.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface JumpToUnreadState {
|
||||
data object Hidden : JumpToUnreadState
|
||||
|
||||
/** The read marker is materialised at [index] in the loaded timeline — smooth scroll to it. */
|
||||
data class InWindow(val index: Int) : JumpToUnreadState
|
||||
|
||||
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
|
||||
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
|
||||
}
|
||||
|
||||
+6
-1
@@ -59,6 +59,9 @@ fun aTimelineState(
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayFloatingDateBadge: Boolean = false,
|
||||
displayJumpToUnread: Boolean = false,
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
newEventState: NewEventState = NewEventState.None,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
@@ -72,13 +75,15 @@ fun aTimelineState(
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = NewEventState.None,
|
||||
newEventState = newEventState,
|
||||
isLive = isLive,
|
||||
focusRequestState = focusRequestState,
|
||||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+285
-28
@@ -10,22 +10,34 @@ package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -39,16 +51,27 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
|
||||
@@ -65,20 +88,24 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||
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
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.testtags.TestTag
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -106,6 +133,7 @@ fun TimelineView(
|
||||
modifier: Modifier = Modifier,
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
forceJumpToReadMarkerVisibility: Boolean = false,
|
||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
floatingDateTopOffset: Dp = 0.dp,
|
||||
) {
|
||||
@@ -125,6 +153,14 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvent.JumpToLive)
|
||||
}
|
||||
|
||||
fun onMarkAllAsRead() {
|
||||
state.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
}
|
||||
|
||||
fun onFocusOnEvent(eventId: EventId) {
|
||||
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
val view = LocalView.current
|
||||
@@ -205,12 +241,17 @@ fun TimelineView(
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
|
||||
newEventState = state.newEventState,
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
displayJumpToUnread = state.displayJumpToUnread,
|
||||
jumpToUnread = state.jumpToUnread,
|
||||
onScrollFinishAt = ::onScrollFinishAt,
|
||||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
onMarkAllAsRead = ::onMarkAllAsRead,
|
||||
onFocusOnEvent = ::onFocusOnEvent,
|
||||
)
|
||||
|
||||
if (state.displayFloatingDateBadge && useReverseLayout) {
|
||||
@@ -289,10 +330,15 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
newEventState: NewEventState,
|
||||
isLive: Boolean,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
forceJumpToReadMarkerVisibility: Boolean,
|
||||
focusRequestState: FocusRequestState,
|
||||
displayJumpToUnread: Boolean,
|
||||
jumpToUnread: JumpToUnreadState,
|
||||
onScrollFinishAt: (Int) -> Unit,
|
||||
onJumpToLive: () -> Unit,
|
||||
onFocusEventRender: () -> Unit,
|
||||
onMarkAllAsRead: () -> Unit,
|
||||
onFocusOnEvent: (EventId) -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
@@ -301,6 +347,22 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||
}
|
||||
}
|
||||
val isJumpToUnreadVisible by remember(jumpToUnread, forceJumpToReadMarkerVisibility) {
|
||||
derivedStateOf {
|
||||
if (forceJumpToReadMarkerVisibility) return@derivedStateOf true
|
||||
when (val jtu = jumpToUnread) {
|
||||
JumpToUnreadState.Hidden -> false
|
||||
// Marker is outside the loaded window — we have no on-screen anchor, so just show.
|
||||
is JumpToUnreadState.OutOfWindow -> true
|
||||
// Marker is in the loaded window — hide once it's scrolled into the visible range.
|
||||
is JumpToUnreadState.InWindow -> {
|
||||
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
|
||||
jtu.index > lastVisibleIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
|
||||
var jumpToLiveHandled by remember { mutableStateOf(true) }
|
||||
|
||||
/**
|
||||
@@ -327,6 +389,16 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
}
|
||||
|
||||
fun jumpToReadMarker() {
|
||||
when (val jtu = jumpToUnread) {
|
||||
JumpToUnreadState.Hidden -> Unit
|
||||
is JumpToUnreadState.InWindow -> coroutineScope.launch {
|
||||
lazyListState.animateScrollToItemCenter(jtu.index)
|
||||
}
|
||||
is JumpToUnreadState.OutOfWindow -> onFocusOnEvent(jtu.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(jumpToLiveHandled, isLive) {
|
||||
if (!jumpToLiveHandled && isLive) {
|
||||
lazyListState.scrollToItem(0)
|
||||
@@ -343,8 +415,11 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
|
||||
LaunchedEffect(canAutoScroll, newEventState) {
|
||||
val shouldScrollToBottom = isScrollFinished &&
|
||||
(canAutoScroll && newEventState == NewEventState.FromOther || newEventState == NewEventState.FromMe)
|
||||
val shouldScrollToBottom = isScrollFinished && when (newEventState) {
|
||||
is NewEventState.FromOther -> canAutoScroll
|
||||
NewEventState.FromMe -> true
|
||||
NewEventState.None -> false
|
||||
}
|
||||
if (shouldScrollToBottom) {
|
||||
scrollToBottom(force = true)
|
||||
}
|
||||
@@ -358,43 +433,145 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
}
|
||||
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
onClick = { jumpToBottom() },
|
||||
)
|
||||
.padding(end = 24.dp, bottom = 16.dp)
|
||||
) {
|
||||
JumpToPositionButton(
|
||||
icon = CompoundIcons.ChevronUp(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
isVisible = isJumpToUnreadVisible,
|
||||
hasUnread = true,
|
||||
onClick = ::jumpToReadMarker,
|
||||
onMarkAsRead = onMarkAllAsRead,
|
||||
testTag = TestTags.jumpToUnreadButton,
|
||||
)
|
||||
// Reserves space for the jump to bottom button so the jump to unread button above
|
||||
// stays in the same position regardless of whether the jump to bottom button is visible.
|
||||
Box(modifier = Modifier.size(36.dp)) {
|
||||
JumpToPositionButton(
|
||||
icon = CompoundIcons.ChevronDown(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
||||
isVisible = isJumpToBottomVisible,
|
||||
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
|
||||
onClick = ::jumpToBottom,
|
||||
onMarkAsRead = onMarkAllAsRead,
|
||||
testTag = TestTags.jumpToBottomButton,
|
||||
dotAlignment = Alignment.BottomCenter,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JumpToBottomButton(
|
||||
private fun JumpToPositionButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
isVisible: Boolean,
|
||||
hasUnread: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onMarkAsRead: () -> Unit,
|
||||
testTag: TestTag,
|
||||
modifier: Modifier = Modifier,
|
||||
dotAlignment: Alignment = Alignment.TopCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
modifier = modifier,
|
||||
visible = isVisible,
|
||||
enter = scaleIn(animationSpec = tween(100)),
|
||||
exit = scaleOut(animationSpec = tween(100)),
|
||||
enter = scaleIn(animationSpec = tween(220), initialScale = 0.8f) + fadeIn(animationSpec = tween(220)),
|
||||
exit = scaleOut(animationSpec = tween(180), targetScale = 0.8f) + fadeOut(animationSpec = tween(180)),
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(36.dp),
|
||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
) {
|
||||
Icon(
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(90f),
|
||||
imageVector = CompoundIcons.ArrowRight(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
||||
)
|
||||
.size(36.dp)
|
||||
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, ElementTheme.colors.borderDisabled, CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { menuExpanded = true },
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.testTag(testTag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
val menuTransitionState = remember { MutableTransitionState(false) }
|
||||
.apply { targetState = menuExpanded }
|
||||
if (menuTransitionState.currentState || menuTransitionState.targetState) {
|
||||
val gapPx = with(LocalDensity.current) { 8.dp.roundToPx() }
|
||||
val positionProvider = remember(gapPx) { CenterStartOfAnchorPositionProvider(gapPx) }
|
||||
Popup(
|
||||
popupPositionProvider = positionProvider,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
// Anchor the scale to the right-center edge so the menu visually grows
|
||||
// outward from the FAB it's attached to.
|
||||
val transformOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 0.5f)
|
||||
AnimatedVisibility(
|
||||
visibleState = menuTransitionState,
|
||||
enter = scaleIn(
|
||||
animationSpec = tween(180),
|
||||
initialScale = 0.9f,
|
||||
transformOrigin = transformOrigin,
|
||||
) + fadeIn(animationSpec = tween(180)),
|
||||
exit = scaleOut(
|
||||
animationSpec = tween(140),
|
||||
targetScale = 0.9f,
|
||||
transformOrigin = transformOrigin,
|
||||
) + fadeOut(animationSpec = tween(140)),
|
||||
) {
|
||||
// Hand-rolled instead of DropdownMenuItem: padding here is tighter
|
||||
// than DropdownMenuItem's 12.dp default to match the Figma spec.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefaultLevel1)
|
||||
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
menuExpanded = false
|
||||
onMarkAsRead()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.MarkAsRead(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconTertiary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.action_mark_as_read),
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val dotYOffset = if (dotAlignment == Alignment.BottomCenter) 4.dp else (-4).dp
|
||||
if (hasUnread) {
|
||||
UnreadIndicatorAtom(
|
||||
modifier = Modifier
|
||||
.align(dotAlignment)
|
||||
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
|
||||
color = ElementTheme.colors.iconSuccessPrimary,
|
||||
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,3 +610,83 @@ internal fun TimelineViewPreview(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
|
||||
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
|
||||
// here only varies the scroll-target state, not the upper indicator's visibility.
|
||||
@Composable
|
||||
private fun TimelineViewWithReadMarker(
|
||||
hasUnreadAbove: Boolean,
|
||||
hasUnreadBelow: Boolean,
|
||||
) {
|
||||
val timelineItems = persistentListOf<TimelineItem>(
|
||||
aTimelineItemEvent(isMine = false),
|
||||
aTimelineItemEvent(isMine = false),
|
||||
aTimelineItemEvent(isMine = true),
|
||||
aTimelineItemEvent(isMine = false),
|
||||
aTimelineItemEvent(isMine = false),
|
||||
aTimelineItemEvent(isMine = false),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
displayJumpToUnread = true,
|
||||
// Index points past the loaded items, mirroring the real-world state the FAB
|
||||
// represents: the user has scrolled past the read marker, so it's no longer in
|
||||
// view. The actual scroll target doesn't matter for a static preview.
|
||||
jumpToUnread = if (hasUnreadAbove) JumpToUnreadState.InWindow(timelineItems.size) else JumpToUnreadState.Hidden,
|
||||
newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None,
|
||||
),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onContentClick = {},
|
||||
onMessageLongClick = {},
|
||||
onSwipeToReply = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
forceJumpToReadMarkerVisibility = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview() = ElementPreview {
|
||||
TimelineViewWithReadMarker(hasUnreadAbove = false, hasUnreadBelow = false)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview {
|
||||
TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors the popup so its right edge sits [gapPx] to the left of the anchor and its vertical
|
||||
* center matches the anchor's. Adapts to localized menu widths and FAB size; coerced to stay
|
||||
* on-screen.
|
||||
*/
|
||||
private class CenterStartOfAnchorPositionProvider(
|
||||
private val gapPx: Int,
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset {
|
||||
val x = anchorBounds.left - popupContentSize.width - gapPx
|
||||
val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2
|
||||
return IntOffset(
|
||||
x = x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)),
|
||||
y = y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -8,12 +8,15 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Model if there is a new event in the timeline and if it is from me or from other.
|
||||
* This can be used to scroll to the bottom of the list when a new event is added.
|
||||
*/
|
||||
enum class NewEventState {
|
||||
None,
|
||||
FromMe,
|
||||
FromOther
|
||||
@Immutable
|
||||
sealed interface NewEventState {
|
||||
data object None : NewEventState
|
||||
data object FromMe : NewEventState
|
||||
data object FromOther : NewEventState
|
||||
}
|
||||
|
||||
+476
@@ -28,6 +28,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
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_2
|
||||
@@ -54,17 +56,20 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
@@ -366,6 +371,427 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is InWindow at the read marker virtual item index`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// SDK delivers items oldest-first; the factory reverses so output index 0 is the newest.
|
||||
// After processing: [msg-newest, membership, msg-2, read-marker, msg-old]
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-2"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("membership"), anEventTimelineItem(content = aRoomMembershipContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 3))
|
||||
assertThat(state.displayJumpToUnread).isTrue()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when no read marker and no fullyReadEventId`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate {
|
||||
it.timelineItems.size == 2 && it.jumpToUnread == JumpToUnreadState.Hidden
|
||||
}.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when JumpToUnread feature flag is disabled`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state ->
|
||||
assertThat(state.displayJumpToUnread).isFalse()
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState becomes FromOther when an event from another user arrives`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
// Seed prevMostRecentItemId so subsequent emissions count as new events.
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState resets to None on OnScrollFinished firstIndex 0`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
markAsReadResult = { Result.success(Unit) },
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.None }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState transitions to FromMe when latest event is from me`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// First, an event from another user moves us to FromOther.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
// Then the local user sends a message: state moves to FromMe.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
||||
)
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromMe }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState does not reset on OnScrollFinished firstIndex other than 0`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
// Scrolling stops above the bottom: state must NOT reset.
|
||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
|
||||
advanceUntilIdle()
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.newEventState == NewEventState.None }).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState stays None for events with PAGINATION origin`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// A back-paginated event arrives. It should not flip newEventState.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
UniqueId("paginated"),
|
||||
anEventTimelineItem(content = aMessageContent()).copy(origin = TimelineItemEventOrigin.PAGINATION),
|
||||
)
|
||||
)
|
||||
}
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is InWindow at index 0 when the read marker is the only item`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 0))
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is OutOfWindow when fullyReadEventId is set but not in the loaded window`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val fullyReadEventId = EventId("\$older-than-loaded-window")
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// Loaded items don't include the fullyReadEventId and the SDK didn't materialise a ReadMarker.
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.OutOfWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.OutOfWindow(eventId = fullyReadEventId))
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId IS in the loaded window`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
// The user is caught up: the marker event is loaded, but the SDK didn't insert a
|
||||
// virtual ReadMarker because there are no items newer than it.
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// A loaded item has eventId == AN_EVENT_ID (default of anEventTimelineItem).
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the timeline is empty`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
// Without any timeline items, the FAB must stay hidden — the user is mid-load.
|
||||
assertThat(initialState.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
advanceUntilIdle()
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.jumpToUnread != JumpToUnreadState.Hidden }).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the feature flag is off`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread prefers InWindow when both a virtual marker and fullyReadEventId are present`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isInstanceOf(JumpToUnreadState.InWindow::class.java)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread hides eagerly after MarkAllAsRead even before a new RoomInfo arrives`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
getLatestEventIdResult = { Result.success(AN_EVENT_ID_2) },
|
||||
)
|
||||
val fullyReadEventId = EventId("\$older-than-loaded-window")
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
val outOfWindow = consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.OutOfWindow }.last()
|
||||
assertThat(outOfWindow.jumpToUnread).isEqualTo(JumpToUnreadState.OutOfWindow(eventId = fullyReadEventId))
|
||||
|
||||
outOfWindow.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
// RoomInfo is intentionally NOT updated — eager hide must fire on the await alone.
|
||||
val afterMark = consumeItemsUntilPredicate { it.jumpToUnread == JumpToUnreadState.Hidden }.last()
|
||||
assertThat(afterMark.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
@@ -975,6 +1401,54 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead invokes markAsFullyRead with latest event id`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isCalledOnce().with(value(A_ROOM_ID), value(AN_EVENT_ID))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead does not invoke markAsFullyRead when latest event id lookup fails`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.failure(RuntimeException("boom")) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead does not invoke markAsFullyRead when there is no latest event`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(null) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
return awaitItem()
|
||||
}
|
||||
@@ -1014,6 +1488,7 @@ class TimelinePresenterTest {
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1033,6 +1508,7 @@ class TimelinePresenterTest {
|
||||
featureFlagService = featureFlagService,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user