mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
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