Skip to content

fix(chat): do not send a read marker below the user's own message - #6844

Open
ToteMeiSter wants to merge 5 commits into
nextcloud:masterfrom
ToteMeiSter:fix/read-marker-not-below-own-message
Open

ToteMeiSter wants to merge 5 commits into
nextcloud:masterfrom
ToteMeiSter:fix/read-marker-not-below-own-message

Conversation

@ToteMeiSter

@ToteMeiSter ToteMeiSter commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

After the user sends a message, the chat can send a stale POST chat/<token>/read that carries a value below the message the user has just sent. The server accepts it and moves the user's lastReadMessage backwards, lastCommonRead (the minimum over the participants) follows, and the other participant keeps seeing a single check mark for a conversation the sender is actively replying in.

Server side confirmation (binlog of oc_talk_attendees.last_read_message of the sender; two out of two occurrences in a 10 minute window):

time (UTC) POST …/read last_read_message own last message
13:01:30 from the app (client log) 2624 → 2623 2624 (sent 13:01:14)
13:03:30 from the app (client log) 2629 → 2628 2629 (sent 13:03:22)

ChatManager::addChatMessage sets the sender's marker to the new message, ChatController::setReadMarker writes whatever the client sends (updateLastReadMessage() has no "only forward" check, unlike the automatic marker).

Cause

ChatViewModel.updateRemoteLastReadMessageIfNeeded() (called from ChatActivity.onPause) sends localLastReadMessage when it is greater than the conversation's lastReadMessage. localLastReadMessage only follows the newest message on screen / the conversation entry, so after a reply it can still point to the message before the own one, while the server already moved the marker to the own message.

Change

  • the local marker advances to the newest own message the server has accepted (newestOwnMessageId),
  • readMarkerToSend() never returns a value below it,
  • no marker is sent while an own message is sent but its server copy has not arrived yet (hasUnconfirmedOwnMessage): the server has already moved the marker.

The change is inside the existing advanceLocalLastReadMessageIfNeeded / updateRemoteLastReadMessageIfNeeded path, no new request and no new mechanism.

How to test

  • Unit: ./gradlew :app:testGenericDebugUnitTest --tests "com.nextcloud.talk.chat.viewmodels.ChatViewModelTest" (the sequence 2623 → own 2624, conversation marker 2620 now yields 2624; with an unconfirmed own message nothing is sent; incoming only → old behaviour).
  • Manual: two accounts in one conversation, A opens the chat, writes a reply and leaves the chat: B shows two check marks right after the reply and still after A leaves.

Not covered by unit tests: the onPause wiring itself (no instrumented test infrastructure in this change).

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

The change and the tests were prepared with AI assistance (Claude Code, claude-sonnet-5-5).

@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e44d8b6f-37c5-40f2-b26b-1a201c72c4e3
📥 Commits

Reviewing files that changed from the base of the PR and between ab24e0f and b789097.

📒 Files selected for processing: 6
  • app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
  • app/src/main/java/com/nextcloud/talk/conversationlist/data/network/ConversationListUpdater.kt
  • app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt
  • app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt
  • app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelReadMarkerTest.kt
  • app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt
🚧 Files skipped from review as they are similar to previous changes: 2
  • app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt
  • app/src/main/java/com/nextcloud/talk/conversationlist/data/network/ConversationListUpdater.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 2 remain after this review.

📝 Walkthrough

ChatViewModel stores the latest database messages before the throttled UI rebuild. It considers the local marker, the conversation marker, and accepted own messages when selecting a marker to send. It suppresses updates while an eligible own temporary message is unconfirmed. In that case, it updates the local unread count without changing the stored marker or queuing server work. Tests cover message statuses, file uploads, and existing server markers.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to b7890

Leaving an anchored chat can still send a marker below a newly sent message, and a deferred marker may remain unsent after the message resolves. Resolve these paths before merging.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 24.19% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 62 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main fix: preventing read markers from being sent below the user's own message.
Description check Passed The description explains the problem, cause, implementation, testing approach, limitations, checklist status, and AI assistance. The optional Screenshots and TODO sections are absent, but the descript…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1df25bda-fe1e-409c-b2fa-a4411fa8bd4c
📥 Commits

Reviewing files that changed from the base of the PR and between acc10f9 and bfd6e0a.

📒 Files selected for processing (2)
  • app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
  • app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.

Comment thread app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt Outdated

@rapterjet2004 rapterjet2004 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the server-side analysis. Taking the max with the newest accepted own message is safe with respect to the earlier "marker too high" issue (#6330): newestOwnMessageId filters on jsonMessageId > 0, and both temporary text ids and upload placeholder ids are ≤ 0. The tests cover the relevant sequences well.

One suggestion (non-blocking): SendStatus.PENDING also covers messages queued while offline, waiting for the network. While such a message exists, readMarkerToSend returns null and setChatReadMessage isn't called at all, which also skips the local read state update. Example: offline, open a chat with 5 unread messages, read them, write a reply and leave: the conversation list keeps showing 5 unread until the message is sent and the next sync corrects it. Still updating the local read state in that case and only skipping the server request would avoid it.

Also: check-ai-trailers fails, most likely because of the trailer format. Assisted-by: Claude Code (claude-sonnet-5-5) should pass the allowed pattern.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Keep marker decisions on the latest message window. · ChatViewModel.kt:1305

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt:1305
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep marker decisions on the latest message window.

In SEARCH_MODE or OLD_CHATBLOCK_MODE, messagesFlow can observe messages around an older anchor. This cache can therefore omit a newly accepted own message. If the local marker is 2623 and that own message is 2624, leaving the anchored view can send 2623 and move the server marker below the own message. Observe the latest window separately for read-marker decisions; keep the anchored window for rendering.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 91343e03-eaa2-4ef9-bf8b-3fb801da44f9
📥 Commits

Reviewing files that changed from the base of the PR and between 8b79419 and ab24e0f.

📒 Files selected for processing: 7
  • app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt
  • app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt
  • app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
  • app/src/main/java/com/nextcloud/talk/conversationlist/data/network/ConversationListUpdater.kt
  • app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelReadMarkerTest.kt
  • app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt
  • app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.

@ToteMeiSter

Copy link
Copy Markdown
Contributor Author

@coderabbitai Re: the outside-diff comment "Keep marker decisions on the latest message window" (ChatViewModel.kt:1305):

Confirmed (the code is from 8b79419, not from the latest push). In search / old chat block mode messagesFlow delivers observeMessagesForAnchor — the chat block around the quoted message (OfflineFirstChatRepository.kt:1122) — and onMessagesChanged overwrote latestMessages with it. After sending a message and jumping to a quote, leaving the chat decided the marker without the own message (and without a queued one), so a marker below it could be sent; the server accepts any value (ChatController.php:1935), see the single check mark case in #6330. Fixed in b789097: onMessagesChanged keeps only the list of the newest messages and ignores anchored windows. Tests on the real view model: both failed on ab24e0f, pass now.

@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -t f '^(ChatViewModel|OfflineFirstChatRepository|ChatViewModelReadMarkerTest)\.kt$'
ast-grep outline app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt --match 'onMessagesChanged|setChatReadMessage' --view expanded
ast-grep outline app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt --match 'observeMessagesForAnchor|messagesFlow' --view expanded
ast-grep outline app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelReadMarkerTest.kt --items all --view expanded
rg -n -C 12 'latestMessages|fun onMessagesChanged|observeMessagesForAnchor' app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt
rg -n -C 15 'a window around an old message|simulateOld|oldMessage|anchor' app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelReadMarkerTest.kt

Length of output: 22359


@ToteMeiSter Thanks for the fix and the clarification about the reviewed commit.

I checked the current code. onMessagesChanged preserves latestMessages when an anchored window is active. The two regression tests cover both queued and confirmed own messages after opening an old-message window. This addresses the reported finding.

I inspected the tests but did not run them.


✏️ Learnings added
Learnt from: ToteMeiSter
URL: https://github.com/nextcloud/talk-android/pull/6844

Timestamp: 2026-10-14T19:36:00.148Z
Learning: In the Kotlin Android chat implementation, app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt switches messagesFlow to observeMessagesForAnchor in search or old-chat-block mode when an anchor is present. app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt returns the message block containing that anchor, which can omit recent confirmed or queued own messages. Read-marker decisions must retain the newest-message list rather than replace it with an anchored window.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@ToteMeiSter

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both points are below.

(1) Local read state. Confirmed: the offline-queued message (PENDING) made readMarkerToSend return null, so setChatReadMessage was skipped as a whole, the local update included. Fixed in a3f4b1c, ab24e0f and b789097 (the last one makes the count-only write touch only unreadMessages, and keeps the marker decision on the newest messages when a quoted or searched message is shown).

ChatViewModel.updateRemoteLastReadMessageIfNeeded (ChatViewModel.kt:2102) now computes two things separately: the local marker (localReadMarker, ChatViewModel.kt:2797, no "own message in flight" condition) and whether to send it to the server (readMarkerToSend, ChatViewModel.kt:2783). While an own message is unconfirmed, setChatReadMessage(marker, sendToServer = false) (ChatViewModel.kt:2172) lowers only the unread count of the conversation entry (ConversationListUpdater.updateLocalUnreadCountOnly, ConversationListUpdater.kt:168). The stored lastReadMessage stays the server value, no pending marker is registered and ReadMarkerSyncWorker is not started.

Why not the full local update you suggested: I tried it in a3f4b1c. If the local lastReadMessage and the pending marker are stored and the message then goes to FAILED, the next onPause compares the marker with the already raised local lastReadMessage and sends nothing, while guardPendingReadMarker keeps the local value. The server stays unread for good. With the count-only write, the marker is decided again on the next call and is sent once the message is confirmed or failed.

Side effect: an online room list sync while the message is still unsent restores the server count until the message goes out. Offline there is no sync, so your scenario keeps the lowered count.

Tests:

  • ChatViewModelReadMarkerTest (Robolectric, ViewModel → repository → WorkManager):
    • leaving the chat with an own message queued still updates the local read state (ChatViewModelReadMarkerTest.kt:75) fails on 8b79419;
    • a marker held back by a queued own message is sent once that message failed (ChatViewModelReadMarkerTest.kt:86) fails on a3f4b1c;
    • both pass on ab24e0f and on b789097.
  • ConversationListFreshnessIntegrationTest.kt:254, unread count only write keeps the stored marker and registers nothing pending (database level).

(2) check-ai-trailers. The format is not the cause. In run https://github.com/nextcloud/talk-android/actions/runs/38146825477 the search step found both trailers (Found AI-assistant/Assisted-by trailer(s): Assisted-by: Claude-Code:claude-opus-5-5 …). The step that failed is gh pr edit --add-label "AI assisted": GraphQL: Resource not accessible by integration (addLabelsToLabelable), exit 1. The pull_request token of a fork PR has no permission to add labels (creating the label also returns 403, but that call ends with || true). The label is set on the PR now. I kept the trailer format; if you want Assisted-by: Claude Code (claude-sonnet-5-5) instead, I will change it.

The server moves the read marker of the sender to a message when it is
sent (ChatManager::addChatMessage) but accepts any later POST chat/<token>/read,
including a lower value. The chat sent its local marker on leaving the chat while
that marker still pointed below the own, just sent message. lastCommonRead is the
minimum over the participants, so the recipient kept seeing a single check mark
after a reply.

Advance the local marker to the newest accepted own message, never send a marker
below it, and skip the request while the sent message is not yet confirmed by
the server (its temporary copy is still shown).

Assisted-by: Claude-Code:claude-sonnet-5-5
Signed-off-by: Krainov Gleb <krajnov.g@kontentplus.ru>
The check that keeps the read marker from going below the user's own message
looked at the rendered items. They lag behind the database (throttled rebuild),
so a message the server had already accepted could be missing there when the
chat was left right after sending, and the older local marker was sent over the
marker the server had moved to that message.

Take the decision from the message list of the database instead, updated before
the throttle: a stored temporary copy of the user's text message that did not fail
is treated as possibly accepted, whether the request is still in flight or only
its server copy is missing. A file placeholder counts only after the file was
shared, as the server has no message for it before that.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: Krainov Gleb <krajnov.g@kontentplus.ru>
… queued

A text message queued offline (SendStatus.PENDING) made readMarkerToSend
return null, so setChatReadMessage was skipped as a whole, including the
local read state update. The conversation list kept showing the messages
as unread until the message was sent and the next sync.

Write the local read state whenever the conversation lacks it and skip
only the server request while an own message is on its way, so the
marker is still never sent below the own message.

Assisted-by: Claude-Code:claude-sonnet-5-5
Signed-off-by: Krainov Gleb <krajnov.g@kontentplus.ru>
The previous commit wrote the full local read state while an own message
was queued. The stored marker then equalled the held-back one, so the
"newer than the conversation marker" check suppressed the send for good:
after the message failed, the server stayed unread and the pending
marker guarded the local state.

While the marker has to wait, lower only the unread count of the
conversation entry. The stored marker stays the one the server knows and
nothing is registered as pending, so the marker is decided again by the
next call and sent once the message is confirmed or failed.

Add a test on the real ChatViewModel for the path
updateRemoteLastReadMessageIfNeeded -> repository -> ReadMarkerSyncWorker.

Assisted-by: Claude-Code:claude-sonnet-5-5
Signed-off-by: Krainov Gleb <krajnov.g@kontentplus.ru>
…e window is shown

Review findings on the held-back read marker:

- updateLocalUnreadCountOnly read the conversation, suspended on the
  count and then wrote the stored marker back with updateReadState, so a
  marker written meanwhile was overwritten with the old one. Write only
  the unread count (ConversationsDao.updateUnreadMessages).
- The decision on the read marker used the last list the view model
  received. In search and old chat block mode that is a window around a
  quoted message, which can miss the own message just sent: leaving the
  chat from there sent a marker below it. Keep the list of the newest
  messages and ignore anchored windows.

Assisted-by: Claude-Code:claude-sonnet-5-5
Signed-off-by: Krainov Gleb <krajnov.g@kontentplus.ru>
@ToteMeiSter
ToteMeiSter force-pushed the fix/read-marker-not-below-own-message branch from b789097 to cc98e55 Compare October 18, 2026 12:36
@github-actions

Copy link
Copy Markdown
Contributor

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants