diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index 3d5d7a1..c706c25 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -8,6 +8,7 @@ import { AuthorLine } from '@/components/nostr/AuthorLine'; import { NoteContent } from '@/components/nostr/NoteContent'; import { NoteCard } from '@/components/nostr/NoteCard'; import { ZapButton } from '@/components/nostr/ZapButton'; +import { ReactionButton } from '@/components/nostr/ReactionButton'; import { Composer } from '@/apps/feed/Composer'; import { DraftNote } from './Draft'; import { Button } from '@/components/ui/button'; @@ -125,6 +126,7 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {

{absoluteTime(event.created_at)}

+
diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index a1d46d5..7fb138d 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -6,6 +6,7 @@ import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; import { ZapButton } from './ZapButton'; +import { ReactionButton } from './ReactionButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; import { useToast } from '@/hooks/useToast'; @@ -59,7 +60,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { {!compact && ( -
+
)} diff --git a/src/components/nostr/ReactionButton.tsx b/src/components/nostr/ReactionButton.tsx new file mode 100644 index 0000000..193f762 --- /dev/null +++ b/src/components/nostr/ReactionButton.tsx @@ -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 ( + <> + + + setAuthOpen(false)} /> + + ); +} diff --git a/src/hooks/useReactions.test.ts b/src/hooks/useReactions.test.ts new file mode 100644 index 0000000..c084ea2 --- /dev/null +++ b/src/hooks/useReactions.test.ts @@ -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); + }); +}); diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts new file mode 100644 index 0000000..2f0ebed --- /dev/null +++ b/src/hooks/useReactions.ts @@ -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({ + 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 { + const byAuthor = new Map(); + 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; +} + +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(key); + + queryClient.setQueryData(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(reactionsQueryKey(target.id), (old = []) => { + const withoutMine = old.filter((event) => event.pubkey !== user.pubkey); + return ownReactions && ownReactions.length > 0 ? withoutMine : [publishedEvent, ...withoutMine]; + }); + }, + }); +}