android: mark messages read when visible and sync unread across devices
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 23:29:35 +03:00
parent d54eb400c7
commit 5921215718
6 changed files with 45 additions and 5 deletions

View File

@@ -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.

View File

@@ -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" -> {

View File

@@ -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(

View File

@@ -168,6 +168,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId,
status = "read",
)
chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.TypingStart -> Unit

View File

@@ -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()

View File

@@ -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)
}
}
}