mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
@@ -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({
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user