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 <[email protected]>
This commit is contained in:
SalastilandClaude Opus 5 committed 2026-08-29 22:05:04 -04:00
1 parent 3719717cf5
commit f08c65cbe7
2 files changed
+48 -8

No files matched your search

+1 -1
Submodule nobilis updated: 80a182035e...8b0aae3b02.
+47 -7
View File
@@ -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<unknown>, 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<void> {
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)
}
}