From bdb42759decb4abd89dccf947063cb53d2026227 Mon Sep 17 00:00:00 2001 From: Jenna Vassar <5023996+jennaharris7@users.noreply.github.com> Date: Thu, 21 May 2026 07:11:53 -0700 Subject: [PATCH] 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) --- .../impl/timeline/TimelinePresenter.kt | 34 ++-- .../messages/impl/timeline/TimelineState.kt | 17 +- .../impl/timeline/TimelineStateProvider.kt | 4 +- .../messages/impl/timeline/TimelineView.kt | 36 +++- .../impl/timeline/TimelinePresenterTest.kt | 178 ++++++++++++++++-- .../space/impl/root/SpaceStateProvider.kt | 1 + gradle/libs.versions.toml | 2 +- .../libraries/matrix/api/room/RoomInfo.kt | 5 + .../impl/auth/AuthenticationException.kt | 1 + .../matrix/impl/room/RoomInfoMapper.kt | 1 + .../impl/fixtures/factories/RoomInfo.kt | 2 + .../fixtures/fakes/FakeFfiSpaceRoomList.kt | 6 +- .../matrix/impl/room/RoomInfoMapperTest.kt | 3 + .../matrix/test/room/RoomInfoFixture.kt | 2 + .../matrix/test/room/RoomSummaryFixture.kt | 1 + 15 files changed, 254 insertions(+), 39 deletions(-) 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 ac08c9adfd..bfc951f6ee 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 @@ -291,20 +291,32 @@ class TimelinePresenter( // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the // bottom of the room. // - // Limitation: when the read marker is outside the loaded window (gaps, pagination), this - // returns null and the jump-to-unread button stays hidden. Proper fix needs an SDK - // accessor for the m.fully_read marker plus FocusedOnEvent navigation on click; gated - // behind FeatureFlags.JumpToUnread until that lands. - val readMarkerIndex = remember { mutableStateOf(null) } - LaunchedEffect(timelineItems, displayJumpToUnread) { + // 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.Hidden) } + LaunchedEffect(timelineItems, displayJumpToUnread, roomInfo.fullyReadEventId) { if (!displayJumpToUnread) { - readMarkerIndex.value = null + jumpToUnread.value = JumpToUnreadState.Hidden return@LaunchedEffect } val items = timelineItems - readMarkerIndex.value = withContext(dispatchers.computation) { - items.indexOfFirst { (it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel } - .takeIf { it >= 0 } + 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 + } } } @@ -358,7 +370,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayFloatingDateBadge = displayFloatingDateBadge, displayJumpToUnread = displayJumpToUnread, - readMarkerIndex = readMarkerIndex.value, + jumpToUnread = jumpToUnread.value, 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 d66dfe3031..c5b563a9c3 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 @@ -36,7 +36,7 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayFloatingDateBadge: Boolean, val displayJumpToUnread: Boolean, - val readMarkerIndex: Int?, + val jumpToUnread: JumpToUnreadState, val eventSink: (TimelineEvent) -> Unit, ) { private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event @@ -85,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 +} 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 2d4e22e79b..81b3134268 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 @@ -60,7 +60,7 @@ fun aTimelineState( displayThreadSummaries: Boolean = false, displayFloatingDateBadge: Boolean = false, displayJumpToUnread: Boolean = false, - readMarkerIndex: Int? = null, + jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { @@ -83,7 +83,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayFloatingDateBadge = displayFloatingDateBadge, displayJumpToUnread = displayJumpToUnread, - readMarkerIndex = readMarkerIndex, + jumpToUnread = jumpToUnread, 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 e9117d7ee7..cca3c1f96c 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 @@ -155,6 +155,10 @@ fun TimelineView( state.eventSink(TimelineEvent.MarkAllAsRead) } + fun onFocusOnEvent(eventId: EventId) { + state.eventSink(TimelineEvent.FocusOnEvent(eventId)) + } + val context = LocalContext.current val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current @@ -240,11 +244,12 @@ fun TimelineView( isLive = state.isLive, focusRequestState = state.focusRequestState, displayJumpToUnread = state.displayJumpToUnread, - readMarkerIndex = state.readMarkerIndex, + jumpToUnread = state.jumpToUnread, onScrollFinishAt = ::onScrollFinishAt, onJumpToLive = ::onJumpToLive, onFocusEventRender = ::onFocusEventRender, onMarkAllAsRead = ::onMarkAllAsRead, + onFocusOnEvent = ::onFocusOnEvent, ) if (state.displayFloatingDateBadge && useReverseLayout) { @@ -326,11 +331,12 @@ private fun BoxScope.TimelineScrollHelper( forceJumpToReadMarkerVisibility: Boolean, focusRequestState: FocusRequestState, displayJumpToUnread: Boolean, - readMarkerIndex: Int?, + jumpToUnread: JumpToUnreadState, onScrollFinishAt: (Int) -> Unit, onJumpToLive: () -> Unit, onFocusEventRender: () -> Unit, onMarkAllAsRead: () -> Unit, + onFocusOnEvent: (EventId) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } @@ -339,12 +345,19 @@ private fun BoxScope.TimelineScrollHelper( lazyListState.firstVisibleItemIndex < 3 && isLive } } - val isJumpToUnreadVisible by remember(readMarkerIndex, forceJumpToReadMarkerVisibility) { + val isJumpToUnreadVisible by remember(jumpToUnread, forceJumpToReadMarkerVisibility) { derivedStateOf { if (forceJumpToReadMarkerVisibility) return@derivedStateOf true - val markerIndex = readMarkerIndex ?: return@derivedStateOf false - val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false - markerIndex > lastVisibleIndex + when (val jtu = jumpToUnread) { + JumpToUnreadState.Hidden -> false + // 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 @@ -375,9 +388,12 @@ private fun BoxScope.TimelineScrollHelper( } fun jumpToReadMarker() { - val markerIndex = readMarkerIndex ?: return - coroutineScope.launch { - lazyListState.animateScrollToItemCenter(markerIndex) + when (val jtu = jumpToUnread) { + JumpToUnreadState.Hidden -> Unit + 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 // 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. - 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, ), timelineProtectionState = aTimelineProtectionState(), 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 f5ede957c8..7cd0f1f126 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 @@ -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.room.FakeBaseRoom 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.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline @@ -371,7 +372,7 @@ class TimelinePresenterTest { } @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()) val timeline = FakeTimeline(timelineItems = timelineItems) val presenter = createTimelinePresenter( @@ -391,8 +392,8 @@ class TimelinePresenterTest { MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())), ) ) - consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state -> - assertThat(state.readMarkerIndex).isEqualTo(3) + consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state -> + assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 3)) assertThat(state.displayJumpToUnread).isTrue() } cancelAndIgnoreRemainingEvents() @@ -400,7 +401,7 @@ class TimelinePresenterTest { } @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()) val timeline = FakeTimeline(timelineItems = timelineItems) val presenter = createTimelinePresenter( @@ -416,16 +417,16 @@ class TimelinePresenterTest { ) ) consumeItemsUntilPredicate { - it.timelineItems.size == 2 && it.readMarkerIndex == null + it.timelineItems.size == 2 && it.jumpToUnread == JumpToUnreadState.Hidden }.last().also { state -> - assertThat(state.readMarkerIndex).isNull() + assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden) } cancelAndIgnoreRemainingEvents() } } @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()) val timeline = FakeTimeline(timelineItems = timelineItems) val presenter = createTimelinePresenter( @@ -443,7 +444,7 @@ class TimelinePresenterTest { ) consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state -> assertThat(state.displayJumpToUnread).isFalse() - assertThat(state.readMarkerIndex).isNull() + assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden) } cancelAndIgnoreRemainingEvents() } @@ -579,7 +580,7 @@ class TimelinePresenterTest { } @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()) val timeline = FakeTimeline(timelineItems = timelineItems) val presenter = createTimelinePresenter( @@ -591,8 +592,163 @@ class TimelinePresenterTest { timelineItems.emit( listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker)) ) - consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state -> - assertThat(state.readMarkerIndex).isEqualTo(0) + consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state -> + 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()) + 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()) + 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()) + 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()) + 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()) + 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() } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index 20f4918a98..6b8d714cb7 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -131,6 +131,7 @@ private fun aSpaceInfo( numUnreadMessages = 0, numUnreadNotifications = 0, numUnreadMentions = 0, + fullyReadEventId = null, heroes = persistentListOf(), pinnedEventIds = persistentListOf(), creators = persistentListOf(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a79320530..cf95945d05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 # 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. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.05.7" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.05.20" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt index b9ed8d61b1..715c7348a1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt @@ -70,6 +70,11 @@ data class RoomInfo( * notification settings. */ 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, val pinnedEventIds: ImmutableList, val creators: ImmutableList, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index 20dbf76a31..fac5227f6a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -28,6 +28,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException { } is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message) + is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message) } is OAuthException -> when (this) { is OAuthException.Generic -> AuthenticationException.OAuth(message) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt index 0e9aadc65b..a1d0dd5cd3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt @@ -71,6 +71,7 @@ class RoomInfoMapper { numUnreadMessages = it.numUnreadMessages.toLong(), numUnreadMentions = it.numUnreadMentions.toLong(), numUnreadNotifications = it.numUnreadNotifications.toLong(), + fullyReadEventId = it.fullyReadEventId?.let(::EventId), historyVisibility = it.historyVisibility.map(), successorRoom = it.successorRoom?.map(), roomVersion = it.roomVersion, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt index 2ce64154f7..a809f16803 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt @@ -52,6 +52,7 @@ internal fun aRustRoomInfo( numUnreadMessages: ULong = 0uL, numUnreadNotifications: ULong = 0uL, numUnreadMentions: ULong = 0uL, + fullyReadEventId: String? = null, pinnedEventIds: List = listOf(), roomCreators: List? = emptyList(), joinRule: JoinRule? = null, @@ -93,6 +94,7 @@ internal fun aRustRoomInfo( numUnreadMessages = numUnreadMessages, numUnreadNotifications = numUnreadNotifications, numUnreadMentions = numUnreadMentions, + fullyReadEventId = fullyReadEventId, pinnedEventIds = pinnedEventIds, creators = roomCreators, joinRule = joinRule, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt index c0ecc53d4f..c85fb287b7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt @@ -40,8 +40,8 @@ class FakeFfiSpaceRoomList( return paginationStateResult() } - override fun rooms(): List { - return roomsResult() + override suspend fun rooms(): List = simulateLongTask { + roomsResult() } override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle { @@ -53,7 +53,7 @@ class FakeFfiSpaceRoomList( spaceRoomListPaginationStateListener?.onUpdate(state) } - override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { + override suspend fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { spaceRoomListEntriesListener = listener return FakeFfiTaskHandle() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt index 56b480d97f..75386964ca 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt @@ -79,6 +79,7 @@ class RoomInfoMapperTest { numUnreadMessages = 12uL, numUnreadNotifications = 13uL, numUnreadMentions = 14uL, + fullyReadEventId = AN_EVENT_ID.value, pinnedEventIds = listOf(AN_EVENT_ID.value), roomCreators = listOf(A_USER_ID.value), historyVisibility = RustRoomHistoryVisibility.Joined, @@ -131,6 +132,7 @@ class RoomInfoMapperTest { numUnreadMessages = 12L, numUnreadNotifications = 13L, numUnreadMentions = 14L, + fullyReadEventId = AN_EVENT_ID, historyVisibility = RoomHistoryVisibility.Joined, successorRoom = null, roomVersion = "12", @@ -223,6 +225,7 @@ class RoomInfoMapperTest { numUnreadMessages = 12L, numUnreadNotifications = 13L, numUnreadMentions = 14L, + fullyReadEventId = null, historyVisibility = RoomHistoryVisibility.Joined, roomVersion = "12", privilegedCreatorRole = true, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt index c15330e9dc..7fba39c909 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt @@ -66,6 +66,7 @@ fun aRoomInfo( numUnreadMessages: Long = 0, numUnreadNotifications: Long = 0, numUnreadMentions: Long = 0, + fullyReadEventId: EventId? = null, historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, roomVersion: String? = "11", privilegedCreatorRole: Boolean = false, @@ -105,6 +106,7 @@ fun aRoomInfo( numUnreadMessages = numUnreadMessages, numUnreadNotifications = numUnreadNotifications, numUnreadMentions = numUnreadMentions, + fullyReadEventId = fullyReadEventId, historyVisibility = historyVisibility, roomVersion = roomVersion, privilegedCreatorRole = privilegedCreatorRole, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 32635a7eea..57f89101b7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -115,6 +115,7 @@ fun aRoomSummary( numUnreadMessages = numUnreadMessages, numUnreadNotifications = numUnreadNotifications, numUnreadMentions = numUnreadMentions, + fullyReadEventId = null, historyVisibility = historyVisibility, roomVersion = roomVersion, privilegedCreatorRole = privilegedCreatorRole,