Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7f5ce76
Feature: add jump to unread button with badge count
jennaharris7 Apr 29, 2026
9f26079
Add missing preview: both fabs have counts
jennaharris7 Apr 29, 2026
2579e68
Don't show NEW timeline divider when not applicable in previews
jennaharris7 Apr 30, 2026
ef7cb35
Quality checks/fixes
jennaharris7 Apr 30, 2026
af9da10
Merge branch 'element-hq:develop' into feature/scroll-to-unread-messages
jennaharris7 May 1, 2026
78aa500
Fold newMessagesCount into NewEventState.FromOther
jennaharris7 May 1, 2026
7148f2f
Semantic fixes: clearer naming conventions, when statements
jennaharris7 May 1, 2026
200b98b
Consolidate JumpToUnreadState
jennaharris7 May 1, 2026
51816e0
Move jumpToUnreadButton to bottom, remove badge count
jennaharris7 May 5, 2026
aed1497
Add mark as read buttons
jennaharris7 May 6, 2026
880a189
Code cleanup, accessibility checks
jennaharris7 May 6, 2026
784f5df
Mark as read menu ui tweaks
jennaharris7 May 6, 2026
0daf7ec
Konsist fixes
jennaharris7 May 6, 2026
4b47963
Keep jump to unread button position stationary
jennaharris7 May 6, 2026
e0046e9
Return null instead of return@launch
jennaharris7 May 11, 2026
65ed527
Add comment to explain jump to bottom button containing box
jennaharris7 May 11, 2026
8d8ac93
Rename preview to reflect actual state
jennaharris7 May 11, 2026
257040f
Merge remote-tracking branch 'upstream/develop' into feature/scroll-t…
jennaharris7 May 12, 2026
bdb4275
Show jump-to-unread when read marker is outside the loaded window
jennaharris7 May 21, 2026
0399dab
Dismiss mark as unread button eagerly
jennaharris7 May 21, 2026
d653ceb
Merge remote-tracking branch 'upstream/develop' into feature/scroll-t…
jennaharris7 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ private fun RoomListModalBottomSheetContent(
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read),
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class RoomListContextMenuTest {
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
clickOn(R.string.screen_roomlist_mark_as_read)
clickOn(CommonStrings.action_mark_as_read)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,9 @@ private fun MessagesViewContent(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
)
val density = LocalDensity.current
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
// Combined height of the banners overlaid above the timeline. Drives the floating
// date badge offset so the badge sits below whichever banners are currently showing.
var topBannersHeightDp by remember { mutableStateOf(0.dp) }

TimelineView(
state = state.timelineState,
Expand All @@ -517,14 +519,15 @@ private fun MessagesViewContent(
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
floatingDateTopOffset = topBannersHeightDp,
)

if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
Column {
Column(
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ sealed interface TimelineEvent {

data object HideShieldDialog : TimelineEvent

data object MarkAllAsRead : TimelineEvent

/**
* Events coming from a timeline item.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
Expand Down Expand Up @@ -95,6 +96,7 @@ class TimelinePresenter(
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"

Expand Down Expand Up @@ -133,9 +135,15 @@ class TimelinePresenter(

val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }

val newEventState = remember { mutableStateOf(NewEventState.None) }
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }

// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
// the SDK to push a refreshed fully-read marker; the after-await ordering means any
// RoomInfo update racing the mark-as-read call has already landed and can't undo this.
val suppressJumpToUnread = remember { mutableStateOf(false) }

val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by remember {
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
Expand All @@ -153,6 +161,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) {
Expand Down Expand Up @@ -223,6 +234,14 @@ class TimelinePresenter(
timelineController.focusOnLive()
}
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.tag(tag).w(it, "Failed to get latest event id to mark as fully read")
null
} ?: return@launch
markAsFullyRead(room.roomId, latestEventId)
suppressJumpToUnread.value = true
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
Expand Down Expand Up @@ -274,6 +293,45 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}

// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
// bottom of the room.
//
// The state has three shapes:
// - InWindow: the SDK has materialised a virtual ReadMarker item in the loaded window;
// tapping the FAB smoothly scrolls to its index.
// - OutOfWindow: the marker event is older than the loaded window, so the SDK gives us
// only the event id via RoomInfo.fullyReadEventId; tapping triggers a focused-event
// load via the existing TimelineEvent.FocusOnEvent path.
// - Hidden: feature flag off, no marker, caught-up (marker loaded but no virtual item),
// or initial load (no items yet).
val jumpToUnread = remember { mutableStateOf<JumpToUnreadState>(JumpToUnreadState.Hidden) }
// The SDK is authoritative again once it pushes a new fully-read marker, so drop the
// post-mark-as-read suppression and let the recompute below pick up the new value.
LaunchedEffect(roomInfo.fullyReadEventId) {
suppressJumpToUnread.value = false
}
LaunchedEffect(timelineItems, displayJumpToUnread, roomInfo.fullyReadEventId, suppressJumpToUnread.value) {
if (!displayJumpToUnread || suppressJumpToUnread.value) {
jumpToUnread.value = JumpToUnreadState.Hidden
return@LaunchedEffect
}
val items = timelineItems
val fullyReadEventId = roomInfo.fullyReadEventId
jumpToUnread.value = withContext(dispatchers.computation) {
val markerIndex = items.indexOfFirst {
(it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel
}
when {
markerIndex >= 0 -> JumpToUnreadState.InWindow(markerIndex)
fullyReadEventId != null && items.isNotEmpty() && !timelineItemIndexer.isKnown(fullyReadEventId) ->
JumpToUnreadState.OutOfWindow(fullyReadEventId)
else -> JumpToUnreadState.Hidden
}
}
}

LaunchedEffect(timelineItems.size, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
Expand Down Expand Up @@ -323,6 +381,8 @@ class TimelinePresenter(
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent,
)
}
Expand Down Expand Up @@ -384,7 +444,7 @@ class TimelinePresenter(
private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState>
newEventState: MutableState<NewEventState>,
) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) {
Expand All @@ -403,12 +463,7 @@ class TimelinePresenter(

if (hasNewEvent) {
// Scroll to bottom if the new event is from me, even if sent from another device
val fromMe = newMostRecentItem.isMine
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
NewEventState.FromOther
}
newEventState.value = if (newMostRecentItem.isMine) NewEventState.FromMe else NewEventState.FromOther
}
prevMostRecentItemId.value = newMostRecentItemId
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ data class TimelineState(
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
Expand Down Expand Up @@ -83,3 +85,18 @@ data class TimelineRoomInfo(
val typingNotificationState: TypingNotificationState,
val predecessorRoom: PredecessorRoom?,
)

/**
* Whether the jump-to-unread FAB should be shown, and if so, how tapping it
* should bring the user to the read marker.
*/
@Immutable
sealed interface JumpToUnreadState {
data object Hidden : JumpToUnreadState

/** The read marker is materialised at [index] in the loaded timeline — smooth scroll to it. */
data class InWindow(val index: Int) : JumpToUnreadState

/** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ fun aTimelineState(
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
Expand All @@ -72,13 +75,15 @@ fun aTimelineState(
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = NewEventState.None,
newEventState = newEventState,
isLive = isLive,
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
eventSink = eventSink,
)
}
Expand Down
Loading
Loading