Skip to content

Let marking unread, favoriting and archiving survive being offline or killed - #6719

Merged
AndyScherzinger merged 17 commits into
masterfrom
feat/noid/durableConversationActions
Oct 22, 2026
Merged

AndyScherzinger merged 17 commits into
masterfrom
feat/noid/durableConversationActions

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Stacked on #6717 — review that one first. #6715 and #6694 have merged; this branch still targets feat/noid/optimisticReminders and will be retargeted to master once #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:

  • Offline. The tap applied, both attempts failed within about a second, and the row flipped back — the app knew it had no connection and still threw the change away.
  • The process going away. Archiving a conversation and immediately swiping the app off the recents list left the change in the local database with its in-memory guard gone, so the next room list sync quietly put the old value back.

feat(conversation list): let unread, favorite and archive outlive the app — the three actions now hand the request to a new ConversationActionWorker, the way the read marker already goes through ReadMarkerSyncWorker. The worker carries a NetworkType.CONNECTED constraint, 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 with REPLACE, 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 …/archive and DELETE …/chat/{token}/read each answer 200 twice 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

  • ⛑️ Tests (unit and/or integration) are included or not needed — five tests cover what the work is named, which decides what replaces what, and the action round trip through the work input; both are silent to break
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed — not backported, this goes into 25.1.0
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

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

@AndyScherzinger AndyScherzinger added this to the 25.1.0 milestone Sep 18, 2026
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 93bea38 to 0e97e99 Compare September 24, 2026 14:27
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch 2 times, most recently from 1af1d9f to d969345 Compare September 24, 2026 14:32
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 0e97e99 to 0f19149 Compare September 24, 2026 15:01
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from d969345 to 02badb4 Compare September 24, 2026 15:03
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 0f19149 to bf571a9 Compare September 24, 2026 16:41
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 02badb4 to bf571a9 Compare September 24, 2026 16:42
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from bf571a9 to a0740e5 Compare October 1, 2026 19:02
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 2a32aa9 to c980541 Compare October 1, 2026 19:03
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from a0740e5 to 08df497 Compare October 2, 2026 16:57
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from c980541 to 71269d5 Compare October 2, 2026 17:00
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 08df497 to 00d3a61 Compare October 5, 2026 22:32
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 71269d5 to d080ec3 Compare October 5, 2026 22:35
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 00d3a61 to 8200d2c Compare October 5, 2026 23:20
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from d080ec3 to 1837ca3 Compare October 5, 2026 23:21
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 8200d2c to 57e7e04 Compare October 5, 2026 23:48
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 1837ca3 to 828811a Compare October 5, 2026 23:52
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 57e7e04 to 2fb50b7 Compare October 6, 2026 05:50
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 828811a to bdda654 Compare October 6, 2026 05:52
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 2fb50b7 to 85883e2 Compare October 6, 2026 08:59
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from bdda654 to bbc0813 Compare October 6, 2026 09:02
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReminders branch from 85883e2 to c7fd3fb Compare October 10, 2026 16:05
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from bbc0813 to 174f1e7 Compare October 10, 2026 16:07
@AndyScherzinger

Copy link
Copy Markdown
Member Author

@coderabbitai review

@mahibi

mahibi commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

@AndyScherzinger i guess i need your review now :D Also fine for me if i should move my changes to a separate PR.
Feel free to make further changes to the PR

@AndyScherzinger

Copy link
Copy Markdown
Member Author

All cool, no need to move them.

Have a bit of a laugh at

one commit or more

😂

Comment thread app/src/main/java/com/nextcloud/talk/jobs/ConversationActionWorker.kt Outdated
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from 2a582e2 to f14863c Compare October 22, 2026 05:18
@AndyScherzinger

This comment was marked as resolved.

@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch 2 times, most recently from 1e68c74 to c5055b5 Compare October 22, 2026 12:08
AndyScherzinger and others added 17 commits October 22, 2026 14:08
… 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
AndyScherzinger force-pushed the feat/noid/durableConversationActions branch from c5055b5 to 3ed7127 Compare October 22, 2026 12:08
@github-actions

Copy link
Copy Markdown
Contributor

Codacy

Lint

TypemasterPR
Warnings138137
Errors1515

SpotBugs

CategoryBaseNew
Bad practice77
Correctness88
Dodgy code4040
Internationalization33
Malicious code vulnerability33
Performance88
Security1111
Total8080

@AndyScherzinger
AndyScherzinger merged commit 0a3d9cc into master Oct 22, 2026
19 checks passed
@AndyScherzinger
AndyScherzinger deleted the feat/noid/durableConversationActions branch October 22, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews AI assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants