Show jump-to-unread when read marker is outside the loaded window

When the m.fully_read marker event is older than the loaded timeline
window, no virtual ReadMarker item is inserted and the chevron-up FAB
silently no-ops. Surfaces the SDK's new fully_read_event_id on RoomInfo
and routes the tap through the existing FocusOnEvent flow so the FAB
appears in catch-up scenarios and lands the user on the marker.

Bumps matrix-rust-components-kotlin to 26.05.20 to pick up the FFI
field; absorbs the ClientBuildException.InvalidRawKey and suspend
SpaceRoomList.rooms / subscribeToRoomUpdate API breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-05-21 07:11:53 -07:00
parent 257040f5ca
commit bdb42759de
15 changed files with 254 additions and 39 deletions
@@ -291,20 +291,32 @@ class TimelinePresenter(
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
// bottom of the room. // bottom of the room.
// //
// Limitation: when the read marker is outside the loaded window (gaps, pagination), this // The state has three shapes:
// returns null and the jump-to-unread button stays hidden. Proper fix needs an SDK // - InWindow: the SDK has materialised a virtual ReadMarker item in the loaded window;
// accessor for the m.fully_read marker plus FocusedOnEvent navigation on click; gated // tapping the FAB smoothly scrolls to its index.
// behind FeatureFlags.JumpToUnread until that lands. // - OutOfWindow: the marker event is older than the loaded window, so the SDK gives us
val readMarkerIndex = remember { mutableStateOf<Int?>(null) } // only the event id via RoomInfo.fullyReadEventId; tapping triggers a focused-event
LaunchedEffect(timelineItems, displayJumpToUnread) { // 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) }
LaunchedEffect(timelineItems, displayJumpToUnread, roomInfo.fullyReadEventId) {
if (!displayJumpToUnread) { if (!displayJumpToUnread) {
readMarkerIndex.value = null jumpToUnread.value = JumpToUnreadState.Hidden
return@LaunchedEffect return@LaunchedEffect
} }
val items = timelineItems val items = timelineItems
readMarkerIndex.value = withContext(dispatchers.computation) { val fullyReadEventId = roomInfo.fullyReadEventId
items.indexOfFirst { (it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel } jumpToUnread.value = withContext(dispatchers.computation) {
.takeIf { it >= 0 } 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
}
} }
} }
@@ -358,7 +370,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge, displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
readMarkerIndex = readMarkerIndex.value, jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -36,7 +36,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean, val displayFloatingDateBadge: Boolean,
val displayJumpToUnread: Boolean, val displayJumpToUnread: Boolean,
val readMarkerIndex: Int?, val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -85,3 +85,18 @@ data class TimelineRoomInfo(
val typingNotificationState: TypingNotificationState, val typingNotificationState: TypingNotificationState,
val predecessorRoom: PredecessorRoom?, 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
}
@@ -60,7 +60,7 @@ fun aTimelineState(
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false, displayFloatingDateBadge: Boolean = false,
displayJumpToUnread: Boolean = false, displayJumpToUnread: Boolean = false,
readMarkerIndex: Int? = null, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None, newEventState: NewEventState = NewEventState.None,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
@@ -83,7 +83,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge, displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
readMarkerIndex = readMarkerIndex, jumpToUnread = jumpToUnread,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -155,6 +155,10 @@ fun TimelineView(
state.eventSink(TimelineEvent.MarkAllAsRead) state.eventSink(TimelineEvent.MarkAllAsRead)
} }
fun onFocusOnEvent(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
val context = LocalContext.current val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
@@ -240,11 +244,12 @@ fun TimelineView(
isLive = state.isLive, isLive = state.isLive,
focusRequestState = state.focusRequestState, focusRequestState = state.focusRequestState,
displayJumpToUnread = state.displayJumpToUnread, displayJumpToUnread = state.displayJumpToUnread,
readMarkerIndex = state.readMarkerIndex, jumpToUnread = state.jumpToUnread,
onScrollFinishAt = ::onScrollFinishAt, onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive, onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender, onFocusEventRender = ::onFocusEventRender,
onMarkAllAsRead = ::onMarkAllAsRead, onMarkAllAsRead = ::onMarkAllAsRead,
onFocusOnEvent = ::onFocusOnEvent,
) )
if (state.displayFloatingDateBadge && useReverseLayout) { if (state.displayFloatingDateBadge && useReverseLayout) {
@@ -326,11 +331,12 @@ private fun BoxScope.TimelineScrollHelper(
forceJumpToReadMarkerVisibility: Boolean, forceJumpToReadMarkerVisibility: Boolean,
focusRequestState: FocusRequestState, focusRequestState: FocusRequestState,
displayJumpToUnread: Boolean, displayJumpToUnread: Boolean,
readMarkerIndex: Int?, jumpToUnread: JumpToUnreadState,
onScrollFinishAt: (Int) -> Unit, onScrollFinishAt: (Int) -> Unit,
onJumpToLive: () -> Unit, onJumpToLive: () -> Unit,
onFocusEventRender: () -> Unit, onFocusEventRender: () -> Unit,
onMarkAllAsRead: () -> Unit, onMarkAllAsRead: () -> Unit,
onFocusOnEvent: (EventId) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
@@ -339,12 +345,19 @@ private fun BoxScope.TimelineScrollHelper(
lazyListState.firstVisibleItemIndex < 3 && isLive lazyListState.firstVisibleItemIndex < 3 && isLive
} }
} }
val isJumpToUnreadVisible by remember(readMarkerIndex, forceJumpToReadMarkerVisibility) { val isJumpToUnreadVisible by remember(jumpToUnread, forceJumpToReadMarkerVisibility) {
derivedStateOf { derivedStateOf {
if (forceJumpToReadMarkerVisibility) return@derivedStateOf true if (forceJumpToReadMarkerVisibility) return@derivedStateOf true
val markerIndex = readMarkerIndex ?: return@derivedStateOf false when (val jtu = jumpToUnread) {
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false JumpToUnreadState.Hidden -> false
markerIndex > lastVisibleIndex // Marker is outside the loaded window — we have no on-screen anchor, so just show.
is JumpToUnreadState.OutOfWindow -> true
// Marker is in the loaded window — hide once it's scrolled into the visible range.
is JumpToUnreadState.InWindow -> {
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
jtu.index > lastVisibleIndex
}
}
} }
} }
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
@@ -375,9 +388,12 @@ private fun BoxScope.TimelineScrollHelper(
} }
fun jumpToReadMarker() { fun jumpToReadMarker() {
val markerIndex = readMarkerIndex ?: return when (val jtu = jumpToUnread) {
coroutineScope.launch { JumpToUnreadState.Hidden -> Unit
lazyListState.animateScrollToItemCenter(markerIndex) is JumpToUnreadState.InWindow -> coroutineScope.launch {
lazyListState.animateScrollToItemCenter(jtu.index)
}
is JumpToUnreadState.OutOfWindow -> onFocusOnEvent(jtu.eventId)
} }
} }
@@ -630,7 +646,7 @@ private fun TimelineViewWithReadMarker(
// Index points past the loaded items, mirroring the real-world state the FAB // Index points past the loaded items, mirroring the real-world state the FAB
// represents: the user has scrolled past the read marker, so it's no longer in // represents: the user has scrolled past the read marker, so it's no longer in
// view. The actual scroll target doesn't matter for a static preview. // view. The actual scroll target doesn't matter for a static preview.
readMarkerIndex = if (hasUnreadAbove) timelineItems.size else null, jumpToUnread = if (hasUnreadAbove) JumpToUnreadState.InWindow(timelineItems.size) else JumpToUnreadState.Hidden,
newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None, newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None,
), ),
timelineProtectionState = aTimelineProtectionState(), timelineProtectionState = aTimelineProtectionState(),
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@@ -371,7 +372,7 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - readMarkerIndex points at the read marker virtual item`() = runTest { fun `present - jumpToUnread is InWindow at the read marker virtual item index`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>()) val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems) val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@@ -391,8 +392,8 @@ class TimelinePresenterTest {
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())), MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
) )
) )
consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state -> consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
assertThat(state.readMarkerIndex).isEqualTo(3) assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 3))
assertThat(state.displayJumpToUnread).isTrue() assertThat(state.displayJumpToUnread).isTrue()
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -400,7 +401,7 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - readMarkerIndex is null when no read marker present`() = runTest { fun `present - jumpToUnread is Hidden when no read marker and no fullyReadEventId`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>()) val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems) val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@@ -416,16 +417,16 @@ class TimelinePresenterTest {
) )
) )
consumeItemsUntilPredicate { consumeItemsUntilPredicate {
it.timelineItems.size == 2 && it.readMarkerIndex == null it.timelineItems.size == 2 && it.jumpToUnread == JumpToUnreadState.Hidden
}.last().also { state -> }.last().also { state ->
assertThat(state.readMarkerIndex).isNull() assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - readMarkerIndex stays null when JumpToUnread feature flag is disabled`() = runTest { fun `present - jumpToUnread is Hidden when JumpToUnread feature flag is disabled`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>()) val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems) val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@@ -443,7 +444,7 @@ class TimelinePresenterTest {
) )
consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state -> consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state ->
assertThat(state.displayJumpToUnread).isFalse() assertThat(state.displayJumpToUnread).isFalse()
assertThat(state.readMarkerIndex).isNull() assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@@ -579,7 +580,7 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - readMarkerIndex is 0 when the read marker is the only item`() = runTest { fun `present - jumpToUnread is InWindow at index 0 when the read marker is the only item`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>()) val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems) val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@@ -591,8 +592,163 @@ class TimelinePresenterTest {
timelineItems.emit( timelineItems.emit(
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker)) listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
) )
consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state -> consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
assertThat(state.readMarkerIndex).isEqualTo(0) assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 0))
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread is OutOfWindow when fullyReadEventId is set but not in the loaded window`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val fullyReadEventId = EventId("\$older-than-loaded-window")
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
)
presenter.test {
awaitFirstItem()
// Loaded items don't include the fullyReadEventId and the SDK didn't materialise a ReadMarker.
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.OutOfWindow }.last().also { state ->
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.OutOfWindow(eventId = fullyReadEventId))
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread is Hidden when fullyReadEventId IS in the loaded window`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
// The user is caught up: the marker event is loaded, but the SDK didn't insert a
// virtual ReadMarker because there are no items newer than it.
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
)
presenter.test {
awaitFirstItem()
// A loaded item has eventId == AN_EVENT_ID (default of anEventTimelineItem).
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the timeline is empty`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
)
presenter.test {
val initialState = awaitFirstItem()
// Without any timeline items, the FAB must stay hidden — the user is mid-load.
assertThat(initialState.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
advanceUntilIdle()
val drained = consumeItemsUntilTimeout()
assertThat(drained.any { it.jumpToUnread != JumpToUnreadState.Hidden }).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the feature flag is off`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
)
presenter.test {
awaitFirstItem()
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - jumpToUnread prefers InWindow when both a virtual marker and fullyReadEventId are present`() = runTest {
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
val timeline = FakeTimeline(timelineItems = timelineItems)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
),
)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
)
presenter.test {
awaitFirstItem()
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
)
)
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
assertThat(state.jumpToUnread).isInstanceOf(JumpToUnreadState.InWindow::class.java)
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@@ -131,6 +131,7 @@ private fun aSpaceInfo(
numUnreadMessages = 0, numUnreadMessages = 0,
numUnreadNotifications = 0, numUnreadNotifications = 0,
numUnreadMentions = 0, numUnreadMentions = 0,
fullyReadEventId = null,
heroes = persistentListOf(), heroes = persistentListOf(),
pinnedEventIds = persistentListOf(), pinnedEventIds = persistentListOf(),
creators = persistentListOf(), creators = persistentListOf(),
+1 -1
View File
@@ -178,7 +178,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should # All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs. # only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.05.7" matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.05.20"
# Others # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -70,6 +70,11 @@ data class RoomInfo(
* notification settings. * notification settings.
*/ */
val numUnreadMentions: Long, val numUnreadMentions: Long,
/**
* Event ID of the user's `m.fully_read` marker for this room, if any.
* Can be set even when the event is older than the loaded timeline window.
*/
val fullyReadEventId: EventId?,
val heroes: ImmutableList<MatrixUser>, val heroes: ImmutableList<MatrixUser>,
val pinnedEventIds: ImmutableList<EventId>, val pinnedEventIds: ImmutableList<EventId>,
val creators: ImmutableList<UserId>, val creators: ImmutableList<UserId>,
@@ -28,6 +28,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
} }
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
} }
is OAuthException -> when (this) { is OAuthException -> when (this) {
is OAuthException.Generic -> AuthenticationException.OAuth(message) is OAuthException.Generic -> AuthenticationException.OAuth(message)
@@ -71,6 +71,7 @@ class RoomInfoMapper {
numUnreadMessages = it.numUnreadMessages.toLong(), numUnreadMessages = it.numUnreadMessages.toLong(),
numUnreadMentions = it.numUnreadMentions.toLong(), numUnreadMentions = it.numUnreadMentions.toLong(),
numUnreadNotifications = it.numUnreadNotifications.toLong(), numUnreadNotifications = it.numUnreadNotifications.toLong(),
fullyReadEventId = it.fullyReadEventId?.let(::EventId),
historyVisibility = it.historyVisibility.map(), historyVisibility = it.historyVisibility.map(),
successorRoom = it.successorRoom?.map(), successorRoom = it.successorRoom?.map(),
roomVersion = it.roomVersion, roomVersion = it.roomVersion,
@@ -52,6 +52,7 @@ internal fun aRustRoomInfo(
numUnreadMessages: ULong = 0uL, numUnreadMessages: ULong = 0uL,
numUnreadNotifications: ULong = 0uL, numUnreadNotifications: ULong = 0uL,
numUnreadMentions: ULong = 0uL, numUnreadMentions: ULong = 0uL,
fullyReadEventId: String? = null,
pinnedEventIds: List<String> = listOf(), pinnedEventIds: List<String> = listOf(),
roomCreators: List<String>? = emptyList(), roomCreators: List<String>? = emptyList(),
joinRule: JoinRule? = null, joinRule: JoinRule? = null,
@@ -93,6 +94,7 @@ internal fun aRustRoomInfo(
numUnreadMessages = numUnreadMessages, numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications, numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions, numUnreadMentions = numUnreadMentions,
fullyReadEventId = fullyReadEventId,
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
creators = roomCreators, creators = roomCreators,
joinRule = joinRule, joinRule = joinRule,
@@ -40,8 +40,8 @@ class FakeFfiSpaceRoomList(
return paginationStateResult() return paginationStateResult()
} }
override fun rooms(): List<SpaceRoom> { override suspend fun rooms(): List<SpaceRoom> = simulateLongTask {
return roomsResult() roomsResult()
} }
override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle { override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle {
@@ -53,7 +53,7 @@ class FakeFfiSpaceRoomList(
spaceRoomListPaginationStateListener?.onUpdate(state) spaceRoomListPaginationStateListener?.onUpdate(state)
} }
override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { override suspend fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle {
spaceRoomListEntriesListener = listener spaceRoomListEntriesListener = listener
return FakeFfiTaskHandle() return FakeFfiTaskHandle()
} }
@@ -79,6 +79,7 @@ class RoomInfoMapperTest {
numUnreadMessages = 12uL, numUnreadMessages = 12uL,
numUnreadNotifications = 13uL, numUnreadNotifications = 13uL,
numUnreadMentions = 14uL, numUnreadMentions = 14uL,
fullyReadEventId = AN_EVENT_ID.value,
pinnedEventIds = listOf(AN_EVENT_ID.value), pinnedEventIds = listOf(AN_EVENT_ID.value),
roomCreators = listOf(A_USER_ID.value), roomCreators = listOf(A_USER_ID.value),
historyVisibility = RustRoomHistoryVisibility.Joined, historyVisibility = RustRoomHistoryVisibility.Joined,
@@ -131,6 +132,7 @@ class RoomInfoMapperTest {
numUnreadMessages = 12L, numUnreadMessages = 12L,
numUnreadNotifications = 13L, numUnreadNotifications = 13L,
numUnreadMentions = 14L, numUnreadMentions = 14L,
fullyReadEventId = AN_EVENT_ID,
historyVisibility = RoomHistoryVisibility.Joined, historyVisibility = RoomHistoryVisibility.Joined,
successorRoom = null, successorRoom = null,
roomVersion = "12", roomVersion = "12",
@@ -223,6 +225,7 @@ class RoomInfoMapperTest {
numUnreadMessages = 12L, numUnreadMessages = 12L,
numUnreadNotifications = 13L, numUnreadNotifications = 13L,
numUnreadMentions = 14L, numUnreadMentions = 14L,
fullyReadEventId = null,
historyVisibility = RoomHistoryVisibility.Joined, historyVisibility = RoomHistoryVisibility.Joined,
roomVersion = "12", roomVersion = "12",
privilegedCreatorRole = true, privilegedCreatorRole = true,
@@ -66,6 +66,7 @@ fun aRoomInfo(
numUnreadMessages: Long = 0, numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0, numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0, numUnreadMentions: Long = 0,
fullyReadEventId: EventId? = null,
historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
roomVersion: String? = "11", roomVersion: String? = "11",
privilegedCreatorRole: Boolean = false, privilegedCreatorRole: Boolean = false,
@@ -105,6 +106,7 @@ fun aRoomInfo(
numUnreadMessages = numUnreadMessages, numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications, numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions, numUnreadMentions = numUnreadMentions,
fullyReadEventId = fullyReadEventId,
historyVisibility = historyVisibility, historyVisibility = historyVisibility,
roomVersion = roomVersion, roomVersion = roomVersion,
privilegedCreatorRole = privilegedCreatorRole, privilegedCreatorRole = privilegedCreatorRole,
@@ -115,6 +115,7 @@ fun aRoomSummary(
numUnreadMessages = numUnreadMessages, numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications, numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions, numUnreadMentions = numUnreadMentions,
fullyReadEventId = null,
historyVisibility = historyVisibility, historyVisibility = historyVisibility,
roomVersion = roomVersion, roomVersion = roomVersion,
privilegedCreatorRole = privilegedCreatorRole, privilegedCreatorRole = privilegedCreatorRole,