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),
|
- read status is now acknowledged by the latest visible message id in chat (not only latest incoming),
|
||||||
- delivery status still uses latest incoming message.
|
- 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.
|
- 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" -> {
|
"message_read" -> {
|
||||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
val messageId = payload["message_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" -> {
|
"typing_start" -> {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ sealed interface RealtimeEvent {
|
|||||||
data class MessageRead(
|
data class MessageRead(
|
||||||
val chatId: Long,
|
val chatId: Long,
|
||||||
val messageId: Long,
|
val messageId: Long,
|
||||||
|
val userId: Long?,
|
||||||
|
val lastReadMessageId: Long?,
|
||||||
) : RealtimeEvent
|
) : RealtimeEvent
|
||||||
|
|
||||||
data class TypingStart(
|
data class TypingStart(
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
messageId = event.messageId,
|
messageId = event.messageId,
|
||||||
status = "read",
|
status = "read",
|
||||||
)
|
)
|
||||||
|
chatRepository.refreshChat(chatId = event.chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
is RealtimeEvent.TypingStart -> Unit
|
is RealtimeEvent.TypingStart -> Unit
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
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 androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
||||||
@@ -205,6 +207,7 @@ fun ChatRoute(
|
|||||||
},
|
},
|
||||||
onInlineSearchChanged = viewModel::onInlineSearchChanged,
|
onInlineSearchChanged = viewModel::onInlineSearchChanged,
|
||||||
onJumpInlineSearch = viewModel::jumpInlineSearch,
|
onJumpInlineSearch = viewModel::jumpInlineSearch,
|
||||||
|
onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +239,7 @@ fun ChatScreen(
|
|||||||
onVoiceRecordSend: () -> Unit,
|
onVoiceRecordSend: () -> Unit,
|
||||||
onInlineSearchChanged: (String) -> Unit,
|
onInlineSearchChanged: (String) -> Unit,
|
||||||
onJumpInlineSearch: (Boolean) -> Unit,
|
onJumpInlineSearch: (Boolean) -> Unit,
|
||||||
|
onVisibleIncomingMessageId: (Long?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val allImageUrls = remember(state.messages) {
|
val allImageUrls = remember(state.messages) {
|
||||||
@@ -270,6 +274,18 @@ fun ChatScreen(
|
|||||||
listState.animateScrollToItem(index = index)
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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?) {
|
fun onSelectMessage(message: MessageItem?) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
onClearSelection()
|
onClearSelection()
|
||||||
@@ -654,11 +665,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
|
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastReadMessageId != latestVisible.id) {
|
if ((lastReadMessageId ?: 0L) < latestVisible.id) {
|
||||||
lastReadMessageId = latestVisible.id
|
lastReadMessageId = latestVisible.id
|
||||||
viewModelScope.launch {
|
|
||||||
markMessageReadUseCase(chatId = chatId, messageId = latestVisible.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user