From b340e85f83fb3cd0465f7495d15715999ffa1988 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 1 Apr 2026 12:45:57 +0200 Subject: [PATCH] Add floating/sticky date badge in the timeline (#6496) * Add floating date indicator while scrolling the timeline (#6433) * Add `FeatureFlags.FloatingDateBadge`. This enables displaying the floating date badge in the timeline as you scroll. * Don't display the floating badge if the timeline isn't reversed. Otherwise, this will affect talkback users and break the existing navigation * Use `TimelineItem.formattedDate()` to get the date to display. Always try finding the closest one (usually it will be just the 1st one we try). * Align designs with iOS. Also fix shadows in fade animation by adding some paddings. * Update screenshots --------- Co-authored-by: Gianluca Iavicoli Co-authored-by: ElementBot --- .../features/messages/impl/MessagesView.kt | 6 + .../impl/timeline/TimelinePresenter.kt | 4 + .../messages/impl/timeline/TimelineState.kt | 1 + .../impl/timeline/TimelineStateProvider.kt | 2 + .../messages/impl/timeline/TimelineView.kt | 12 ++ .../timeline/components/FloatingDateBadge.kt | 144 ++++++++++++++++++ .../event/TimelineItemEventFactory.kt | 6 + .../impl/timeline/model/TimelineItem.kt | 8 + .../designsystem/theme/ColorAliases.kt | 3 + .../libraries/featureflag/api/FeatureFlags.kt | 9 +- ....components_FloatingDateBadge_Day_0_en.png | 3 + ...omponents_FloatingDateBadge_Night_0_en.png | 3 + 12 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/FloatingDateBadge.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Night_0_en.png 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 0caebea8d5..8e81ee74a7 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -464,6 +465,9 @@ private fun MessagesViewContent( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, ) + val density = LocalDensity.current + var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) } + TimelineView( state = state.timelineState, timelineProtectionState = state.timelineProtectionState, @@ -479,11 +483,13 @@ private fun MessagesViewContent( forceJumpToBottomVisibility = forceJumpToBottomVisibility, onJoinCallClick = onJoinCallClick, nestedScrollConnection = scrollBehavior.nestedScrollConnection, + floatingDateTopOffset = pinnedBannerHeightDp, ) 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(), ) { 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 12e4e0b1d1..8a7011552e 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 @@ -149,6 +149,9 @@ class TimelinePresenter( val displayThreadSummaries by produceState(false) { value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) } + val displayFloatingDateBadge by produceState(false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge) + } fun handleEvent(event: TimelineEvent) { when (event) { @@ -315,6 +318,7 @@ class TimelinePresenter( messageShieldDialogData = messageShieldDialogData.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, displayThreadSummaries = displayThreadSummaries, + displayFloatingDateBadge = displayFloatingDateBadge, eventSink = ::handleEvent, ) } 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 03f0083856..1869ad6906 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 @@ -34,6 +34,7 @@ data class TimelineState( val messageShieldDialogData: MessageShieldData?, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val displayThreadSummaries: Boolean, + val displayFloatingDateBadge: Boolean, 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 184acf1386..9840ac5107 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 @@ -56,6 +56,7 @@ fun aTimelineState( messageShield: MessageShield? = null, resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), displayThreadSummaries: Boolean = false, + displayFloatingDateBadge: Boolean = false, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId @@ -75,6 +76,7 @@ fun aTimelineState( messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, displayThreadSummaries = displayThreadSummaries, + displayFloatingDateBadge = displayFloatingDateBadge, eventSink = eventSink, ) } 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 08a7191f3f..0137b1736d 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 @@ -47,10 +47,12 @@ 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.dp 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 +import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories @@ -105,6 +107,7 @@ fun TimelineView( lazyListState: LazyListState = rememberLazyListState(), forceJumpToBottomVisibility: Boolean = false, nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), + floatingDateTopOffset: Dp = 0.dp, ) { fun clearFocusRequestState() { state.eventSink(TimelineEvent.ClearFocusRequestState) @@ -210,6 +213,15 @@ fun TimelineView( onJumpToLive = ::onJumpToLive, onFocusEventRender = ::onFocusEventRender, ) + + if (state.displayFloatingDateBadge && useReverseLayout) { + FloatingDateBadgeOverlay( + lazyListState = lazyListState, + timelineItems = state.timelineItems, + isLive = state.isLive, + topOffset = floatingDateTopOffset, + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/FloatingDateBadge.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/FloatingDateBadge.kt new file mode 100644 index 0000000000..996bb07b81 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/FloatingDateBadge.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 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. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun BoxScope.FloatingDateBadgeOverlay( + lazyListState: LazyListState, + timelineItems: ImmutableList, + isLive: Boolean, + topOffset: Dp = 0.dp, +) { + // This needs to be a state to trigger a `derivedState` recalculation + val updatedTimelineItems by rememberUpdatedState(timelineItems) + + // Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list + val lastVisibleItemWithTimestamp by remember { + derivedStateOf { + var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null + while (index >= 0) { + when (val item = updatedTimelineItems.getOrNull(index)) { + is TimelineItem.Event -> return@derivedStateOf item + is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item + is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull() + null -> Unit + } + index-- + } + null + } + } + + // Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed + var formattedDate: String? by remember { mutableStateOf(null) } + // Update the formatted date when we have a new non-null timestamp + LaunchedEffect(lastVisibleItemWithTimestamp) { + lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it } + } + + val isAtBottom by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex < 3 && isLive + } + } + + var isBadgeVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.isScrollInProgress } + .collectLatest { isScrolling -> + if (isScrolling) { + isBadgeVisible = true + } else { + delay(2000.milliseconds) + isBadgeVisible = false + } + } + } + + val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null + + AnimatedVisibility( + visible = showBadge, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp + topOffset), + enter = fadeIn(animationSpec = tween(150)), + exit = fadeOut(animationSpec = tween(300)), + ) { + formattedDate?.let { dateText -> + FloatingDateBadge( + modifier = Modifier.padding(8.dp), + dateText = dateText, + ) + } + } +} + +@Composable +internal fun FloatingDateBadge( + dateText: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = ElementTheme.colors.floatingDateBadgeBackground, + shadowElevation = 4.dp, + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = dateText, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun FloatingDateBadgePreview() = ElementPreview { + Box(modifier = Modifier.padding(16.dp)) { + FloatingDateBadge(dateText = "March 9, 2026") + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 366c88157e..cf515a0b51 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -66,6 +66,11 @@ class TimelineItemEventFactory( timestamp = currentTimelineItem.event.timestamp, mode = DateFormatterMode.TimeOnly, ) + val sentDate = dateFormatter.format( + timestamp = currentTimelineItem.event.timestamp, + mode = DateFormatterMode.Day, + useRelative = true, + ) val senderAvatarData = AvatarData( id = currentSender.value, name = senderProfile.getDisambiguatedDisplayName(currentSender), @@ -108,6 +113,7 @@ class TimelineItemEventFactory( canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, sentTimeMillis = currentTimelineItem.event.timestamp, sentTime = sentTime, + sentDate = sentDate, groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState(), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index e169b10403..c9adba21da 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.EventId @@ -59,6 +60,12 @@ sealed interface TimelineItem { is GroupedEvents -> "groupedEvent" } + fun formattedDate(): String? = when (this) { + is Event -> sentDate.takeIf { it.isNotEmpty() } + is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() } + is GroupedEvents -> null + } + data class Virtual( val id: UniqueId, val model: TimelineItemVirtualModel @@ -75,6 +82,7 @@ sealed interface TimelineItem { val content: TimelineItemEventContent, val sentTimeMillis: Long = 0L, val sentTime: String = "", + val sentDate: String = "", val isMine: Boolean = false, val isEditable: Boolean, val canBeRepliedTo: Boolean, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt index 06827fb218..8973e312ce 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator val SemanticColors.pinnedMessageBannerBorder get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400 +val SemanticColors.floatingDateBadgeBackground + get() = if (isLight) bgCanvasDefault else bgSubtlePrimary + @PreviewsDayNight @Composable internal fun ColorAliasesPreview() = ElementPreview { 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 6cd9dec60c..9fe21a10c3 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 @@ -156,10 +156,17 @@ enum class FeatureFlags( ), ValidateNetworkWhenSchedulingNotificationFetching( key = "feature.validate_network_when_scheduling_notification_fetching", - title = "validate internet connectivity when scheduling notification fetching", + title = "Validate internet connectivity when scheduling notification fetching", description = "Only fetch events for push notifications when the device has internet connectivity. " + "Enabling this can be problematic in air-gapped environments.", defaultValue = { true }, isFinished = false, ), + FloatingDateBadge( + key = "feature.floating_date_badge", + title = "Display sticky date headers in the timeline", + description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Day_0_en.png new file mode 100644 index 0000000000..73693b6b51 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8109af7397a43f24a27b7c988f7b6bd23e555df038ff9a272068707796a60962 +size 8465 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Night_0_en.png new file mode 100644 index 0000000000..042d482550 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FloatingDateBadge_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25ba3336d226da0fb5956750b02f389c59b81c93e6150d8fb17d5195fd161204 +size 7705