From f08c65cbe7241548551db2d2fbf76dc6b78069eb Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 29 Aug 2026 22:05:04 -0400 Subject: [PATCH] Show a reaction the moment it is clicked The pill changed only when the gateway echoed the reaction back, which made a click on a reaction-role message look like it had done nothing: those bots take the reaction off again the instant they grant the role, so the add and the removal both arrive and the row ends up exactly as it started. The natural response is to click again, and again. Showing it at once separates "this did not register" from "this happened and was undone by somebody else". A failure puts the row back, so the optimistic view never outlives being wrong. Only ever moves this account's own vote: the count follows because we are one of the reactors, everyone else's stays where it is, and a tally that reaches zero disappears exactly as it does when the service reports it. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/state/store.ts | 54 ++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/nobilis b/nobilis index 80a1820..8b0aae3 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 80a182035e21a2e04d3be9d758e8723b083018d1 +Subproject commit 8b0aae3b0256a6a9e7e6a0b965d6714b46101281 diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 34e5d2c..c535867 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1,19 +1,20 @@ import type { Account, + AudioDevice, Buffer as WireBuffer, - NobilisEvent, + BufferGroup, CustomEmoji, + IncomingCall, + MatrixVerification, Member, Message, - BufferGroup, - MatrixVerification, + NobilisEvent, + Reaction, RoomPermissions, SockchatSmilie, - AudioDevice, - VoicePrefs, VoiceChannel, - VoiceSession, - IncomingCall + VoicePrefs, + VoiceSession } from '../../../shared/wire' import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format' import { isImageFile, resolveMediaUrl } from '../lib/util' @@ -303,6 +304,26 @@ async function uploadHost(service: string | undefined, attachmentPath: string): } } +/** + * This account's own reaction added to or taken off a tally. + * + * Only ever moves our own vote: the count follows because we are one of the + * reactors, and everyone else's stays where it is. A tally that reaches zero + * disappears, the same as it does when the service reports it, so an + * optimistic row and a real one look alike. + */ +function applyOwnReaction(list: Reaction[], emoji: string, add: boolean): Reaction[] { + const existing = list.find((r) => r.emoji === emoji) + if (!existing) { + return add ? [...list, { emoji, count: 1, me: true }] : list + } + // Already in the state being asked for - the service will say the same. + if (existing.me === add) return list + const count = Math.max(0, existing.count + (add ? 1 : -1)) + if (count === 0) return list.filter((r) => r.emoji !== emoji) + return list.map((r) => (r.emoji === emoji ? { ...r, count, me: add } : r)) +} + function bestEffort(work: Promise, what: string): void { void work.catch((e: Error) => console.debug(`[moho] ${what}:`, e.message)) } @@ -1560,10 +1581,29 @@ export class ChatStore { } } + /** + * Reacting, shown immediately rather than when the service agrees. + * + * The pill used to change only when the gateway echoed the reaction back, + * which made a click on a reaction-role message look like it had done + * nothing at all: those bots take the reaction off again the moment they + * grant the role, so the add and the removal both arrive and the row ends + * up exactly as it started. People click again, and again. + * + * Showing it at once separates "this did not register" from "this + * happened and was undone by somebody else" - and a failure puts the row + * back, so an optimistic view never outlives being wrong. + */ async toggleReaction(bufferId: string, messageId: string, emoji: string, add: boolean): Promise { + const before = this.state.messagesByBuffer[bufferId]?.find((m) => m.id === messageId)?.reactions + this.mapMessage(bufferId, messageId, (m) => ({ + ...m, + reactions: applyOwnReaction(m.reactions ?? [], emoji, add) + })) try { await window.moho.rpc('toggleReaction', { bufferId, messageId, emoji, add }) } catch (e) { + this.mapMessage(bufferId, messageId, (m) => ({ ...m, reactions: before })) this.toast('error', (e as Error).message) } }