mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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:
parent
ace12f7b07
commit
87e7f3578c
@@ -426,6 +426,7 @@ export function ChannelScreen({
|
||||
handleSelectThreadReplyTarget,
|
||||
handleToggleReaction,
|
||||
} = useChannelPaneHandlers({
|
||||
channelId: activeChannelId,
|
||||
deleteMessageMutation,
|
||||
editMessageMutation,
|
||||
editTargetId,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -573,6 +573,7 @@ export function HomeView({
|
||||
emoji,
|
||||
eventId: message.id,
|
||||
remove,
|
||||
channelId: selectedChannel?.id,
|
||||
});
|
||||
await channelMessagesQuery.refetch();
|
||||
onRefresh();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
103
desktop/src/features/messages/lib/optimisticReaction.test.mjs
Normal file
103
desktop/src/features/messages/lib/optimisticReaction.test.mjs
Normal 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:");
|
||||
});
|
||||
41
desktop/src/features/messages/lib/optimisticReaction.ts
Normal file
41
desktop/src/features/messages/lib/optimisticReaction.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user