desktop: render the reactor's own reaction optimistically

Clicking a reaction put the kind:7 on the relay but it never rendered:
the GUI showed nothing and re-clicking hit "duplicate: reaction already
exists". Root cause is a missing optimistic cache write, not a fetch
window or transient timeout.

useToggleReactionMutation fired the reaction and did nothing locally —
unlike useDeleteMessageMutation and useEditMessageMutation right below
it, which both apply the change to the channel cache on success (their
comments note the relay round-trip "can lag perceptibly"). For a
reaction the round-trip never arrives at all: kind:7 carries only an `e`
tag, no `h`, so the `#h`-scoped live subscription (buildChannelFilter)
misses it — reactions only enter the cache via the cold-load `#e` aux
backfill. So a freshly-clicked reaction stayed invisible until the next
channel switch, and re-clicking just re-hit the relay's duplicate guard.

Give the reaction mutation the same treatment as edit/delete: an
optimistic onMutate that synthesizes the kind:7 (target `e` tag, emoji
content, NIP-30 emoji tag for custom emoji, actor pubkey) and writes it
into the channel cache, with onError rollback. Render dedupes reactions
by target:actor:emoji, so when the genuine event later loads via
backfill it collapses onto the optimistic one — no double count. Removal
drops the matching optimistic (or backfilled) reaction.

Scoped via an optional `channelId` in the mutate variables: channel
surfaces (ChannelScreen, Home inbox) pass it; Pulse omits it and keeps
its own reaction cache, so a note is never counted in two caches.

createOptimisticReaction is a pure module so the contract is unit-tested
without React: optimistic reaction renders reactedByCurrentUser, the
real backfilled event collapses onto it without double-counting, and a
custom-emoji reaction carries its NIP-30 tag.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-21 09:54:55 -04:00
parent ace12f7b07
commit 87e7f3578c
6 changed files with 222 additions and 4 deletions

View File

@@ -426,6 +426,7 @@ export function ChannelScreen({
handleSelectThreadReplyTarget,
handleToggleReaction,
} = useChannelPaneHandlers({
channelId: activeChannelId,
deleteMessageMutation,
editMessageMutation,
editTargetId,

View File

@@ -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<typeof useDeleteMessageMutation>;
editMessageMutation: ReturnType<typeof useEditMessageMutation>;
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,
});
},
[],

View File

@@ -573,6 +573,7 @@ export function HomeView({
emoji,
eventId: message.id,
remove,
channelId: selectedChannel?.id,
});
await channelMessagesQuery.refetch();
onRefresh();

View File

@@ -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<typeof channelMessagesKey>;
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<RelayEvent[]>(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<RelayEvent[]>(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<CustomEmoji[]>(customEmojiQueryKey),
);
const optimistic = createOptimisticReaction(
eventId,
emoji,
emojiUrl,
pubkey,
);
queryClient.setQueryData<RelayEvent[]>(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<CustomEmoji[]>(customEmojiQueryKey),
);
await addReaction(eventId, emoji, emojiUrl);
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<RelayEvent[]>(
context.queryKey,
context.previous,
);
}
},
});
}

View File

@@ -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:");
});

View File

@@ -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: "",
};
}