Fix marking a room as read re-instantiates its timeline (#5628)

* Add `Timeline.markAsRead` to avoid reinstantiating the timeline using `Room.markAsRead`

* Mark as read when exiting the room screen, destroy the timeline when fully closed

* Ensure `MarkAsFullyReadAndExit` event can only be processed once

* Fix `DelayedVisibility` not being displayed in previews
This commit is contained in:
Jorge Martin Espinosa 2025-10-30 08:39:06 +01:00 committed by GitHub
parent bb61126c96
commit 6c3b280ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 281 additions and 89 deletions

View file

@ -18,6 +18,7 @@ sealed interface MessagesEvents {
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
data object Dismiss : MessagesEvents
data object MarkAsFullyReadAndExit : MessagesEvents
}
enum class InviteDialogAction {

View file

@ -23,4 +23,5 @@ interface MessagesNavigator {
fun onPreviewAttachment(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun onNavigateUp()
}

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import android.app.Activity
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -263,6 +264,8 @@ class MessagesNode(
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
override fun onNavigateUp() = navigateUp()
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
@ -271,6 +274,11 @@ class MessagesNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val state = presenter.present()
BackHandler {
state.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
@ -279,7 +287,7 @@ class MessagesNode(
}
MessagesView(
state = state,
onBackClick = this::navigateUp,
onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) },
onRoomDetailsClick = this::onRoomDetailsClick,
onEventContentClick = { isLive, event ->
if (isLive) {

View file

@ -36,6 +36,7 @@ import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineState
@ -65,6 +66,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.toThreadId
@ -93,6 +95,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@AssistedInject
class MessagesPresenter(
@ -122,6 +125,8 @@ class MessagesPresenter(
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<MessagesState> {
@AssistedFactory
interface Factory {
@ -138,10 +143,13 @@ class MessagesPresenter(
timelineMode = timelineController.mainTimelineMode()
)
private val markingAsReadAndExiting = AtomicBoolean(false)
@Composable
override fun present(): MessagesState {
htmlConverterProvider.Update()
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()
@ -239,6 +247,22 @@ class MessagesPresenter(
is MessagesEvents.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch {
if (!markingAsReadAndExiting.getAndSet(true)) {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.w(it, "Failed to get latest event id to mark as fully read")
navigator.onNavigateUp()
return@launch
}
latestEventId?.let { eventId ->
sessionCoroutineScope.launch {
markAsFullyRead(room.roomId, eventId)
}
}
navigator.onNavigateUp()
markingAsReadAndExiting.set(false)
}
}
}
}

View file

@ -229,6 +229,8 @@ class ThreadedMessagesNode(
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
}
override fun onNavigateUp() = navigateUp()
private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() }
}

View file

@ -8,29 +8,26 @@
package io.element.android.features.messages.impl.timeline
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
interface MarkAsFullyRead {
operator fun invoke(roomId: RoomId)
suspend operator fun invoke(roomId: RoomId, eventId: EventId): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultMarkAsFullyRead(
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
) : MarkAsFullyRead {
override fun invoke(roomId: RoomId) {
matrixClient.sessionCoroutineScope.launch {
matrixClient.getRoom(roomId)?.use { room ->
room.markAsRead(receiptType = ReceiptType.FULLY_READ)
.onFailure {
Timber.e("Failed to mark room $roomId as fully read", it)
}
}
override suspend fun invoke(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
matrixClient.markRoomAsFullyRead(roomId, eventId).onFailure {
Timber.e(it, "Failed to mark room $roomId as fully read for event $eventId")
}
}
}

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
@ -85,7 +84,6 @@ class TimelinePresenter(
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead,
private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> {
@AssistedFactory
@ -219,12 +217,6 @@ class TimelinePresenter(
}
}
DisposableEffect(Unit) {
onDispose {
markAsFullyRead(room.roomId)
}
}
LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
@ -388,7 +380,7 @@ class TimelinePresenter(
) = launch(dispatchers.computation) {
// If we are at the bottom of timeline, we mark the room as read.
if (firstVisibleIndex == 0) {
room.markAsRead(receiptType = readReceiptType)
room.liveTimeline.markAsRead(receiptType = readReceiptType)
} else {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)