Add Nostr reactions to notes and replies (#61)

* 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>
This commit is contained in:
mroxso
2026-09-07 16:50:36 +02:00
committed by GitHub
parent aa559c56cc
commit 1a24971388
5 changed files with 263 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppC
import { AuthorLine } from '@/components/nostr/AuthorLine';
import { NoteContent } from '@/components/nostr/NoteContent';
import { NoteCard } from '@/components/nostr/NoteCard';
import { ReactionButton } from '@/components/nostr/ReactionButton';
import { Composer } from '@/apps/feed/Composer';
import { DraftNote } from './Draft';
import { Button } from '@/components/ui/button';
@@ -121,7 +122,10 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
<div className="mt-3">
<NoteContent content={event.content} className="text-base" />
</div>
<p className="mt-3 text-xs text-muted-foreground">{absoluteTime(event.created_at)}</p>
<div className="mt-3 flex items-center gap-3">
<p className="text-xs text-muted-foreground">{absoluteTime(event.created_at)}</p>
<ReactionButton target={event} className="h-6" />
</div>
</div>
{user && (

View File

@@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools';
import { AuthorLine } from './AuthorLine';
import { NoteContent } from './NoteContent';
import { BookmarkButton } from './BookmarkButton';
import { ReactionButton } from './ReactionButton';
import { Button } from '@/components/ui/button';
import { useWindowManager } from '@/os/useWindowManager';
import { useToast } from '@/hooks/useToast';
@@ -70,6 +71,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
<Repeat2 className="size-3.5" aria-hidden />
Copy link
</Button>
<ReactionButton target={event} />
<BookmarkButton target={{ type: 'e', value: event.id }} />
</div>
)}

View File

@@ -0,0 +1,73 @@
import { useState } from 'react';
import { Heart, Loader2 } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Button } from '@/components/ui/button';
import AuthDialog from '@/components/auth/AuthDialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useReactions, summarizeReactions, useToggleReaction } from '@/hooks/useReactions';
import { cn } from '@/lib/utils';
/** Likes `target` (a note or reply) via NIP-25 reactions, from the feed or a thread. */
export function ReactionButton({ target, className }: { target: NostrEvent; className?: string }) {
const { user } = useCurrentUser();
const { toast } = useToast();
const [authOpen, setAuthOpen] = useState(false);
const reactions = useReactions(target.id);
const toggle = useToggleReaction();
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) {
setAuthOpen(true);
return;
}
toggle.mutate(
{ target, ownReactions: reacted ? ownReactions : undefined },
{
onError: (error) => {
toast({
title: reacted ? 'Could not remove like' : 'Could not like',
description: error instanceof Error ? error.message : 'No relay accepted the update.',
variant: 'destructive',
});
},
},
);
};
const label = reacted
? `Remove your like${count > 0 ? `${count} ${count === 1 ? 'like' : 'likes'}` : ''}`
: `Like${count > 0 ? `${count} ${count === 1 ? 'like' : 'likes'}` : ''}`;
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
className={cn('h-7 gap-1.5 px-2 text-xs text-muted-foreground', reacted && 'text-rose-500', className)}
onClick={handleClick}
disabled={toggle.isPending}
aria-pressed={reacted}
aria-label={label}
>
{toggle.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<Heart className={cn('size-3.5', reacted && 'fill-current')} aria-hidden />
)}
<span aria-hidden>{count > 0 ? count : ''}</span>
</Button>
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { summarizeReactions } from './useReactions';
function reaction(pubkey: string, content: string, createdAt: number): NostrEvent {
return { id: `${pubkey}-${createdAt}`, pubkey, created_at: createdAt, kind: 7, tags: [], content, sig: '' };
}
describe('summarizeReactions', () => {
it('counts each author once', () => {
const events = [reaction('alice', '+', 1), reaction('bob', '+', 2)];
expect(summarizeReactions(events).count).toBe(2);
});
it('keeps only the latest reaction per author', () => {
const events = [reaction('alice', '+', 1), reaction('alice', '-', 2)];
const summary = summarizeReactions(events);
expect(summary.count).toBe(0);
expect(summary.byAuthor.get('alice')?.content).toBe('-');
});
it('excludes downvotes from the count', () => {
const events = [reaction('alice', '+', 1), reaction('bob', '-', 1)];
expect(summarizeReactions(events).count).toBe(1);
});
it('returns zero for no reactions', () => {
expect(summarizeReactions(undefined).count).toBe(0);
expect(summarizeReactions([]).byAuthor.size).toBe(0);
});
});

152
src/hooks/useReactions.ts Normal file
View File

@@ -0,0 +1,152 @@
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];
});
},
});
}