From 7f5ce768600b65eb54213435e5f954962537ae23 Mon Sep 17 00:00:00 2001 From: Jenna Vassar <5023996+jennaharris7@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:41:05 -0700 Subject: [PATCH] Feature: add jump to unread button with badge count --- .../features/messages/impl/MessagesView.kt | 38 ++- .../impl/timeline/TimelinePresenter.kt | 72 ++++- .../messages/impl/timeline/TimelineState.kt | 4 + .../impl/timeline/TimelineStateProvider.kt | 16 + .../messages/impl/timeline/TimelineView.kt | 203 ++++++++++-- .../model/event/TimelineItemEventContent.kt | 22 ++ .../impl/timeline/TimelinePresenterTest.kt | 302 ++++++++++++++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 + .../src/main/res/values/temporary.xml | 5 + .../tests/konsist/KonsistPreviewTest.kt | 2 + 10 files changed, 634 insertions(+), 37 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5a0b14b820..4ffbaed4dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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() } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index edd8d446dc..82f7717a8d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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(null) } val newEventState = remember { mutableStateOf(NewEventState.None) } + val newMessagesCount = remember { mutableIntStateOf(0) } val messageShieldDialogData: MutableState = 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, prevMostRecentItemId: MutableState, - newEventState: MutableState + newEventState: MutableState, + 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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 1869ad6906..ccd4d44ea8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 9840ac5107..e30eb1be97 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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().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 { return persistentListOf( // 3 items (First Middle Last) with isMine = false diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 2105cf9df7..8c7d890d2f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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( + 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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 9c4c48d11e..fe2c264932 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 194694714b..e4ddbf6c82 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 15e61f4260..60e164df0f 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -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", diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index ba6c431d8b..26c83aaa53 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -7,4 +7,9 @@ "Black" + "Jump to unread messages" + + "Jump to %1$d unread message" + "Jump to %1$d unread messages" + diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 433237cd13..8817561e8f 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -160,6 +160,8 @@ class KonsistPreviewTest { "TimelineItemVoiceViewUnifiedPreview", "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", + "TimelineViewWithReadMarkerDotBadgesPreview", + "TimelineViewWithReadMarkerNoBadgesPreview", "UserAvatarColorsPreview", "UserProfileHeaderSectionWithVerificationViolationPreview", "VoiceItemViewPlayPreview",