Merge remote-tracking branch 'origin/main' into feat/zaps

# Conflicts:
#	src/apps/notes/index.tsx
#	src/components/nostr/NoteCard.tsx

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:10:15 +00:00
committed by GitHub
5 changed files with 261 additions and 1 deletions

View File

@@ -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) {
<div className="mt-3 flex items-center gap-3">
<p className="text-xs text-muted-foreground">{absoluteTime(event.created_at)}</p>
<ZapButton target={event} className="h-6" />
<ReactionButton target={event} className="h-6" />
</div>
</div>

View File

@@ -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) {
<NoteContent content={event.content} />
{!compact && (
<div className="mt-2 flex items-center gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<div className="mt-2 flex flex-wrap items-center gap-1">
<Button
variant="ghost"
size="sm"
@@ -79,6 +80,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
Copy link
</Button>
<ZapButton target={event} revealed={revealed} />
<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];
});
},
});
}