diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 14ae4203ea..7eacfaa700 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -426,6 +426,7 @@ export function ChannelScreen({ handleSelectThreadReplyTarget, handleToggleReaction, } = useChannelPaneHandlers({ + channelId: activeChannelId, deleteMessageMutation, editMessageMutation, editTargetId, diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 5b4cdeb026..5cb825511f 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -18,6 +18,7 @@ import type { * rather than listing the whole mutation as a dependency. */ export function useChannelPaneHandlers({ + channelId, deleteMessageMutation, editMessageMutation, editTargetId, @@ -37,6 +38,7 @@ export function useChannelPaneHandlers({ threadReplyTargetId, toggleReactionMutation, }: { + channelId: string | null; deleteMessageMutation: ReturnType; editMessageMutation: ReturnType; editTargetId: string | null; @@ -83,6 +85,9 @@ export function useChannelPaneHandlers({ const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync); toggleMutateRef.current = toggleReactionMutation.mutateAsync; + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; + const handleCancelThreadReply = React.useCallback(() => { setThreadReplyTargetId(openThreadHeadIdRef.current); }, [setThreadReplyTargetId]); @@ -290,6 +295,7 @@ export function useChannelPaneHandlers({ emoji, eventId: message.id, remove, + channelId: channelIdRef.current ?? undefined, }); }, [], diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 8d79cc3c53..e448bd1efb 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -573,6 +573,7 @@ export function HomeView({ emoji, eventId: message.id, remove, + channelId: selectedChannel?.id, }); await channelMessagesQuery.refetch(); onRefresh(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b9..411d476cf5 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -32,15 +32,18 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; +import { createOptimisticReaction } from "@/features/messages/lib/optimisticReaction"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; import { MIN_TOP_LEVEL_ROWS_PER_FETCH, pageOlderMessagesUntilRowFloor, } from "@/features/messages/lib/pageOlderMessages"; import { + KIND_REACTION, KIND_STREAM_MESSAGE, KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; +import { useIdentityQuery } from "@/shared/api/hooks"; type MessageQueryContext = { optimisticId: string; @@ -491,6 +494,7 @@ export function useSendMessageMutation( export function useToggleReactionMutation() { const queryClient = useQueryClient(); + const identityQuery = useIdentityQuery(); return useMutation< void, Error, @@ -498,23 +502,85 @@ export function useToggleReactionMutation() { eventId: string; emoji: string; remove: boolean; - } + // The channel whose timeline cache to optimistically update. Omit for + // surfaces that own their own reaction cache (e.g. Pulse notes), so they + // are not double-counted in two caches. + channelId?: string; + }, + | { + queryKey: ReturnType; + previous: RelayEvent[]; + } + | undefined >({ + // Reactions never round-trip back to the client (kind:7 carries only an + // `e` tag, so the `#h`-scoped live subscription misses them — they only + // arrive via the cold-load `#e` aux backfill). So apply the change to the + // channel cache ourselves, optimistically, or the reactor's own reaction + // stays invisible until the next channel switch. Mirrors the apply-on- + // success cache writes in the edit/delete mutations below. + onMutate: ({ eventId, emoji, remove, channelId }) => { + const pubkey = identityQuery.data?.pubkey; + if (!channelId || !pubkey) { + return undefined; + } + + const queryKey = channelMessagesKey(channelId); + const previous = queryClient.getQueryData(queryKey) ?? []; + const pubkeyLower = pubkey.toLowerCase(); + const trimmedEmoji = emoji.trim(); + + const isOwnReactionForTarget = (event: RelayEvent) => + event.kind === KIND_REACTION && + event.pubkey.toLowerCase() === pubkeyLower && + event.content.trim() === trimmedEmoji && + event.tags.some((tag) => tag[0] === "e" && tag[1] === eventId); + + if (remove) { + queryClient.setQueryData(queryKey, (current = []) => + current.filter((event) => !isOwnReactionForTarget(event)), + ); + } else { + // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL + // from the cached workspace palette so the optimistic kind:7 renders + // the right glyph. Unicode reactions resolve to no URL. + const emojiUrl = reactionEmojiUrl( + emoji, + queryClient.getQueryData(customEmojiQueryKey), + ); + const optimistic = createOptimisticReaction( + eventId, + emoji, + emojiUrl, + pubkey, + ); + queryClient.setQueryData(queryKey, (current = []) => + sortMessages([...current, optimistic]), + ); + } + + return { queryKey, previous }; + }, mutationFn: async ({ eventId, emoji, remove }) => { if (remove) { await removeReaction(eventId, emoji); return; } - // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL - // from the cached workspace palette so the kind:7 carries the NIP-30 - // `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL. const emojiUrl = reactionEmojiUrl( emoji, queryClient.getQueryData(customEmojiQueryKey), ); await addReaction(eventId, emoji, emojiUrl); }, + onError: (_error, _variables, context) => { + if (context) { + queryClient.setQueryData( + context.queryKey, + context.previous, + ); + } + }, }); } diff --git a/desktop/src/features/messages/lib/optimisticReaction.test.mjs b/desktop/src/features/messages/lib/optimisticReaction.test.mjs new file mode 100644 index 0000000000..a974f31dbd --- /dev/null +++ b/desktop/src/features/messages/lib/optimisticReaction.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createOptimisticReaction } from "./optimisticReaction.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { sortMessages } from "./messageQueryKeys.ts"; +import { KIND_REACTION } from "@/shared/constants/kinds"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const MESSAGE_ID = + "08212d19af68b69e17ecfa4e2d4477a03f6aff70a3c3e2cf106b389a5a8f3515"; +const ME = "1111111111111111111111111111111111111111111111111111111111111111"; + +function streamMessage() { + return { + id: MESSAGE_ID, + pubkey: "2222222222222222222222222222222222222222222222222222222222222222", + kind: 9, + created_at: 1_700_000_000, + content: "hello world", + tags: [["h", CHANNEL_ID]], + sig: "sig", + }; +} + +function render(events) { + return formatTimelineMessages(events, null, ME, null, {}); +} + +function reactionRow(rows) { + const row = rows.find((r) => r.id === MESSAGE_ID) ?? rows[0]; + return row.reactions ?? []; +} + +test("optimistic reaction renders the reactor's own reaction with no relay round-trip", () => { + // Reactions carry only an `e` tag, so the #h-scoped live subscription never + // delivers a freshly-clicked reaction back — the optimistic cache write is + // the only thing that makes the reactor's own ✅ appear. Without the fix the + // reaction is invisible and re-clicking hits the relay's duplicate guard. + const optimistic = createOptimisticReaction(MESSAGE_ID, "✅", undefined, ME); + + assert.equal(optimistic.kind, KIND_REACTION); + assert.deepEqual( + optimistic.tags.find((t) => t[0] === "e"), + ["e", MESSAGE_ID], + ); + + const rows = render(sortMessages([streamMessage(), optimistic])); + const reactions = reactionRow(rows); + + assert.equal(reactions.length, 1, "one reaction should render"); + assert.equal(reactions[0].emoji, "✅"); + assert.equal(reactions[0].count, 1); + assert.equal( + reactions[0].reactedByCurrentUser, + true, + "the optimistic reaction must be attributed to the current user", + ); +}); + +test("the real backfilled reaction collapses onto the optimistic one — no double count", () => { + // When the genuine kind:7 later loads via the #e aux backfill it has a + // different event id, but render dedupes by target:actor:emoji, so the two + // must not stack into a count of 2. + const optimistic = createOptimisticReaction(MESSAGE_ID, "✅", undefined, ME); + const realReaction = { + id: "0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b", + pubkey: ME, + kind: KIND_REACTION, + created_at: 1_700_000_005, + content: "✅", + tags: [["e", MESSAGE_ID]], + sig: "realsig", + }; + + const rows = render( + sortMessages([streamMessage(), optimistic, realReaction]), + ); + const reactions = reactionRow(rows); + + assert.equal(reactions.length, 1); + assert.equal( + reactions[0].count, + 1, + "must not double-count optimistic + real", + ); + assert.equal(reactions[0].reactedByCurrentUser, true); +}); + +test("custom-emoji optimistic reaction carries the NIP-30 emoji tag", () => { + const optimistic = createOptimisticReaction( + MESSAGE_ID, + ":party:", + "https://example.test/party.png", + ME, + ); + + assert.deepEqual( + optimistic.tags.find((t) => t[0] === "emoji"), + ["emoji", "party", "https://example.test/party.png"], + ); + assert.equal(optimistic.content, ":party:"); +}); diff --git a/desktop/src/features/messages/lib/optimisticReaction.ts b/desktop/src/features/messages/lib/optimisticReaction.ts new file mode 100644 index 0000000000..cedba5f150 --- /dev/null +++ b/desktop/src/features/messages/lib/optimisticReaction.ts @@ -0,0 +1,41 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_REACTION } from "@/shared/constants/kinds"; + +/** + * Synthesize the kind:7 reaction event a freshly-clicked reaction produces, so + * it can be written into the channel cache immediately rather than waiting for + * a relay round-trip that never comes: reactions carry only an `e` tag (no + * `h`), so the `#h`-scoped live subscription (`buildChannelFilter`) never + * delivers them back — they only arrive via the cold-load `#e` aux backfill. + * Without this the reactor's own reaction stays invisible until the next + * channel switch, and re-clicking just re-hits the relay's duplicate guard. + * + * The shape mirrors what `formatTimelineMessages` reads: the target `e` tag, + * the emoji as content, the optional NIP-30 `["emoji", shortcode, url]` tag for + * custom emoji, and the actor's pubkey. Reaction render dedupes by + * `targetId:actorPubkey:emoji`, so when the real event later loads via backfill + * it collapses onto this one — no double count. + */ +export function createOptimisticReaction( + targetEventId: string, + emoji: string, + emojiUrl: string | undefined, + pubkey: string, +): RelayEvent { + const tags: string[][] = [["e", targetEventId]]; + // Custom-emoji reaction (NIP-30): content is `:shortcode:` with the image URL + // on a matching `emoji` tag. + if (emojiUrl && emoji.startsWith(":") && emoji.endsWith(":")) { + tags.push(["emoji", emoji.slice(1, -1), emojiUrl]); + } + + return { + id: `optimistic-reaction-${crypto.randomUUID()}`, + pubkey, + created_at: Math.floor(Date.now() / 1_000), + kind: KIND_REACTION, + tags, + content: emoji, + sig: "", + }; +}