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",
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1730ce48c314caff6efe29287609dcea9f8cc6eda235a4e1f0a0d210e36e4fe7
size 37453
oid sha256:1d3fd3cbe32073e9e58032baacec15591dcd1219d87e17048c7cad709402543d
size 37455
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78bcce34ea3a2a76333aa8f4e3bfb832507b49038c8e73a34c6ca70735db28bf
size 35828
oid sha256:8201b2412df88f9850a28129d354bc13f77397dc9156c598b0ae64e962e00653
size 36134
@@ -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
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:092e8d3ae1d219d48e3f696244cc83a53aa6402096ec6b7d817d3a115c6a28ac
size 50750
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6852e8f7ab35f830864a94360450b7d4b78b16ce7cb75f2152757dabddf8868
size 49599
oid sha256:4f5ce420b36245b164131386d761cbcd003450a2f8d4789a5d5aa681e69f3a74
size 48582
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:41bc1773dea61705e9109be0e4570be26e3a88c2347d028e348557694e843a20
size 88182
oid sha256:21c58efd51180d851fdba6c7ab8efa4c1777ef7339c0b8b249153b08cf6cc8f9
size 87548
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037
size 51116
oid sha256:a97aa2dda9f95577c260015cc619c520d7cb363ce1f2daa1c790d4976cce43de
size 50098
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3
size 63156
oid sha256:fccf8ce1c5bb494b8a009390ea6dcaa0c6a17051d5f311f939006141f73a3251
size 62124
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026
size 47404
oid sha256:7e269908cdd45a3996a1b04b8e477a75703bbf01cede83d773460f64f6f49871
size 46378
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3
size 64392
oid sha256:41adea47d3fdd76cc8dd49c1084b17544078ce3881e6b4e91cccf673efa3bef9
size 63367
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952
size 54368
oid sha256:2a22c1ca92e494a08a5641bd8f34c9edd45129b8187af26764c4020c91df400f
size 53373
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8
size 64322
oid sha256:788d2f17f3dfa8f4f63dc5a6454a4097a23ff984ec0985cf34ed3df146ba60ac
size 63442
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f7e3f1cdaf18d7d529be07cde3bdc9100243780101e73dd53c3d78639775974
size 66862
oid sha256:6870f4b12402d309b61277bb6c1c79cf4f7184d364c9275f10679e2f656a9e06
size 66225
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3d8648d88afa54cc57ae97853e10d3c09e5f7a9e5eb34a1634cdd4fca11b6dd
size 70959
oid sha256:bd36b89a823c06f75546ecfc55474650931fa9b70700244a1c333a4cad221c4f
size 69970
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:467e5aee42e9041db2672dc0d37e98fc1090c19207604a31247f3142d845f792
size 493534
oid sha256:fc2fa4ca81add0b97d595a3ab69b4b9dd7296a0a92afec5c230d863df34db72d
size 492920
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:723238ad84c9925eb0edbbf225b50a03bf29e5671777d9f03eba6b910ee00fcf
size 488705
oid sha256:28afc0e4146fbb75e7109b0cc3f0beac4dabf2f69c4a9928d2389a6908be312f
size 488121
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80436cf3797f7fe641e3312074d29d5385d09e93cf58927702cd5a2d9bda6a40
size 65249
oid sha256:4b7082e677feb1a1b9ddfdbf2f02a6ee214805ce21a17b88de7369f7b41f0ed1
size 64370
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2928e30fe5c473b93644cced7f8826696b6a8018b10ab96d89152b76c835b006
size 80674
oid sha256:9c8148f10a526303a10cb7cb79487c94d53d4ef93fde817d8696fa6cd564ea83
size 79960
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bda29daa05fd2ca70ee96e430e887c02d6a05e62accd298b0206dd1c532a77d3
size 69548
oid sha256:c565dd13a3d3a40aeabf92c169f1d4afe0b324eb630890287237c5ea16f38071
size 68671
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:991669a0939c78b1d1238cdc5f0197e123e5e21229332c53a7e0d65ff5f11ab7
size 99624
oid sha256:faafdb864f31d90df887cb689e8166b6a8f09941391af8df6d47ffcf62d2ebbb
size 98965
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bc3aff28b9187e987f4bf41bb81460234e4fbf1f5976de84c5f0126dfd28455
size 54529
oid sha256:886b3b82ebfa02b88dfbaf8a4627167ee4c6670fec6b223cf605a253d3fc5e97
size 53683
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c07f6bff1e3c3626ec2bb5ecbdb3f17931a8ba1ea01e93b6d14594b9ba11639
size 534417
oid sha256:314e3df36d05e4faa6750214c7b44d685502700617d7a7c87c688beac9a91d95
size 533638
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c62d43a36212da9b518269767f647493556a5082ccbd1cb3919b608687112405
size 49392
oid sha256:358d8aca9d2e08fc55b7afd10f9577462ac5ab1f9abe95523c2f21df12d12240
size 49449
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acf5a1f1e3f1073fa1cff4dd18ce13b5ec6f19c57e0ace95b074ad1681813fce
size 83691
oid sha256:acc9ef48e0d5c2bc08ca02f5415e7751357c2b3a6136440c3c7c323ab73b667c
size 83771
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4
size 50876
oid sha256:fa3a5b26a6d98948f69598cab12346ecc54edf72d131cafda69f68854e7d2741
size 50937
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70
size 61888
oid sha256:42955e8bed36514ef57a3e53b579041d92c5f37edcd587431a8086fdf99dfca5
size 61975
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481
size 47422
oid sha256:83ad6aad137696d06e00aa575d5cb35f96f0d90a61c1eab2a203958da2a73008
size 47474
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67
size 63288
oid sha256:513cb5cca0f8bea0af4c6272faa93c9cc6bb39470a90af807f1f8ca7edd1fd37
size 63374
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f
size 53792
oid sha256:104638ce62de3f283f4bbc1ee3967816b88d84a6969cfc0cdd3a50f716ad8096
size 53876
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314
size 64889
oid sha256:ee37c77bae1dbf13fe3d22650828bcd4b636b9be847ae0bb23ecfed0ce037313
size 64974
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06e2c25d3cbd111b22bd0188e2b1880729e023170c7bed972cdb3c34544ba55e
size 55547
oid sha256:204bd19f811ab2da4068165b67e2260d0855332877140fd67631a6f9e89c0455
size 55640
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1ebb6edcfa045967fd9d9a2b554b1e2bc1ef0e20680661c0af2d8530348ab72
size 69388
oid sha256:3ac772343aad8977a8618d69a50d97ff4d9818eb5869c3fc03e58e43f4b4abc1
size 69473
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3bf788838a204acb53bceba268327e3c2a772447490a597ed4929487716bc6fb
size 486425
oid sha256:2fb3a106c42c8176321be45a2334b185c59ad7c12e3fa099b4c9ef0b4fc98c3f
size 486526
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80e198bd8cf523c785de4a66c4c22cecb22d1040377bdfd66eeed821a4281592
size 481590
oid sha256:17a7b359ec376b3fcd4bf4bd60d26a9b0ea5f6319d1da291006c1bf36d3f1b39
size 481665
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08c51bcd425352f667189a6fdf98067322f90f76d506029f3d8db640c943654f
size 64109
oid sha256:e49aa7b7b5245cc48851ceb9d43e2cfbf4df07e427baf071f81717e0ce454c62
size 64186
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31b76164389481cbe53ea7caa1b1754202314274a0634b8737ad7023bed62a02
size 79065
oid sha256:434369f889778088c1612a304a4d7a50de90048eeb0506cd13345567cf0dd0b1
size 79196
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9962689d7ea7eedd28c7dbec66244df0321bcf45238b47fb9beac50ee81b4cd
size 68585
oid sha256:91ab3270af1a376d76ea3457b9e083036ba009a95794fcbddcde62f5a6dbf2b1
size 68637
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58d5217e29f95074a403572449d54a072d5969852d09c3a1ddacb43949f4a570
size 97346
oid sha256:9ea0d765a77d9682e376021a2c1c65b9a6bf00dbe69a0762b8c0abf9d0634880
size 97456
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24c430f5d40a0215e5c11348d40d2b9a50cf5bbc6639e5ebdb408004b0f3024c
size 54499
oid sha256:d02a19a7067b5ab4a6eddfdc50179e0457ff4e40f99746cd384bf5ef528f019b
size 54581
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:122b56f230efcad08fef31e50709dfaf4315befcc119e65c08b71c52404d87c7
size 216117
oid sha256:e6211f8de0d2ac250ea4318532c52bc5ba13e5f964a75424df0fc1c584f66f2a
size 216195
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee269e52306e048bbd0a06ea7c56f9080f37f37b00909c3d48a02a3a37bd57bc
size 131800
oid sha256:02c4892eaa7d27c867266c428374b64e5915ec50ddcdf8b1d532e1cbc6f56462
size 130436
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:179b30927c3db3963bef12243ae69908ad0b99275906197abd02c110f012213f
size 57267
oid sha256:0e3467a7606a5a7beaafb510faf008ed567b1e3a9915ede6e2ec09c491af0bb9
size 56552
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc02a7c7b38753d9509b3cb0fb2eb0e29b6d6c79b8471ba4dc7dd5b81b13d15b
size 50892
oid sha256:446ae29cbb211665f6d91129a7dee0f718b407fcb2a64d3f2c0e962e1cb47af7
size 50168
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44fd4c046c89628b32d85c7732ef2ed6a50b3e02928bd2409b6bfbb0c6aa3fd9
size 59064
oid sha256:38ae233a128e887efc48da73be3d6ce50a7095601315ed49a3c9cb209ffdda26
size 58352
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d1a8746f4dc362d7d1caa424b18c09c3d2e14753e8d6599267d472a61e197df
size 59137
oid sha256:7b749e1260d6ba2b7fef5ae9efd58612f6d67d1788f6c1e5cebc22d58d0fd7b7
size 58563
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8093bd975c14cf5da50203a039e0c292dadc68183fdee5076adc217058b555f8
size 57236
oid sha256:22153168f6f3e84ab789faea29c94fffbc1a5e4c1b9a98deccb0460192fdde23
size 57213
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:507ab916afdc3f4d1429ef70c6e19dac8a80713636b064657430c19252494098
size 55558
oid sha256:48b91e52306daa750b4b5792535e1f1865ae3b74a36215376a51dafdd0dcd506
size 54837
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:616deb9421b09e2fa25d6953263fd70a9f687fdfbdf9503bbc35e7a0660ec440
size 58459
oid sha256:1e79e1cc59ba62ca8eb99300eb520b05d7ac6d52c077e3010c03b474865b15f4
size 58374
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b14de263a1f2356b3e84b2a633b0ed837bd160d04c1ec8eeb8c55176856e59ce
size 48755
oid sha256:08fb14008b08d86755c3473e7194ef0a39bb73c3f0d48122ca80c6a019ec98eb
size 48676
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50ef8852ecea57a766f5b074ab4703d753de424c403c9fa778614d7b863e0e98
size 60393
oid sha256:ce795c4e32a1be3d57f6b1c87588d8c7ed0442996ab9d8a96f1ec5a07627e598
size 59663
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e11d2920b56154389cffd1d56b986de2a570bff474d4f4861c710239d27c44e0
size 58442
oid sha256:963fcec9647d0e5e57bd3fbe3edd389c17be7558c1da7e93fa44f34db34307ee
size 57726
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1cb21bd5e7d348d1d07ca4b6a360f26a4735cc9760fdd7e3b4b1bcde32da6f08
size 62655
oid sha256:85f6f4c64f67faa99b9af65307a0cd3b46a68db52503c9c929375408597995d9
size 62094
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f3b1665b2038873c849362b16a04713ef89d17a9b3e44dc828754110e192e33
size 56407
oid sha256:7a3281f35461edb900ea434c68543dc684a5fad7dab7c9aabac510bc59d442b3
size 56479
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:805245ffe7f4e99a0f5927b89bcaa6ab8eaf7cc603d352b14b8109e76eecbdf2
size 51742
oid sha256:19d14e3d779b163053c28e66d3381f0cabe3b540744606e955cd733f9e044089
size 51713
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:674d24421c80412d52bc3fe4559d0518a37ae2cee8e8a1408e2085a809816248
size 59731
oid sha256:262e8d4b8daa2d25593834fd0ab5d7019b0a3dd13707aa96e0701bf9240c6525
size 59809
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f988610da5b4feaa47eb9e5224f9b0d2d47633d9aa8fd45117fff75f5040680
size 58673
oid sha256:51a5b682243b969bc2d7d609decb823b0509d240f3ff14ee78319260af76a47f
size 58740
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a23be24d65190802b26b4e586c80a5cadc6451ea7d351e19c777947daa688d4
size 52352
oid sha256:718d5c9f24c114f3bf88e000b637ea19598d7bb9b97001ba137fc12a6a1285e4
size 52608
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72460dfd88da9b6ebf586e98a819dcc6d377da3515df35a0a2b7247fa376f7d8
size 54987
oid sha256:82e6e38576a507b19e58fce9dda0734ec00bcedcb0baf8f4c7919dbaaf7d2b85
size 55046
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2e99ae9731e9c75fd025db8f24649d6219941ff3aaf0bd4a67bc2c8d1f204cb3
size 53516
oid sha256:478851e9e2e8c80095aa552b98a5d574749200ff49899c9472c913b07459c4b5
size 53758
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:539799354ad5b383c308779b1278e879db4542d9d1c870c13a54a0ae927767fb
size 44098
oid sha256:22ea7fe121a63b35a2c2bb47609e692f8bf2b913871a6baf3d420c04334c6c4a
size 44344
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8538de17063819eb6aa0a614a8f108b122b0597769f253b538775d2b90473276
size 59186
oid sha256:7cf3d727da78b39ecf24d4df7db0884af9008db67cc92274849c1b0978f87726
size 59252
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c7ba8b81a3df9e797ca317a450a7f1bf0a8391aa6b919136827b5ab8e324cdb7
size 57057
oid sha256:bfe848e4c365cf4b4d663145f74e5f8b3f47a4c8994961c530603f0248400316
size 57117
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:86273e812cbc6245527c3d1f138111ece99375aec0d68728882b656b01687bff
size 64392
oid sha256:a966c9a1fa4fdd933f4ef9239592e90458a2819ff4a45c857714d2e38c0df5b2
size 64405