android: mark messages read when visible and sync unread across devices
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" -> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -168,6 +168,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
messageId = event.messageId,
|
||||
status = "read",
|
||||
)
|
||||
chatRepository.refreshChat(chatId = event.chatId)
|
||||
}
|
||||
|
||||
is RealtimeEvent.TypingStart -> Unit
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user