mirror of
https://github.com/layer-systems/website.git
synced 2026-09-15 07:47:34 +02:00
* Add NIP-25 reactions to notes and replies Signed-in users can like a note or reply from the feed, a thread view, or its replies. The like count and the viewer's own reaction state come from kind-7 events tagged to the target; liking publishes a kind 7 with content "+", and un-liking publishes a NIP-09 deletion of the viewer's own reaction rather than a competing "-" event, since deletions are what most relays and clients actually honor. Updates are optimistic (instant toggle, rollback on publish failure) and reconcile against relays afterward. Signed-out users get the sign-in dialog instead of a silent no-op. Closes #53 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * 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 --------- Co-authored-by: highperfocused <highperfocused@pm.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
153 lines
5.8 KiB
TypeScript
153 lines
5.8 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { useCurrentUser } from './useCurrentUser';
|
|
import { useNostrPublish } from './useNostrPublish';
|
|
|
|
/** NIP-25 reactions. Content `-` is a downvote; anything else (commonly `+`) is a like. */
|
|
const REACTION_KIND = 7;
|
|
const DELETION_KIND = 5;
|
|
|
|
function reactionsQueryKey(eventId: string) {
|
|
return ['nostr', 'reactions', eventId] as const;
|
|
}
|
|
|
|
/** All kind-7 reactions to `eventId`, newest first. */
|
|
export function useReactions(eventId: string | undefined) {
|
|
const { nostr } = useNostr();
|
|
|
|
return useQuery<NostrEvent[]>({
|
|
queryKey: reactionsQueryKey(eventId ?? ''),
|
|
enabled: Boolean(eventId),
|
|
queryFn: async ({ signal }) => {
|
|
const events = await nostr.query(
|
|
[{ kinds: [REACTION_KIND], '#e': [eventId!], limit: 500 }],
|
|
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
|
|
);
|
|
return events.sort((a, b) => b.created_at - a.created_at);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
/** Only the most recent reaction per author — an author can change their mind. */
|
|
function latestPerAuthor(events: NostrEvent[]): Map<string, NostrEvent> {
|
|
const byAuthor = new Map<string, NostrEvent>();
|
|
for (const event of events) {
|
|
const existing = byAuthor.get(event.pubkey);
|
|
if (!existing || event.created_at > existing.created_at) {
|
|
byAuthor.set(event.pubkey, event);
|
|
}
|
|
}
|
|
return byAuthor;
|
|
}
|
|
|
|
export interface ReactionSummary {
|
|
/** Distinct authors whose latest reaction is a like (i.e. not a `-` downvote). */
|
|
count: number;
|
|
byAuthor: Map<string, NostrEvent>;
|
|
}
|
|
|
|
export function summarizeReactions(events: NostrEvent[] | undefined): ReactionSummary {
|
|
const byAuthor = latestPerAuthor(events ?? []);
|
|
let count = 0;
|
|
for (const event of byAuthor.values()) {
|
|
if (event.content !== '-') count++;
|
|
}
|
|
return { count, byAuthor };
|
|
}
|
|
|
|
interface ToggleReactionInput {
|
|
/** The note or reply being reacted to. */
|
|
target: 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 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();
|
|
const publish = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ target, ownReactions }: ToggleReactionInput) => {
|
|
if (!user) throw new Error('Sign in to react');
|
|
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,
|
|
content: '+',
|
|
tags: [
|
|
['e', target.id],
|
|
['p', target.pubkey],
|
|
['k', target.kind.toString()],
|
|
],
|
|
});
|
|
},
|
|
onMutate: async ({ target, ownReactions }) => {
|
|
if (!user) return undefined;
|
|
const key = reactionsQueryKey(target.id);
|
|
await queryClient.cancelQueries({ queryKey: key });
|
|
const previous = queryClient.getQueryData<NostrEvent[]>(key);
|
|
|
|
queryClient.setQueryData<NostrEvent[]>(key, (old = []) => {
|
|
const withoutMine = old.filter((event) => event.pubkey !== user.pubkey);
|
|
if (ownReactions && ownReactions.length > 0) return withoutMine;
|
|
const optimistic: NostrEvent = {
|
|
id: `optimistic:${target.id}:${user.pubkey}`,
|
|
pubkey: user.pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: REACTION_KIND,
|
|
content: '+',
|
|
tags: [['e', target.id], ['p', target.pubkey]],
|
|
sig: '',
|
|
};
|
|
return [optimistic, ...withoutMine];
|
|
});
|
|
|
|
return { previous, key };
|
|
},
|
|
onError: (_error, _variables, context) => {
|
|
if (context) {
|
|
queryClient.setQueryData(context.key, context.previous);
|
|
}
|
|
},
|
|
// 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];
|
|
});
|
|
},
|
|
});
|
|
}
|