Fix reaction un-like to delete all of the user's reaction events

Addresses Copilot review feedback on PR #61: deleting only the latest
of a user's kind-7 reactions let an older `+` resurface as "the"
reaction after refetch, silently re-inflating the like count. Now a
single NIP-09 deletion covers every one of the viewer's own reaction
events on the target, tagged with `k` (7) to match this repo's other
deletion events (useWebBookmarks).

While validating this live against real relays, found and fixed two
more bugs in the same toggle mutation, both stemming from the
eventual-consistency trap already fixed for mute lists on another
branch:
- `onSettled` force-invalidated the reactions query right after a
  successful publish; the refetch could hit a relay that hadn't
  indexed the new event yet and silently revert a like back to
  "unliked" about a second later.
- Fixing that naively (dropping the invalidate) left the optimistic
  placeholder's fake `optimistic:...` id in the cache forever, so a
  like immediately followed by an unlike built a deletion event that
  targeted an id no relay had ever seen.

Both are fixed by writing the mutation's own known-correct result
(the real signed event, or its removal) straight into the query
cache on success, instead of trusting an immediate relay re-read.

Confirmed live with a throwaway account against production relays:
liking persists after the optimistic phase, unliking's deletion event
targets the real reaction id, and two full like/unlike cycles remain
stable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-07 16:47:48 +02:00
parent 8ec053b5b5
commit 7f28248790
2 changed files with 44 additions and 14 deletions

View File

@@ -19,6 +19,10 @@ export function ReactionButton({ target, className }: { target: NostrEvent; clas
const { count, byAuthor } = summarizeReactions(reactions.data);
const own = user ? byAuthor.get(user.pubkey) : undefined;
const reacted = Boolean(own && own.content !== '-');
// Kind 7 isn't replaceable, so the viewer may have more than one reaction
// event on this note; un-reacting needs to clear all of them, not just the
// one `summarizeReactions` picked as "latest".
const ownReactions = user ? (reactions.data ?? []).filter((event) => event.pubkey === user.pubkey) : [];
const handleClick = () => {
if (!user) {
@@ -26,7 +30,7 @@ export function ReactionButton({ target, className }: { target: NostrEvent; clas
return;
}
toggle.mutate(
{ target, existing: reacted ? own : undefined },
{ target, ownReactions: reacted ? ownReactions : undefined },
{
onError: (error) => {
toast({

View File

@@ -60,15 +60,23 @@ export function summarizeReactions(events: NostrEvent[] | undefined): ReactionSu
interface ToggleReactionInput {
/** The note or reply being reacted to. */
target: NostrEvent;
/** The signed-in user's current like on `target`, if any — pass to un-react. */
existing?: NostrEvent;
/**
* All of the signed-in user's own reaction events on `target`, if any —
* pass to un-react. Kind 7 is a regular (non-replaceable) event, so a user
* can end up with more than one over time (races, retries, multiple
* devices); every one of them needs deleting, not just the newest.
*/
ownReactions?: NostrEvent[];
}
/**
* Likes or un-likes a note. Un-reacting publishes a NIP-09 deletion of the
* previous reaction rather than a new negative one — most relays and clients
* honor deletions, whereas a `-` reaction would just add a second, conflicting
* event without necessarily retracting the first from anyone's count.
* Likes or un-likes a note. Un-reacting publishes a single NIP-09 deletion
* covering *all* of the viewer's own reaction events on the target, rather
* than just the most recently seen one — most relays and clients honor
* deletions, whereas a `-` reaction would just add another, conflicting
* event without necessarily retracting the others. Deleting only the latest
* would leave any older `+` in place to resurface as "the" reaction (and
* re-inflate the count) once relays stop returning the deleted one.
*/
export function useToggleReaction() {
const { user } = useCurrentUser();
@@ -76,10 +84,14 @@ export function useToggleReaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ target, existing }: ToggleReactionInput) => {
mutationFn: async ({ target, ownReactions }: ToggleReactionInput) => {
if (!user) throw new Error('Sign in to react');
if (existing) {
return publish.mutateAsync({ kind: DELETION_KIND, content: '', tags: [['e', existing.id]] });
if (ownReactions && ownReactions.length > 0) {
return publish.mutateAsync({
kind: DELETION_KIND,
content: '',
tags: [...ownReactions.map((event): [string, string] => ['e', event.id]), ['k', String(REACTION_KIND)]],
});
}
return publish.mutateAsync({
kind: REACTION_KIND,
@@ -91,7 +103,7 @@ export function useToggleReaction() {
],
});
},
onMutate: async ({ target, existing }) => {
onMutate: async ({ target, ownReactions }) => {
if (!user) return undefined;
const key = reactionsQueryKey(target.id);
await queryClient.cancelQueries({ queryKey: key });
@@ -99,7 +111,7 @@ export function useToggleReaction() {
queryClient.setQueryData<NostrEvent[]>(key, (old = []) => {
const withoutMine = old.filter((event) => event.pubkey !== user.pubkey);
if (existing) return withoutMine;
if (ownReactions && ownReactions.length > 0) return withoutMine;
const optimistic: NostrEvent = {
id: `optimistic:${target.id}:${user.pubkey}`,
pubkey: user.pubkey,
@@ -119,8 +131,22 @@ export function useToggleReaction() {
queryClient.setQueryData(context.key, context.previous);
}
},
onSettled: (_data, _error, { target }) => {
queryClient.invalidateQueries({ queryKey: reactionsQueryKey(target.id) });
// Deliberately not `invalidateQueries` here: right after a successful
// publish, relays are eventually consistent, so an immediate re-query
// commonly hits one that hasn't indexed the new event yet — the stale
// result would silently overwrite the correct state a moment later
// (confirmed live: a like reverted to "unliked" ~1s after publishing).
// Swapping in the mutation's own known-correct result is also required,
// not just safer: `onMutate`'s optimistic entry uses a fake
// `optimistic:...` id, and a like followed immediately by an unlike needs
// the *real* signed event id to build a deletion relays will honor —
// without this, that later delete would target an id that never existed.
onSuccess: (publishedEvent, { target, ownReactions }) => {
if (!user) return;
queryClient.setQueryData<NostrEvent[]>(reactionsQueryKey(target.id), (old = []) => {
const withoutMine = old.filter((event) => event.pubkey !== user.pubkey);
return ownReactions && ownReactions.length > 0 ? withoutMine : [publishedEvent, ...withoutMine];
});
},
});
}