From 5921215718459698d6bed137cbe98546bdaedd63 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 23:29:35 +0300 Subject: [PATCH] android: mark messages read when visible and sync unread across devices --- android/CHANGELOG.md | 8 ++++++++ .../data/realtime/RealtimeEventParser.kt | 7 ++++++- .../domain/realtime/model/RealtimeEvent.kt | 2 ++ .../usecase/HandleRealtimeEventsUseCase.kt | 1 + .../daemonlord/messenger/ui/chat/ChatScreen.kt | 16 ++++++++++++++++ .../messenger/ui/chat/ChatViewModel.kt | 16 ++++++++++++---- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index eaaaceb..6f17f16 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -706,3 +706,11 @@ - read status is now acknowledged by the latest visible message id in chat (not only latest incoming), - delivery status still uses latest incoming message. - This removes cases where unread badge reappears after chat list refresh because the previous read ack used an outdated incoming id. + +### Step 107 - Read-on-visible + cross-device unread sync +- Implemented read acknowledgement from actual visible messages in `ChatScreen`: + - tracks visible `LazyColumn` rows and sends read up to max visible incoming message id. + - unread now drops as messages appear on screen while scrolling. +- Improved cross-device sync (web <-> android): + - `message_read` realtime event now parses `user_id` and `last_read_message_id`. + - on `message_read`, Android refreshes chat snapshot from backend to keep unread counters aligned across devices. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt index 10935b1..324ffb5 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt @@ -102,7 +102,12 @@ class RealtimeEventParser @Inject constructor( "message_read" -> { val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored - RealtimeEvent.MessageRead(chatId = chatId, messageId = messageId) + RealtimeEvent.MessageRead( + chatId = chatId, + messageId = messageId, + userId = payload["user_id"].longOrNull(), + lastReadMessageId = payload["last_read_message_id"].longOrNull(), + ) } "typing_start" -> { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt index bfe932d..b686ff3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt @@ -52,6 +52,8 @@ sealed interface RealtimeEvent { data class MessageRead( val chatId: Long, val messageId: Long, + val userId: Long?, + val lastReadMessageId: Long?, ) : RealtimeEvent data class TypingStart( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 28ed1e5..0f15b04 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -168,6 +168,7 @@ class HandleRealtimeEventsUseCase @Inject constructor( messageId = event.messageId, status = "read", ) + chatRepository.refreshChat(chatId = event.chatId) } is RealtimeEvent.TypingStart -> Unit diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index ef05aee..9629a92 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration @@ -91,6 +92,7 @@ import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked import coil.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder @@ -205,6 +207,7 @@ fun ChatRoute( }, onInlineSearchChanged = viewModel::onInlineSearchChanged, onJumpInlineSearch = viewModel::jumpInlineSearch, + onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId, ) } @@ -236,6 +239,7 @@ fun ChatScreen( onVoiceRecordSend: () -> Unit, onInlineSearchChanged: (String) -> Unit, onJumpInlineSearch: (Boolean) -> Unit, + onVisibleIncomingMessageId: (Long?) -> Unit, ) { val listState = rememberLazyListState() val allImageUrls = remember(state.messages) { @@ -270,6 +274,18 @@ fun ChatScreen( listState.animateScrollToItem(index = index) } } + LaunchedEffect(listState, state.messages) { + snapshotFlow { + listState.layoutInfo.visibleItemsInfo + .mapNotNull { info -> state.messages.getOrNull(info.index) } + .filter { !it.isOutgoing } + .maxOfOrNull { it.id } + } + .distinctUntilChanged() + .collectLatest { visibleIncomingMaxId -> + onVisibleIncomingMessageId(visibleIncomingMaxId) + } + } Column( modifier = Modifier .fillMaxSize() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 3da6006..a2ee8ed 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -189,6 +189,17 @@ class ChatViewModel @Inject constructor( ) } + fun onVisibleIncomingMessageId(messageId: Long?) { + val visibleIncomingId = messageId ?: return + if ((lastReadMessageId ?: 0L) >= visibleIncomingId) { + return + } + lastReadMessageId = visibleIncomingId + viewModelScope.launch { + markMessageReadUseCase(chatId = chatId, messageId = visibleIncomingId) + } + } + fun onSelectMessage(message: MessageItem?) { if (message == null) { onClearSelection() @@ -654,11 +665,8 @@ class ChatViewModel @Inject constructor( markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id) } } - if (lastReadMessageId != latestVisible.id) { + if ((lastReadMessageId ?: 0L) < latestVisible.id) { lastReadMessageId = latestVisible.id - viewModelScope.launch { - markMessageReadUseCase(chatId = chatId, messageId = latestVisible.id) - } } }