mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
73
src/components/nostr/ReactionButton.tsx
Normal file
73
src/components/nostr/ReactionButton.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/hooks/useReactions.test.ts
Normal file
31
src/hooks/useReactions.test.ts
Normal 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
152
src/hooks/useReactions.ts
Normal 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];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user