Skip to content

perf(chat): render reactions, deletions, edits and pinned messages without waiting for the server - #6694

Merged
AndyScherzinger merged 5 commits into
masterfrom
feat/noid/optimisticReactions
Sep 20, 2026
Merged

AndyScherzinger merged 5 commits into
masterfrom
feat/noid/optimisticReactions

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Sep 10, 2026

Copy link
Copy Markdown
Member

Reactions, deletions, edits, pinning and dismissing the pinned banner only rendered once the server had acknowledged them, so every one of those taps cost a full round trip - for deletions and edits even the round trip plus the delivery of the system message that reports the change, and for the pinned banner two round trips - before anything moved on screen.

Each of them is now applied to the local database before the request goes out, and the chat renders from there. A request that fails for a transient reason (connection problem, rate limit, server error) is retried once; only a request that finally fails reverts the local change, and the revert is applied only while the local state still carries it, so a server payload that arrived meanwhile through signaling or a chat sync stays authoritative. A cancelled request - closing the chat mid-flight - reverts as well, instead of leaving the change behind forever. This follows the pattern the conversation list already uses for mark as read, favorites and archiving.

One commit per action:

  • Reactions - the local write moves ahead of the request. Adding a reaction only counted HTTP 201 as success, so a server answering 200 (reaction already applied) persisted nothing at all; add now accepts 200 and 201, delete accepts 200 and 404. Failures were only logged (the Reaction*ViewState LiveData had no observers left since the Compose chat rewrite) and now reach the user as a snackbar. Operations on the same message are serialized, so two quick taps cannot overwrite each other's local write. The chips animate their count, their colors and their place in the row, so the immediate change reads as intentional rather than as a jump.
  • Deletions - deleteChatMessages wrote nothing locally; the bubble only turned into a deleted message when the MESSAGE_DELETED system message came back through the sync. The message is now marked deleted locally, including dropping its attachment parameters so a deleted image stops rendering as an image, and restored from a snapshot if the request fails. HTTP 404 counts as success. The call moves from RxJava to coroutines on the way, which is what lets it reuse withRetry.
  • Edits - the same wait applied, and the server's answer was used for status codes only. The new text and the edit metadata are written before the request; a server that refuses the edit (a message that is too old, for instance) restores the previous version. The confirmed version is persisted from the system message's parent, which is where the edit endpoint reports the edited message - the same place the chat sync reads it from.
  • Pinning - pinning called the API, discarded the returned message and then refreshed the whole room, so the banner needed two round trips. The pinned message is written into the local conversation entry, guarded in the room list merge like the pending favorite flags, and the guard is released once the request completes so a message pinned by somebody else cannot freeze the banner.
  • Dismissing the pinned banner - same shape: the dismissal is written locally and guarded, so the banner disappears on the tap instead of after the request and the room refresh that follows it.

One change is not per-action: the message list rebuild ran behind a 200 ms debounce, which held back every isolated change - each of the above included - for the full window. It now uses a leading edge throttle (Flow.throttleLatest): the first change is passed on immediately, a burst still collapses into one rebuild per window, so the reason the debounce existed is kept. Every other local edit and incoming message benefits from this too.

Behaviour is meant to be exactly as before, only mimicked locally instead of awaited.

🖼️ Screenshots

None - the UI itself does not change, only when it updates.

🚧 TODO

  • Nothing outstanding

🏁 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 (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.0.1 milestone Sep 10, 2026
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch from 3c90e33 to 591b1a8 Compare September 10, 2026 20:35
@AndyScherzinger AndyScherzinger modified the milestones: 25.0.1, 25.1.0 Sep 10, 2026
@AndyScherzinger

Copy link
Copy Markdown
Member Author

/backport to stable-25.0.x

@AndyScherzinger AndyScherzinger added enhancement New feature or request performances Performances issues and optimisations labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/34527290870/artifacts/10172455176
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

@AndyScherzinger AndyScherzinger changed the title perf(chat): render reactions without waiting for the server perf(chat): render reactions, deletions, edits and pinning without waiting for the server Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/34536421911/artifacts/10175823129
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch from a17638a to 0ad7b87 Compare September 11, 2026 05:41
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/34566909089/artifacts/10186511874
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch 2 times, most recently from 8d847ec to e16c78a Compare September 12, 2026 06:46
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/34678988533/artifacts/10293456862
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch from e16c78a to b9621fd Compare September 15, 2026 07:11
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/34940435207/artifacts/10385038818
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch 3 times, most recently from e517620 to 35f88e8 Compare September 17, 2026 16:29
@AndyScherzinger AndyScherzinger changed the title perf(chat): render reactions, deletions, edits and pinning without waiting for the server perf(chat): render reactions, deletions, edits and pinned messages without waiting for the server Sep 17, 2026
A reaction was written to the local database only after the server had
acknowledged it, so tapping an emoji rendered a full round trip later.

The reaction is now applied locally before the request is sent. A request
that fails for a transient reason is retried once, and only a request that
finally fails reverts the local change. The revert is applied only while the
local message still carries the optimistic change, so a server payload that
arrived meanwhile through signaling or a chat sync stays authoritative.
Adding a reaction now accepts HTTP 200 next to 201 and removing one accepts
404, so a reaction that is already in the desired state no longer counts as a
failure. Operations on the same message and emoji are serialized, and a
reaction that could not be applied is now reported to the user instead of
only being logged.

The message list rebuild waited behind a 200 ms debounce, which held back
every isolated change - such as a reaction of one's own - for that whole
window. It now uses a leading edge throttle that passes the first change on
immediately and still collapses bursts into one rebuild per window.

The reaction chips animate their count, their colors and their place in the
row, so a change that lands instantly still reads as intentional.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Deleting a message asked the server and wrote nothing locally, so the bubble
only turned into a deleted message once the corresponding system message came
back through the chat sync - the round trip plus the delivery of that message.

The message is now marked as deleted in the local database before the request
goes out, and the deletion is reverted from a snapshot if the request finally
fails. A transient failure is retried once, and HTTP 404 counts as success:
the server no longer knows the message, so it is gone either way.

The call moves from RxJava to coroutines on the way, which is what lets it
reuse withRetry.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Editing a message sent the request and wrote nothing locally, and the server's
answer was used for status codes only. The new text therefore appeared when the
corresponding system message came back through the chat sync.

The new text and the edit metadata are now written to the local database before
the request goes out. A transient failure is retried once; a failing request or
a server that refuses the edit, such as for a message that is too old, restores
the previous version from a snapshot. The version the server confirms is
persisted as the authoritative one - taken from the system message's parent,
which is where the edit endpoint reports the edited message, the same way the
chat sync reads it.

Editing a temporary message already worked this way, through
editTempChatMessage.

Leaving edit mode also clears the input and lets the input buttons follow the
input state again instead of turning all of them visible, so the save and send
buttons no longer overlap, the scheduled messages button no longer appears
unasked, and the edited text no longer stays behind in the input.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Pinning a message called the API, discarded the message it returned and then
refreshed the whole room, so the pinned banner only appeared after two round
trips.

The pinned message is now written into the local conversation entry before the
request goes out, guarded in the room list merge the way the pending favorite
flags are, so a response computed before the change reached the server cannot
revert it. A transient failure is retried once, and a request that finally fails
restores the previous state. The room refresh stays in place: it re-asserts the
server's state and reveals another pinned message after unpinning.

The shared items screen pins and unpins without ever opening the chat, so the
local write is skipped when the repository has no conversation data.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Dismissing the pinned banner called the API and then refetched the whole room,
so the banner stayed on screen for two round trips - a tap that looks ignored.

The conversation entry now hides the message first, guarded like a pin so a room
list response computed before the request cannot bring the banner back, and the
dismissal is undone if the request finally fails.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger force-pushed the feat/noid/optimisticReactions branch from a9d9f50 to 763e963 Compare September 17, 2026 17:31
@AndyScherzinger
AndyScherzinger merged commit 1913fc1 into master Sep 20, 2026
13 of 21 checks passed
@AndyScherzinger
AndyScherzinger deleted the feat/noid/optimisticReactions branch September 20, 2026 05:31
AndyScherzinger added a commit that referenced this pull request Sep 21, 2026
The backport of #6694 brought the IOException and HttpException catch
clauses into ChatViewModel but not their imports, so stable-25.0.x has
not compiled since. Add them back in the same place master keeps them.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted enhancement New feature or request performances Performances issues and optimisations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants