diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 3d07f40834..478878b939 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -38,6 +38,7 @@ import { MIN_TOP_LEVEL_ROWS_PER_FETCH, pageOlderMessagesUntilRowFloor, } from "@/features/messages/lib/pageOlderMessages"; +import { isDuplicateReactionError } from "@/features/pulse/lib/noteActions"; import { KIND_REACTION, KIND_STREAM_MESSAGE, @@ -575,7 +576,18 @@ export function useToggleReactionMutation() { emoji, queryClient.getQueryData(customEmojiQueryKey), ); - await addReaction(eventId, emoji, emojiUrl); + try { + await addReaction(eventId, emoji, emojiUrl); + } catch (error) { + // The relay already has this reaction (its `e`-only kind:7 never came + // back over the `#h` live sub, so the cache didn't render it and the + // user clicked again — Tyler's exact repro). That's idempotent + // success: swallow it so onError doesn't roll back the optimistic + // write and leave us back at "duplicate but nothing visible". + if (!isDuplicateReactionError(error)) { + throw error; + } + } }, onError: (_error, _variables, context) => { if (context) { diff --git a/desktop/src/features/messages/lib/optimisticReaction.test.mjs b/desktop/src/features/messages/lib/optimisticReaction.test.mjs index a974f31dbd..d4d19b5e4e 100644 --- a/desktop/src/features/messages/lib/optimisticReaction.test.mjs +++ b/desktop/src/features/messages/lib/optimisticReaction.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { createOptimisticReaction } from "./optimisticReaction.ts"; import { formatTimelineMessages } from "./formatTimelineMessages.ts"; import { sortMessages } from "./messageQueryKeys.ts"; +import { isDuplicateReactionError } from "@/features/pulse/lib/noteActions"; import { KIND_REACTION } from "@/shared/constants/kinds"; const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -101,3 +102,24 @@ test("custom-emoji optimistic reaction carries the NIP-30 emoji tag", () => { ); assert.equal(optimistic.content, ":party:"); }); + +test("duplicate-on-add is recognized so the optimistic write is kept, not rolled back", () => { + // Tyler's exact repro: the relay already holds the reactor's ✅ (it never + // came back over the `#h` live sub, so the cache didn't render it). Clicking + // again writes the optimistic reaction, then `addReaction` rejects with the + // relay's duplicate message. useToggleReactionMutation swallows precisely + // this error so onError never runs and the optimistic reaction survives — + // turning "duplicate but nothing visible" into a rendered ✅. This pins the + // relay error string that swallow depends on; if it drifts, the bug returns. + const relayDuplicate = new Error( + "relay rejected event: duplicate: reaction already exists", + ); + assert.equal(isDuplicateReactionError(relayDuplicate), true); + + // Any other failure must still propagate (onError rolls back) — we only + // treat duplicate-on-add as idempotent success. + assert.equal( + isDuplicateReactionError(new Error("relay rejected event: rate-limited")), + false, + ); +});