Skip to content

Commit 325162d

Browse files
VelikovPetarclaude
andauthored
Port V6 fix: Prevent calling MarkRead on the current user's own unsynced last message (#6471)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68ec437 commit 325162d

2 files changed

Lines changed: 87 additions & 6 deletions

File tree

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import io.getstream.chat.android.models.Option
6565
import io.getstream.chat.android.models.Poll
6666
import io.getstream.chat.android.models.PollOption
6767
import io.getstream.chat.android.models.Reaction
68+
import io.getstream.chat.android.models.SyncStatus
6869
import io.getstream.chat.android.models.User
6970
import io.getstream.chat.android.models.Vote
7071
import io.getstream.chat.android.ui.common.feature.messages.translations.MessageOriginalTranslationsStore
@@ -1714,10 +1715,21 @@ public class MessageListController(
17141715
val itemState = messagesState.messageItems.lastOrNull { messageItem ->
17151716
messageItem is HasMessageListItemState
17161717
} as? HasMessageListItemState
1717-
val messageId = itemState?.message?.id
1718-
val messageText = itemState?.message?.text
1718+
val message = itemState?.message
1719+
val messageId = message?.id
1720+
val messageText = message?.text
17191721
logger.d { "[markLastMessageRead] cid: $cid, msgId($isInThread): $messageId, msgText: \"$messageText\"" }
17201722

1723+
// Skip when our own message is at the bottom and hasn't been confirmed by the server.
1724+
// Without this, marking read on an empty channel (only an in-flight optimistic message
1725+
// exists) causes the server to persist last_read_message_id = "" because its view of
1726+
// the channel is empty.
1727+
val currentUserId = clientState.user.value?.id
1728+
if (message != null && message.user.id == currentUserId && message.syncStatus != SyncStatus.COMPLETED) {
1729+
logger.v { "[markLastMessageRead] cid: $cid; rejected[$isInThread] (own unsynced): $messageId" }
1730+
return
1731+
}
1732+
17211733
val lastSeenMessageId = this.lastSeenMessageId
17221734
if (lastSeenMessageId == messageId) {
17231735
logger.v { "[markLastMessageRead] cid: $cid; rejected[$isInThread] (already seen msgId): $messageId" }

stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import io.getstream.chat.android.models.Message
4040
import io.getstream.chat.android.models.MessageType
4141
import io.getstream.chat.android.models.MessagesState
4242
import io.getstream.chat.android.models.Reaction
43+
import io.getstream.chat.android.models.SyncStatus
4344
import io.getstream.chat.android.models.TypingEvent
4445
import io.getstream.chat.android.models.User
4546
import io.getstream.chat.android.models.Vote
@@ -308,9 +309,9 @@ internal class MessageListControllerTests {
308309
fun `When repetitive markLastMessageRead calls appear only single API call should be sent`() = runTest {
309310
val chatClient: ChatClient = mock()
310311
val messages = arrayListOf(
311-
randomMessage(id = "1"),
312-
randomMessage(id = "2"),
313-
randomMessage(id = "3"),
312+
randomMessage(id = "1", syncStatus = SyncStatus.COMPLETED),
313+
randomMessage(id = "2", syncStatus = SyncStatus.COMPLETED),
314+
randomMessage(id = "3", syncStatus = SyncStatus.COMPLETED),
314315
)
315316
val messagesState = MutableStateFlow(messages)
316317
val controller = Fixture(chatClient = chatClient)
@@ -334,6 +335,68 @@ internal class MessageListControllerTests {
334335
verify(chatClient, times(1)).markRead(any(), any())
335336
}
336337

338+
@Test
339+
fun `When current user's last message is COMPLETED markLastMessageRead should invoke markRead`() = runTest {
340+
val chatClient: ChatClient = mock()
341+
val messagesState = MutableStateFlow(
342+
listOf(randomMessage(id = "1", user = user1, syncStatus = SyncStatus.COMPLETED)),
343+
)
344+
val controller = Fixture(chatClient = chatClient)
345+
.givenCurrentUser()
346+
.givenChannelQuery()
347+
.givenMarkRead()
348+
.givenChannelState(messagesState = messagesState)
349+
.get()
350+
351+
controller.markLastMessageRead()
352+
delay(1000)
353+
354+
verify(chatClient, times(1)).markRead(eq(CHANNEL_TYPE), eq(CHANNEL_ID))
355+
controller.lastSeenMessageId `should be equal to` "1"
356+
}
357+
358+
@Test
359+
fun `When current user's last message is not COMPLETED markLastMessageRead should not invoke markRead`() = runTest {
360+
val chatClient: ChatClient = mock()
361+
val messagesState = MutableStateFlow(
362+
listOf(randomMessage(id = "1", user = user1, syncStatus = SyncStatus.IN_PROGRESS)),
363+
)
364+
val controller = Fixture(chatClient = chatClient)
365+
.givenCurrentUser()
366+
.givenChannelQuery()
367+
.givenMarkRead()
368+
.givenChannelState(messagesState = messagesState)
369+
.get()
370+
371+
controller.markLastMessageRead()
372+
delay(1000)
373+
374+
verify(chatClient, times(0)).markRead(any(), any())
375+
controller.lastSeenMessageId.shouldBeNull()
376+
}
377+
378+
@Test
379+
fun `When peer's last message is not COMPLETED markLastMessageRead should still invoke markRead`() = runTest {
380+
// syncStatus is local-only and not on the wire. Peer messages inherit the data
381+
// class default — the gate must not block them on that.
382+
val chatClient: ChatClient = mock()
383+
val messagesState = MutableStateFlow(
384+
listOf(randomMessage(id = "1", user = user2, syncStatus = SyncStatus.IN_PROGRESS)),
385+
)
386+
val controller = Fixture(chatClient = chatClient)
387+
.givenCurrentUser()
388+
.givenChannelQuery()
389+
.givenMarkRead()
390+
.givenChannelState(messagesState = messagesState)
391+
.get()
392+
393+
controller.markLastMessageRead()
394+
delay(1000)
395+
396+
verify(chatClient, times(1)).markRead(eq(CHANNEL_TYPE), eq(CHANNEL_ID))
397+
controller.lastSeenMessageId `should be equal to` "1"
398+
}
399+
337400
@Test
338401
fun `When channelData changes the updated Channel instance must be emitted`() = runTest {
339402
val chatClient: ChatClient = mock()
@@ -1320,12 +1383,18 @@ internal class MessageListControllerTests {
13201383
@OptIn(ExperimentalCoroutinesApi::class)
13211384
private fun nowDate() = Date(testCoroutines.dispatcher.scheduler.currentTime)
13221385

1323-
private fun nowMessage(author: User, type: String, text: String = randomString()): Message {
1386+
private fun nowMessage(
1387+
author: User,
1388+
type: String,
1389+
text: String = randomString(),
1390+
syncStatus: SyncStatus = SyncStatus.COMPLETED,
1391+
): Message {
13241392
val nowDate = nowDate()
13251393
return randomMessage(
13261394
user = author,
13271395
type = type,
13281396
text = text,
1397+
syncStatus = syncStatus,
13291398
createdAt = nowDate,
13301399
updatedAt = nowDate,
13311400
deletedAt = null,

0 commit comments

Comments
 (0)