Merge pull request #7024 from element-hq/feature/bma/scroll-to-unread-messages

Scroll to unread messages
This commit is contained in:
Benoit Marty
2026-07-06 16:02:28 +02:00
committed by GitHub
83 changed files with 1160 additions and 174 deletions
@@ -106,7 +106,7 @@ private fun RoomListModalBottomSheetContent(
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read),
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
)
},
@@ -34,7 +34,7 @@ class RoomListContextMenuTest : RobolectricTest() {
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
clickOn(R.string.screen_roomlist_mark_as_read)
clickOn(CommonStrings.action_mark_as_read)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
@@ -514,7 +514,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,
@@ -531,14 +533,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(),
) {
@@ -25,6 +25,8 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
/**
* Events coming from a timeline item.
*/
@@ -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 isLive by remember {
timelineController.isLive()
@@ -145,6 +153,9 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
}
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
fun handleEvent(event: TimelineEvent) {
when (event) {
@@ -218,6 +229,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))
@@ -274,7 +293,67 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
LaunchedEffect(timelineItems.size, focusRequestState.value) {
// 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.map { it.identifier() },
displayJumpToUnread,
roomInfo.fullyReadEventId,
roomInfo.numUnreadMessages,
suppressJumpToUnread.value,
) {
if (!displayJumpToUnread || suppressJumpToUnread.value) {
jumpToUnread.value = JumpToUnreadState.Hidden
return@LaunchedEffect
}
val items = timelineItems
val fullyReadEventId = roomInfo.fullyReadEventId
val hasUnreadMessages = roomInfo.numUnreadMessages > 0
val markerIndex = withContext(dispatchers.computation) {
items.indexOfFirst {
(it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel
}
}
jumpToUnread.value = when {
markerIndex >= 0 -> JumpToUnreadState.InWindow(markerIndex)
// Out-of-window only when there is genuinely unread *displayable* content
// (numUnreadMessages counts "interesting" messages, never state/hidden events) AND
// the marker event isn't merely an in-window item we don't render. isKnown is the
// cheap display-index check; isEventLoaded falls back to the SDK to tell
// "in window but not displayed" apart from "genuinely out of window".
fullyReadEventId != null &&
hasUnreadMessages &&
items.isNotEmpty() &&
!timelineItemIndexer.isKnown(fullyReadEventId) &&
!timelineController.activeTimelineFlow().value.isEventLoaded(fullyReadEventId) ->
JumpToUnreadState.OutOfWindow(fullyReadEventId)
else -> JumpToUnreadState.Hidden
}
}
// Keyed on the full [timelineItems] reference (not just .size) so we re-resolve the index
// when a focused timeline loads with the same item count as the window it replaced — e.g.
// jumping to an out-of-window read marker in a busy room, where both windows fill to the
// same page size. With .size as the key the effect wouldn't re-run, the focused event's
// index would stay unresolved, and the scroll would never fire until a second tap.
LaunchedEffect(timelineItems.map { it.identifier() }, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId
@@ -321,6 +400,8 @@ class TimelinePresenter(
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent,
)
}
@@ -382,7 +463,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) {
@@ -401,12 +482,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
}
@@ -33,6 +33,8 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -81,3 +83,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
}
@@ -57,6 +57,9 @@ fun aTimelineState(
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: 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
@@ -69,12 +72,14 @@ fun aTimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
newEventState = NewEventState.None,
newEventState = newEventState,
isLive = isLive,
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
eventSink = eventSink,
)
}
@@ -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
@@ -107,6 +134,7 @@ fun TimelineView(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
forceJumpToReadMarkerVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) {
@@ -126,6 +154,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
@@ -206,12 +242,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 (useReverseLayout) {
@@ -290,10 +331,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 } }
@@ -302,6 +348,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) }
/**
@@ -328,6 +390,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)
@@ -344,8 +416,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)
}
@@ -359,43 +434,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),
)
}
}
}
}
@@ -435,3 +612,84 @@ 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 = {},
onGalleryItemClick = { _, _ -> },
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)),
)
}
}
@@ -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
}
@@ -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,500 @@ 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(),
// There is genuinely unread *displayable* content, and the marker event isn't loaded
// (isEventLoaded defaults to false), so the FAB targets the out-of-window marker.
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 1),
),
)
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 the marker event is loaded in the window but not displayed`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
// The marker event is in the loaded window (e.g. a state/filtered event) but never rendered.
val fullyReadEventId = EventId("\$loaded-but-not-displayed")
val timeline = FakeTimeline(timelineItems = timelineItems).apply {
isEventLoadedLambda = { it == fullyReadEventId }
}
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 1),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
)
presenter.test {
awaitFirstItem()
// Displayed items don't contain the marker event, but the SDK reports it as loaded.
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())),
)
)
advanceUntilIdle()
// It must never be OutOfWindow — there is nothing displayable to jump to.
val drained = consumeItemsUntilTimeout()
assertThat(drained.any { it.jumpToUnread is JumpToUnreadState.OutOfWindow }).isFalse()
assertThat(drained.last().jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread is Hidden when out of window but there are no unread messages`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val fullyReadEventId = EventId("\$older-than-loaded-window")
// isEventLoaded defaults to false (genuinely out of window), but numUnreadMessages is 0.
val timeline = FakeTimeline(timelineItems = timelineItems)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 0),
),
)
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())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
)
advanceUntilIdle()
val drained = consumeItemsUntilTimeout()
assertThat(drained.any { it.jumpToUnread != JumpToUnreadState.Hidden }).isFalse()
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, numUnreadMessages = 1),
),
)
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 +1474,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 +1561,7 @@ class TimelinePresenterTest {
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1033,6 +1581,7 @@ class TimelinePresenterTest {
featureFlagService = featureFlagService,
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
)
}
}
@@ -8,7 +8,9 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
@@ -34,14 +36,17 @@ fun UnreadIndicatorAtom(
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null,
border: BorderStroke? = null,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
count != null && count >= 1 -> CounterAtom(
count = count.toInt(),
modifier = modifier.semantics {
contentDescription?.let { this.contentDescription = it }
},
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
@@ -53,7 +58,8 @@ fun UnreadIndicatorAtom(
}
.size(size)
.clip(CircleShape)
.background(color),
.background(color)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
)
}
}
@@ -108,6 +108,14 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
JumpToUnread(
key = "feature.jump_to_unread",
title = "Jump to unread messages",
description = "Show a button to jump to the read marker, plus a count badge on the scroll-to-bottom button " +
"when new messages arrive while scrolled away.",
defaultValue = { false },
isFinished = false,
),
SlashCommand(
key = "feature.slash_command",
title = "Parse slash commands in the message composer",
@@ -224,6 +224,13 @@ interface Timeline : AutoCloseable {
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
/**
* Returns true if [eventId] is currently loaded in this timeline's window, even if the display
* layer does not render it (e.g. filtered or state events). This distinguishes "in the window
* but not displayed" from "genuinely outside the loaded window".
*/
suspend fun isEventLoaded(eventId: EventId): Boolean
/**
* Adds a new pinned event by sending an updated `m.room.pinned_events`
* event containing the new event id.
@@ -660,6 +660,24 @@ class RustTimeline(
}
}
override suspend fun isEventLoaded(eventId: EventId): Boolean = withContext(dispatcher) {
val isInLoadedItems = _timelineItems.replayCache.firstOrNull().orEmpty().any { timelineItem ->
timelineItem is MatrixTimelineItem.Event && timelineItem.eventId == eventId
}
if (isInLoadedItems) {
// Displayed events are caught above. The SDK-level list still holds events the display
// layer later drops, so this avoids the FFI call for in-window-but-not-rendered events.
true
} else {
// getEventTimelineItemByEventId throws when the event isn't in the loaded window.
// EventTimelineItem is a Disposable wrapping a native handle, so release it once we've
// confirmed presence — otherwise the handle lingers until the GC cleaner runs.
runCatchingExceptions {
inner.getEventTimelineItemByEventId(eventId.value).use { /* presence is all we need */ }
}.isSuccess
}
}
override suspend fun pinEvent(eventId: EventId): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.pinEvent(eventId = eventId.value)
@@ -460,6 +460,9 @@ class FakeTimeline(
override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId)
var isEventLoadedLambda: (eventId: EventId) -> Boolean = { false }
override suspend fun isEventLoaded(eventId: EventId): Boolean = isEventLoadedLambda(eventId)
var pinEventLambda: (eventId: EventId) -> Result<Boolean> = { lambdaError() }
override suspend fun pinEvent(eventId: EventId): Result<Boolean> {
return pinEventLambda(eventId)
@@ -100,6 +100,12 @@ object TestTags {
*/
val floatingActionButton = TestTag("floating-action-button")
/**
* Timeline jump-to-position buttons (long-press exposes "Mark as read").
*/
val jumpToUnreadButton = TestTag("jump-to-unread-button")
val jumpToBottomButton = TestTag("jump-to-bottom-button")
/**
* Timeline.
*/
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="action_mark_as_read">"Mark as read"</string>
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
</resources>
@@ -161,6 +161,8 @@ class KonsistPreviewTest {
"TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview",
"TimelineViewWithReadMarkerBothIndicatorsPreview",
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
"UserAvatarColorsPreview",
"UserProfileHeaderSectionWithVerificationViolationPreview",
"VoiceItemViewPlayPreview",
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ff35c533fef9ca6ddd0e0f31ae4f4d5673e7e90f6d52d9636bfa62a045d8ebf
size 47952
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbcfc8e7f8fa55d007014f39be61a2d8a5a895ecb51f06d83b4f65c13302ebdd
size 51087
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:990da337f293792e43768e9aa507581df18fac8b2b9dd01d7ad980e4239bec53
size 47571