mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
+23
-11
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-10
@@ -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(),
|
||||||
|
|||||||
+167
-11
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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(),
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
+5
@@ -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>,
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+3
-3
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user