Feature: add jump to unread button with badge count

This commit is contained in:
Jenna Vassar
2026-04-29 15:41:05 -07:00
parent 7080cf77e6
commit 7f5ce76860
10 changed files with 634 additions and 37 deletions
@@ -500,7 +500,10 @@ 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 every banner overlaid above the timeline (pinned messages,
// knock requests). Used to push both the floating date badge and the jump-to-unread
// FAB below any banner that's currently showing.
var topBannersHeightDp by remember { mutableStateOf(0.dp) }
TimelineView(
state = state.timelineState,
@@ -516,28 +519,31 @@ private fun MessagesViewContent(
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
floatingDateTopOffset = topBannersHeightDp,
)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
Column(
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@@ -10,16 +10,19 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
@@ -32,6 +35,8 @@ 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.event.isMessageContent
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
@@ -133,6 +138,7 @@ class TimelinePresenter(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val newMessagesCount = remember { mutableIntStateOf(0) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
@@ -152,6 +158,9 @@ class TimelinePresenter(
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
fun handleEvent(event: TimelineEvent) {
when (event) {
@@ -168,6 +177,7 @@ class TimelinePresenter(
if (isLive) {
if (event.firstIndex == 0) {
newEventState.value = NewEventState.None
newMessagesCount.intValue = 0
}
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded(
@@ -268,7 +278,39 @@ class TimelinePresenter(
}
LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState, newMessagesCount)
}
// Read marker position + unread count, scanned off the main thread. The UI gates display via
// [displayJumpToUnread]; the values are always computed so the state stays up to date if the
// feature flag flips at runtime.
val readMarkerIndex = remember { mutableIntStateOf(-1) }
val unreadMessagesCount = remember { mutableIntStateOf(0) }
LaunchedEffect(timelineItems) {
val items = timelineItems
withContext(dispatchers.computation) {
var markerIdx = -1
var unread = 0
for ((i, item) in items.withIndex()) {
if ((item as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel) {
markerIdx = i
break
}
if (item is TimelineItem.Event &&
!item.isMine &&
item.origin != TimelineItemEventOrigin.PAGINATION &&
item.content.isMessageContent()
) {
unread++
}
}
// Apply both writes atomically so consumers never see a half-updated pair
// (e.g. a non-negative markerIdx with the previous unread count).
Snapshot.withMutableSnapshot {
readMarkerIndex.intValue = markerIdx
unreadMessagesCount.intValue = if (markerIdx < 0) 0 else unread
}
}
}
LaunchedEffect(timelineItems.size, focusRequestState.value) {
@@ -320,6 +362,10 @@ class TimelinePresenter(
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
readMarkerIndex = readMarkerIndex.intValue,
unreadMessagesCount = unreadMessagesCount.intValue,
newMessagesCount = newMessagesCount.intValue,
eventSink = ::handleEvent,
)
}
@@ -377,11 +423,17 @@ class TimelinePresenter(
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
* The state never goes back to None from this method, but need to be reset from somewhere else.
*
* It also maintains [newMessagesCount], counting how many incoming messages from other users have arrived in
* each batch since [prevMostRecentItemId] — this drives the badge on the scroll-to-bottom button. The count is
* reset to zero when the most recent event is from the local user (they're back to active engagement); the
* scroll-finish handler resets it when the user returns to the bottom of the timeline.
*/
private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState>
newEventState: MutableState<NewEventState>,
newMessagesCount: MutableIntState,
) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) {
@@ -406,6 +458,22 @@ class TimelinePresenter(
} else {
NewEventState.FromOther
}
if (fromMe) {
newMessagesCount.intValue = 0
} else {
var delta = 0
for (item in timelineItems) {
if (item.identifier() == prevMostRecentItemIdValue) break
if (item is TimelineItem.Event &&
item.origin != TimelineItemEventOrigin.PAGINATION &&
!item.isMine &&
item.content.isMessageContent()
) {
delta++
}
}
newMessagesCount.intValue += delta
}
}
prevMostRecentItemId.value = newMostRecentItemId
}
@@ -35,6 +35,10 @@ data class TimelineState(
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val displayJumpToUnread: Boolean,
val readMarkerIndex: Int,
val unreadMessagesCount: Int,
val newMessagesCount: Int,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.anAggregatedReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
@@ -57,6 +58,10 @@ fun aTimelineState(
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
displayJumpToUnread: Boolean = true,
readMarkerIndex: Int = -1,
unreadMessagesCount: Int = 0,
newMessagesCount: Int = 0,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -77,10 +82,21 @@ fun aTimelineState(
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
readMarkerIndex = readMarkerIndex,
unreadMessagesCount = unreadMessagesCount,
newMessagesCount = newMessagesCount,
eventSink = eventSink,
)
}
internal fun aTimelineItemReadMarker(): TimelineItem.Virtual {
return TimelineItem.Virtual(
id = UniqueId(UUID.randomUUID().toString()),
model = TimelineItemReadMarkerModel,
)
}
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
return persistentListOf(
// 3 items (First Middle Last) with isMine = false
@@ -14,10 +14,14 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
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.lazy.LazyColumn
@@ -39,15 +43,18 @@ 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.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.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -70,15 +77,18 @@ 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.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.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -106,6 +116,7 @@ fun TimelineView(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
forceJumpToReadMarkerVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) {
@@ -205,9 +216,15 @@ fun TimelineView(
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
newEventState = state.newEventState,
isLive = state.isLive,
focusRequestState = state.focusRequestState,
readMarkerIndex = state.readMarkerIndex,
unreadMessagesCount = state.unreadMessagesCount,
newMessagesCount = state.newMessagesCount,
displayJumpToUnread = state.displayJumpToUnread,
topInset = floatingDateTopOffset,
onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender,
@@ -289,7 +306,13 @@ private fun BoxScope.TimelineScrollHelper(
newEventState: NewEventState,
isLive: Boolean,
forceJumpToBottomVisibility: Boolean,
forceJumpToReadMarkerVisibility: Boolean,
focusRequestState: FocusRequestState,
readMarkerIndex: Int,
unreadMessagesCount: Int,
newMessagesCount: Int,
displayJumpToUnread: Boolean,
topInset: Dp,
onScrollFinishAt: (Int) -> Unit,
onJumpToLive: () -> Unit,
onFocusEventRender: () -> Unit,
@@ -301,6 +324,19 @@ private fun BoxScope.TimelineScrollHelper(
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
val isReadMarkerOffTop by remember {
derivedStateOf {
if (!displayJumpToUnread || readMarkerIndex < 0) {
false
} else if (forceJumpToReadMarkerVisibility) {
true
} else {
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
readMarkerIndex > lastVisibleIndex
}
}
}
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
var jumpToLiveHandled by remember { mutableStateOf(true) }
/**
@@ -327,6 +363,13 @@ private fun BoxScope.TimelineScrollHelper(
}
}
fun jumpToReadMarker() {
if (readMarkerIndex < 0) return
coroutineScope.launch {
lazyListState.animateScrollToItemCenter(readMarkerIndex)
}
}
LaunchedEffect(jumpToLiveHandled, isLive) {
if (!jumpToLiveHandled && isLive) {
lazyListState.scrollToItem(0)
@@ -358,19 +401,41 @@ 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,
TimelineFab(
icon = CompoundIcons.ChevronDown(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
isVisible = isJumpToBottomVisible,
// Hide the badge entirely when the feature is off, regardless of the count value.
count = if (displayJumpToUnread) newMessagesCount else 0,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() },
onClick = ::jumpToBottom,
)
val jumpToUnreadDescription = if (unreadMessagesCount > 0) {
pluralStringResource(CommonPlurals.a11y_jump_to_unread_messages_count, unreadMessagesCount, unreadMessagesCount)
} else {
stringResource(id = CommonStrings.a11y_jump_to_unread_messages)
}
TimelineFab(
icon = CompoundIcons.ChevronUp(),
contentDescription = jumpToUnreadDescription,
isVisible = isReadMarkerOffTop,
count = unreadMessagesCount,
// Top padding includes [topInset] so the FAB sits below any pinned-events banner.
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 24.dp, top = topInset + 12.dp),
onClick = ::jumpToReadMarker,
)
}
@Composable
private fun JumpToBottomButton(
private fun TimelineFab(
icon: ImageVector,
contentDescription: String,
isVisible: Boolean,
count: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -380,25 +445,67 @@ private fun JumpToBottomButton(
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
) {
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(
Box {
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(
modifier = Modifier.size(24.dp),
imageVector = icon,
contentDescription = contentDescription,
)
}
TimelineCountBadge(
count = count,
modifier = Modifier
.size(24.dp)
.rotate(90f),
imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
.align(Alignment.TopEnd)
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
)
}
}
}
/**
* Small accent badge overlaid on a timeline FAB. Shows the count when it's between 1 and 9, otherwise a dot.
* Renders nothing when [count] is zero or negative.
*/
@Composable
private fun TimelineCountBadge(
count: Int,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
if (count <= 9) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp)
.background(color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape)
.border(width = 2.dp, color = ElementTheme.colors.iconOnSolidPrimary, shape = CircleShape)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.toString(),
color = ElementTheme.colors.textOnSolidPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
textAlign = TextAlign.Center,
)
}
} else {
Box(
modifier = modifier
.size(12.dp)
.background(color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape)
.border(width = 2.dp, color = ElementTheme.colors.iconOnSolidPrimary, shape = CircleShape),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewPreview(
@@ -433,3 +540,61 @@ internal fun TimelineViewPreview(
)
}
}
@Composable
private fun TimelineViewWithReadMarker(
unreadMessagesCount: Int,
newMessagesCount: Int,
) {
val readMarker = aTimelineItemReadMarker()
val timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = true),
readMarker,
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
)
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
readMarkerIndex = timelineItems.indexOf(readMarker),
unreadMessagesCount = unreadMessagesCount,
newMessagesCount = newMessagesCount,
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onContentClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerNoBadgesPreview() = ElementPreview {
TimelineViewWithReadMarker(unreadMessagesCount = 0, newMessagesCount = 0)
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerPreview() = ElementPreview {
TimelineViewWithReadMarker(unreadMessagesCount = 3, newMessagesCount = 12)
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerDotBadgesPreview() = ElementPreview {
TimelineViewWithReadMarker(unreadMessagesCount = 47, newMessagesCount = 99)
}
@@ -99,6 +99,28 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
*/
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
/**
* Whether the event content is a user-facing message that should be counted toward unread totals.
* Excludes state events, profile changes, membership changes, redactions, and unknown content.
*/
fun TimelineItemEventContent.isMessageContent(): Boolean = when (this) {
is TimelineItemTextBasedContent,
is TimelineItemAudioContent,
is TimelineItemEncryptedContent,
is TimelineItemFileContent,
is TimelineItemImageContent,
is TimelineItemStickerContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent,
is TimelineItemLegacyCallInviteContent,
is TimelineItemRtcNotificationContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -42,6 +42,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
@@ -58,12 +59,14 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss
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
@@ -365,6 +368,305 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - unreadMessagesCount counts message-content items between newest and read marker, excluding state events`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.readMarkerIndex).isEqualTo(-1)
assertThat(initialState.unreadMessagesCount).isEqualTo(0)
// 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.readMarkerIndex >= 0 }.last().also { state ->
// 2 message items above the marker; the membership state event is skipped.
assertThat(state.readMarkerIndex).isEqualTo(3)
assertThat(state.unreadMessagesCount).isEqualTo(2)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - unreadMessagesCount excludes own messages and PAGINATION-origin events`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
awaitFirstItem()
// After processing (factory reverses): [other-newest, own-msg, paginated, 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("paginated"),
anEventTimelineItem(content = aMessageContent()).copy(origin = TimelineItemEventOrigin.PAGINATION),
),
MatrixTimelineItem.Event(UniqueId("own-msg"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
MatrixTimelineItem.Event(UniqueId("other-newest"), anEventTimelineItem(content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.readMarkerIndex >= 0 }.last().also { state ->
assertThat(state.readMarkerIndex).isEqualTo(3)
// Only `other-newest` counts: own-msg and paginated are filtered out.
assertThat(state.unreadMessagesCount).isEqualTo(1)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - readMarkerIndex is -1 when no read marker present`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.readMarkerIndex).isEqualTo(-1)
assertThat(initialState.unreadMessagesCount).isEqualTo(0)
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
assertThat(state.readMarkerIndex).isEqualTo(-1)
assertThat(state.unreadMessagesCount).isEqualTo(0)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount increments by N when N events from others arrive in one batch`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newMessagesCount).isEqualTo(0)
// Seed prevMostRecentItemId so subsequent emissions count as new events.
timelineItems.emit(
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Three new events from another user arrive in a single batch.
timelineItems.getAndUpdate { items ->
items + listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("3"), anEventTimelineItem(content = aMessageContent())),
)
}
consumeItemsUntilPredicate { it.newMessagesCount == 3 }.last().also { state ->
assertThat(state.newMessagesCount).isEqualTo(3)
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount resets to 0 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())))
}
val countedState = consumeItemsUntilPredicate { it.newMessagesCount == 1 }.last()
assertThat(countedState.newMessagesCount).isEqualTo(1)
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
consumeItemsUntilPredicate { it.newMessagesCount == 0 }.last().also { state ->
assertThat(state.newMessagesCount).isEqualTo(0)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount resets to 0 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 increments the count.
timelineItems.getAndUpdate { items ->
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
}
consumeItemsUntilPredicate { it.newMessagesCount == 1 }
// Then the local user sends a message: count should reset.
timelineItems.getAndUpdate { items ->
items + listOf(
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
)
}
consumeItemsUntilPredicate {
it.newEventState == NewEventState.FromMe && it.newMessagesCount == 0
}.last().also { state ->
assertThat(state.newMessagesCount).isEqualTo(0)
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount 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.newMessagesCount == 1 }
// Scrolling stops above the bottom: the count must NOT reset.
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
advanceUntilIdle()
// No state should emit with newMessagesCount == 0.
val drained = consumeItemsUntilTimeout()
assertThat(drained.any { it.newMessagesCount == 0 }).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount does not increment 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 bump the badge.
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.newMessagesCount).isEqualTo(0)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount does not increment for state events`() = 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 membership change arrives. It should not bump the badge.
timelineItems.getAndUpdate { items ->
items + listOf(
MatrixTimelineItem.Event(UniqueId("membership"), anEventTimelineItem(content = aRoomMembershipContent())),
)
}
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
assertThat(state.newMessagesCount).isEqualTo(0)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - readMarkerIndex is 0 when the read marker is the only item`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
awaitFirstItem()
timelineItems.emit(
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
)
consumeItemsUntilPredicate { it.readMarkerIndex >= 0 }.last().also { state ->
assertThat(state.readMarkerIndex).isEqualTo(0)
assertThat(state.unreadMessagesCount).isEqualTo(0)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - newMessagesCount accumulates across multiple batches as prevMostRecentItemId advances`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
presenter.test {
awaitFirstItem()
// Seed prevMostRecentItemId so subsequent emissions count as new events.
timelineItems.emit(
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Batch 1: 1 new event → count = 1.
timelineItems.getAndUpdate { items ->
items + listOf(MatrixTimelineItem.Event(UniqueId("b1-1"), anEventTimelineItem(content = aMessageContent())))
}
consumeItemsUntilPredicate { it.newMessagesCount == 1 }
// Batch 2: 2 more new events → count = 3.
timelineItems.getAndUpdate { items ->
items + listOf(
MatrixTimelineItem.Event(UniqueId("b2-1"), anEventTimelineItem(content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("b2-2"), anEventTimelineItem(content = aMessageContent())),
)
}
consumeItemsUntilPredicate { it.newMessagesCount == 3 }
// Batch 3: 1 more new event → count = 4.
timelineItems.getAndUpdate { items ->
items + listOf(MatrixTimelineItem.Event(UniqueId("b3-1"), anEventTimelineItem(content = aMessageContent())))
}
consumeItemsUntilPredicate { it.newMessagesCount == 4 }.last().also { state ->
assertThat(state.newMessagesCount).isEqualTo(4)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - reaction ordering`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
@@ -136,6 +136,13 @@ enum class FeatureFlags(
defaultValue = { false },
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",
@@ -7,4 +7,9 @@
<resources>
<string name="common_black">"Black"</string>
<string name="a11y_jump_to_unread_messages">"Jump to unread messages"</string>
<plurals name="a11y_jump_to_unread_messages_count">
<item quantity="one">"Jump to %1$d unread message"</item>
<item quantity="other">"Jump to %1$d unread messages"</item>
</plurals>
</resources>
@@ -160,6 +160,8 @@ class KonsistPreviewTest {
"TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview",
"TimelineViewWithReadMarkerDotBadgesPreview",
"TimelineViewWithReadMarkerNoBadgesPreview",
"UserAvatarColorsPreview",
"UserProfileHeaderSectionWithVerificationViolationPreview",
"VoiceItemViewPlayPreview",