Repository navigation
Let marking unread, favoriting and archiving survive being offline or killed - #6719
Merged
Merged
Conversation
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
September 24, 2026 14:27
93bea38 to
0e97e99
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
2 times, most recently
from
September 24, 2026 14:32
1af1d9f to
d969345
Compare
6 tasks done
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
September 24, 2026 15:01
0e97e99 to
0f19149
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
September 24, 2026 15:03
d969345 to
02badb4
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
September 24, 2026 16:41
0f19149 to
bf571a9
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
September 24, 2026 16:42
02badb4 to
bf571a9
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 1, 2026 19:02
bf571a9 to
a0740e5
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 1, 2026 19:03
2a32aa9 to
c980541
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 2, 2026 16:57
a0740e5 to
08df497
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 2, 2026 17:00
c980541 to
71269d5
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 5, 2026 22:32
08df497 to
00d3a61
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 5, 2026 22:35
71269d5 to
d080ec3
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 5, 2026 23:20
00d3a61 to
8200d2c
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 5, 2026 23:21
d080ec3 to
1837ca3
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 5, 2026 23:48
8200d2c to
57e7e04
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 5, 2026 23:52
1837ca3 to
828811a
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 6, 2026 05:50
57e7e04 to
2fb50b7
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 6, 2026 05:52
828811a to
bdda654
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 6, 2026 08:59
2fb50b7 to
85883e2
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 6, 2026 09:02
bdda654 to
bbc0813
Compare
AndyScherzinger
force-pushed
the
feat/noid/optimisticReminders
branch
from
October 10, 2026 16:05
85883e2 to
c7fd3fb
Compare
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 10, 2026 16:07
bbc0813 to
174f1e7
Compare
Member
Author
|
@coderabbitai review |
Collaborator
|
@AndyScherzinger i guess i need your review now :D Also fine for me if i should move my changes to a separate PR. |
mahibi
approved these changes
Oct 21, 2026
Member
Author
|
All cool, no need to move them. Have a bit of a laugh at
😂 |
AndyScherzinger
commented
Oct 21, 2026
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 22, 2026 05:18
2a582e2 to
f14863c
Compare
This comment was marked as resolved.
This comment was marked as resolved.
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
2 times, most recently
from
October 22, 2026 12:08
1e68c74 to
c5055b5
Compare
… app All three reverted themselves when the request did not come back, so a tap taken offline flickered and a tap taken just before the app was killed was lost. They now go to a worker, the way the read marker does, which waits for a connection and survives the process. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Marking unread went through the worker while marking read still called the server directly, so marking unread and then read could reach the server in the opposite order and leave the conversation unread. Marking read now goes through the worker as well, and both use the room's read marker work slot that the chat's ReadMarkerSyncWorker uses, so the last of them replaces any that has not been sent yet. The replaced work never releases its guard, so each releases the other's. A read marker that was sent stays guarded until a sync shows it, like the one from the chat. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…ad and unread Which guard marking read or unread releases was spelled out in the view model. It now lives in ConversationListUpdater as switchToPendingMarkAsRead and switchToPendingMarkAsUnread, next to the guards themselves, with tests for what each leaves guarded. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
… unread The chat now shares the read marker work slot with marking read and unread in the conversation list, so it replaces work that was enqueued there, but it only set its own guard and left the other one behind. A read marker left behind kept the unread count from following the server after a message was marked unread, so new messages did not raise it. The chat now switches the guard the same way the conversation list does. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The change was handed to the worker only after the local write, from the view model's scope. Leaving the screen during that write cancelled the scope, so the change was shown but never sent, and its guard was never released. The scheduler now enqueues separately from waiting for the outcome, and each action sets its guard and enqueues right away. Only the local write, reporting the change as applied and waiting for the outcome run in the view model's scope, so the applied state can no longer be reported after a failure either. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Marking read and unread, archiving and favoriting each set their guard, enqueued the work and then wrote, reported and awaited it themselves, which spelled out the same order four times. They now call applyAndSend, which sets the guard for the action, enqueues the work and then shows the optimistic entry and waits for the outcome, so the guard can neither be forgotten nor set after the work was enqueued. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…re read The actions now come before the helpers they call, in the order they call them, instead of being interleaved with the reset functions. Marking read names the message id it sends as the last read message and says what leaving it out does, and the comment on applyAndSend is shorter. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…come The tests no longer have the server refuse the request but have the worker get the change through or give up on it, so their names and the class comment now say that. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
A missing user went through the same path as a failed request and was only kept from being retried by a credentials check in the retry branch. It now gives up right away, so send() reads as: no user, send, then decide between success, retry and giving up. A test covers that a change for a user that is gone is given up at once rather than retried. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
… comment Marking read and unread built the same read marker url with the same lines; both now use readMarkerUrl. The class comment keeps what the worker does, why a retry is safe, what replaces what and what happens when it gives up, and leaves the longer reasoning to the history. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The user, room, action, value and read marker of an action were passed as five parameters through send, perform, releaseGuard, giveUp and enqueue, two of which needed a LongParameterList suppression. They now travel together as ConversationActionWorker.Change, which also names the internal conversation id the guards are keyed by. The work input keys and the unique work names are unchanged, so work enqueued before still runs. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
applyAndSend now takes the action before the conversations it applies to, so each call starts with what it does. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
awaitOutcome answered whether a change went through, so the only caller had to negate it to find out whether to take the change back. It is now awaitWorkerGaveUp, which answers that question directly. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ConversationAction was an enum, so the value an action sets travelled next to it as enabled and lastReadMessage through the view model, the scheduler and the worker, each meaningful for only some actions and silently ignored for the others. It is now a sealed interface whose MarkRead, MarkUnread, Favorite and Archive carry exactly their own value, so a mismatch can no longer be written and applyAndSend no longer needs a LongParameterList suppression. The work input is written and read in one place, inputDataOf and changeOf, with the same keys, action names and unique work names as before. A test reads work input written the previous way, so work enqueued before an update still runs. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Marking read or unread in the conversation list now goes through the read marker work slot, so it replaces the chat's ReadMarkerSyncWorker. That worker wrapped its request in runCatching, which catches the CancellationException of being replaced and reads it as a request that failed. On the last attempt it then gave up, and giving up releases both read state guards - the guards the replacing work had just set. The next room list sync would undo what the user did last. The cancellation is no longer swallowed. blockingSingle() does not observe it either, so work that was already stopped can still reach the failure path, and that path now leaves the guards alone. Covered by four tests, including that work the list replaced releases no guard, and that a user who is gone still releases both - verified to fail against runCatching and pass against this. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
perform() asked for the conversation api version before looking at the action, although only favoriting and archiving speak that api. Marking read and unread need the chat api alone, so on a server that offers chat-v1 but none of conversation-v1, -v2 or -v4, getConversationApiVersion throws NoSupportedApiException and the read state never reaches the server. The worker reads that as a request that failed, retries it twice more with backoff, and holds the guard until it finally gives up. The version is now asked for in the two branches that build a conversation url. Covered by `marking read on a server with only the chat api does not ask for the conversation api`, verified to fail while the version was asked for up front. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
send() asked for another attempt for any failure that was not a cancellation, so an answer the server had already given cost three attempts with exponential backoff before the guard was released. For that whole window the list showed a value the server was never going to confirm. It now reuses isTransientFailure, the rule optimisticAction and ChatViewModel already follow: a connection problem, a rate limit or a server error is worth another attempt, an answer the server gave is not. Measured against Talk 26.0.0-dev on Nextcloud 36: every refusal on these five endpoints arrives as a non-2xx HTTP status, because /ocs/v2.php maps the OCS status onto it - a room that does not exist answers 404, a room the user is not in answers 404, a negative message id answers 400. So a refusal reaches the worker as an HttpException that isTransientFailure reads as permanent, and it is given up on at once rather than retried into a backoff. Covered by a connection problem being retried, a server error being retried and a refusal being given up on with its guard released at once. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
AndyScherzinger
force-pushed
the
feat/noid/durableConversationActions
branch
from
October 22, 2026 12:08
c5055b5 to
3ed7127
Compare
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #6717 — review that one first. #6715 and #6694 have merged; this branch still targets
feat/noid/optimisticRemindersand will be retargeted tomasteronce #6717 lands.Marking a conversation unread, favoriting it and archiving it were optimistic but not durable. The local entry changed at once, then the request had two attempts in the view model's scope, and anything that stopped it there reverted the change. Two cases that made worse:
feat(conversation list): let unread, favorite and archive outlive the app— the three actions now hand the request to a newConversationActionWorker, the way the read marker already goes throughReadMarkerSyncWorker. The worker carries aNetworkType.CONNECTEDconstraint, so a change made offline waits for a connection instead of being discarded, and it retries with exponential backoff across process death. Work is unique per conversation and action withREPLACE, so favoriting and unfavoriting in quick succession sends only the last intent, while archiving and marking unread on the same conversation stay independent.This is only safe because each of these sets a value rather than moving one. Verified against a Nextcloud 36 dev instance running Talk
main:POST …/favorite,POST …/archiveandDELETE …/chat/{token}/readeach answer200twice in a row and leave the same state, so a retry can never overshoot. That is the opposite of the note-to-self share in #6717, which has no such guarantee and therefore reads back before retrying.Failure handling keeps both halves. While the list is still on screen it observes the work and, when the worker has used up its attempts, puts the conversation back and shows the error snackbar exactly as before. When the list is gone the worker releases the pending guard instead, and the next room list sync restores the server state.
Known limitation, deliberately not solved here. The pending guard lives in memory in
ConversationListUpdater. If the process dies before the worker runs, the guard is gone, so a room list sync between the restart and the worker finishing can show the old value for a moment before the worker's success brings it back. Making the guard durable means persisting it next to the conversation entry, which is a schema change and its own review.🖼️ Screenshots
No UI was added or changed. The archive snackbar now appears when the row moves rather than when the server answers.
🏁 Checklist
🤖 AI (if applicable)