diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx
index a7f2155..a88872d 100644
--- a/src/apps/notes/index.tsx
+++ b/src/apps/notes/index.tsx
@@ -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) {
- {absoluteTime(event.created_at)}
+
+
{absoluteTime(event.created_at)}
+
+
{user && (
diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx
index cfd9146..59e746b 100644
--- a/src/components/nostr/NoteCard.tsx
+++ b/src/components/nostr/NoteCard.tsx
@@ -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) {
Copy link
+
)}
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];
+ });
+ },
+ });
+}