mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
Add NIP-18 reposts and quote posts for notes
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { useMutedPubkeys } from '@/hooks/useMuteList';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { isReply } from '@/lib/nostrUtils';
|
||||
import { GENERIC_REPOST_KIND, REPOST_KIND } from '@/hooks/useReposts';
|
||||
import type { AppProps } from '@/os/types';
|
||||
|
||||
type Scope = 'following' | 'global';
|
||||
@@ -25,9 +26,14 @@ const PAGE_SIZE = 50;
|
||||
* happily return blanks and oddities, so the feed validates before it draws.
|
||||
* Replies (NIP-10 `e` tags) are excluded too: without their parent for
|
||||
* context they read as indistinguishable, orphaned root posts — open the
|
||||
* thread from the Note app instead.
|
||||
* thread from the Note app instead. Reposts (kind 6/16) only need a valid
|
||||
* `e` tag to point at what they're reposting; their `content` is optional
|
||||
* per NIP-18, so it isn't part of this check.
|
||||
*/
|
||||
function isRenderableNote(event: NostrEvent): boolean {
|
||||
if (event.kind === REPOST_KIND || event.kind === GENERIC_REPOST_KIND) {
|
||||
return event.tags.some(([name, value]) => name === 'e' && Boolean(value));
|
||||
}
|
||||
return (
|
||||
event.kind === 1 &&
|
||||
typeof event.content === 'string' &&
|
||||
@@ -43,10 +49,11 @@ function useFeed(scope: Scope, authors: string[] | undefined) {
|
||||
queryKey: ['nostr', 'feed', scope, scope === 'following' ? (authors ?? []).length : 0],
|
||||
enabled: scope === 'global' || Boolean(authors),
|
||||
queryFn: async ({ signal }) => {
|
||||
const kinds = [1, REPOST_KIND, GENERIC_REPOST_KIND];
|
||||
const filter =
|
||||
scope === 'following'
|
||||
? { kinds: [1], authors: authors!.slice(0, 500), limit: PAGE_SIZE }
|
||||
: { kinds: [1], limit: PAGE_SIZE };
|
||||
? { kinds, authors: authors!.slice(0, 500), limit: PAGE_SIZE }
|
||||
: { kinds, limit: PAGE_SIZE };
|
||||
|
||||
const events = await nostr.query([filter], {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
|
||||
|
||||
@@ -9,32 +9,17 @@ 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 { RepostButton } from '@/components/nostr/RepostButton';
|
||||
import { Composer } from '@/apps/feed/Composer';
|
||||
import { DraftNote } from './Draft';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNote } from '@/hooks/useNote';
|
||||
import { absoluteTime, decodeRelayHints, displayName } from '@/lib/nostrUtils';
|
||||
import type { AppProps } from '@/os/types';
|
||||
|
||||
function useNote(id: string | undefined, relays: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['nostr', 'note', id ?? '', relays?.join(',') ?? ''],
|
||||
enabled: Boolean(id),
|
||||
queryFn: async ({ signal }) => {
|
||||
const [event] = await nostr.query([{ ids: [id!] }], {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
|
||||
relays,
|
||||
});
|
||||
return event ?? null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function useReplies(id: string | undefined, relays: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
@@ -127,6 +112,7 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {
|
||||
<p className="text-xs text-muted-foreground">{absoluteTime(event.created_at)}</p>
|
||||
<ZapButton target={event} className="h-6" />
|
||||
<ReactionButton target={event} className="h-6" />
|
||||
<RepostButton target={event} className="h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, Repeat2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link2, MessageSquare, Repeat2 } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { AuthorLine } from './AuthorLine';
|
||||
@@ -8,10 +8,17 @@ import { BookmarkButton } from './BookmarkButton';
|
||||
import { ModerationMenu } from './ModerationMenu';
|
||||
import { ZapButton } from './ZapButton';
|
||||
import { ReactionButton } from './ReactionButton';
|
||||
import { RepostButton } from './RepostButton';
|
||||
import { QuotedNotePreview } from './QuotedNotePreview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useRelayHints } from '@/hooks/useRelayHints';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNote } from '@/hooks/useNote';
|
||||
import { GENERIC_REPOST_KIND, REPOST_KIND, parseEmbeddedRepost, repostReference } from '@/hooks/useReposts';
|
||||
import { displayName } from '@/lib/nostrUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NoteCardProps {
|
||||
@@ -19,13 +26,23 @@ interface NoteCardProps {
|
||||
/** Hides the reply affordance when the note is already the open thread root. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
/** Guards against pathological (self-referential) repost chains. Internal use only. */
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const MAX_REPOST_DEPTH = 3;
|
||||
|
||||
/**
|
||||
* One note in a list. Dense by design: a 44px-ish header, the content, and a
|
||||
* thin action row — no oversized card padding.
|
||||
*
|
||||
* A NIP-18 repost (kind 6/16) renders as an attribution banner over the
|
||||
* original note rather than as its own bubble, so a repost never loses the
|
||||
* context of what was reposted. A NIP-18 quote post is a regular kind-1 note
|
||||
* carrying a `q` tag; it renders its author's own words plus an embedded,
|
||||
* navigable preview of the quoted note — keeping the two clearly distinct.
|
||||
*/
|
||||
export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
export function NoteCard({ event, compact, className, depth = 0 }: NoteCardProps) {
|
||||
const { openApp } = useWindowManager();
|
||||
const { toast } = useToast();
|
||||
const hints = useRelayHints();
|
||||
@@ -34,6 +51,12 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
// until this note is actually looked at instead of firing on every mount.
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
if ((event.kind === REPOST_KIND || event.kind === GENERIC_REPOST_KIND) && depth < MAX_REPOST_DEPTH) {
|
||||
return <RepostedNote event={event} compact={compact} className={className} depth={depth} />;
|
||||
}
|
||||
|
||||
const quoteTag = event.tags.find(([name, value]) => name === 'q' && Boolean(value));
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
// Without relay hints a shared link only resolves for people who happen
|
||||
@@ -60,6 +83,8 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
<div className="mt-2 pl-11">
|
||||
<NoteContent content={event.content} />
|
||||
|
||||
{quoteTag && <QuotedNotePreview id={quoteTag[1]} relays={quoteTag[2] ? [quoteTag[2]] : undefined} />}
|
||||
|
||||
{!compact && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
@@ -77,11 +102,12 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
onClick={copyLink}
|
||||
>
|
||||
<Repeat2 className="size-3.5" aria-hidden />
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
Copy link
|
||||
</Button>
|
||||
<ZapButton target={event} revealed={revealed} />
|
||||
<ReactionButton target={event} />
|
||||
<RepostButton target={event} />
|
||||
<BookmarkButton target={{ type: 'e', value: event.id }} />
|
||||
<ModerationMenu pubkey={event.pubkey} event={event} className="ml-auto" />
|
||||
</div>
|
||||
@@ -90,3 +116,55 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepostedNoteProps {
|
||||
event: NostrEvent;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** The attribution banner + original note for a NIP-18 repost. */
|
||||
function RepostedNote({ event, compact, className, depth }: RepostedNoteProps) {
|
||||
const { openApp } = useWindowManager();
|
||||
const { data: author } = useAuthor(event.pubkey);
|
||||
const name = displayName(event.pubkey, author?.metadata);
|
||||
|
||||
const embedded = useMemo(() => parseEmbeddedRepost(event), [event]);
|
||||
const reference = repostReference(event);
|
||||
// A malformed, self-referential repost (its `e` tag points at itself) must
|
||||
// not be followed, or fetching "the original" would just re-render this
|
||||
// same repost forever.
|
||||
const fetchId = !embedded && reference && reference.id !== event.id ? reference.id : undefined;
|
||||
const fetched = useNote(fetchId, reference?.relay ? [reference.relay] : undefined);
|
||||
const original = embedded ?? fetched.data;
|
||||
|
||||
return (
|
||||
<div className={cn('border-b border-border last:border-b-0', className)}>
|
||||
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground">
|
||||
<Repeat2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openApp('profile', { pubkey: event.pubkey })}
|
||||
className="truncate font-medium hover:underline"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<span>reposted</span>
|
||||
</div>
|
||||
|
||||
{original ? (
|
||||
<NoteCard event={original} compact={compact} className="border-b-0" depth={depth + 1} />
|
||||
) : fetched.isLoading ? (
|
||||
<div className="space-y-2 px-4 py-3 pl-11">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 pb-3 pl-11 text-xs text-muted-foreground">
|
||||
This note is unavailable — it may live on another relay.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/nostr/QuoteDialog.tsx
Normal file
85
src/components/nostr/QuoteDialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AuthorLine } from './AuthorLine';
|
||||
import { NoteContent } from './NoteContent';
|
||||
import { useCreateQuotePost } from '@/hooks/useReposts';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface QuoteDialogProps {
|
||||
target: NostrEvent;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes a NIP-18 quote post: the user's own commentary plus a clear,
|
||||
* embedded reference back to the note being quoted — never a copy of its
|
||||
* text, which would lose attribution to the original author.
|
||||
*/
|
||||
export function QuoteDialog({ target, isOpen, onClose }: QuoteDialogProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const createQuote = useCreateQuotePost();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open || createQuote.isPending) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await createQuote.mutateAsync({ target, content });
|
||||
toast({ title: 'Quote post published' });
|
||||
setContent('');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not publish quote post',
|
||||
description: error instanceof Error ? error.message : 'No relay accepted the note.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quote post</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="Add your commentary… (optional)"
|
||||
autoFocus
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<AuthorLine pubkey={target.pubkey} createdAt={target.created_at} size="sm" />
|
||||
<NoteContent content={target.content} className="mt-1.5 line-clamp-6 text-sm" />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={createQuote.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={submit} disabled={createQuote.isPending} className="gap-1.5">
|
||||
{createQuote.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Send className="size-3.5" aria-hidden />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
68
src/components/nostr/QuotedNotePreview.tsx
Normal file
68
src/components/nostr/QuotedNotePreview.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { AuthorLine } from './AuthorLine';
|
||||
import { NoteContent } from './NoteContent';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useNote } from '@/hooks/useNote';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuotedNotePreviewProps {
|
||||
id: string;
|
||||
relays?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note a NIP-18 `q` tag points at, embedded inline so a quote post reads
|
||||
* as a quote rather than a bare link — while staying visually separate from
|
||||
* the quoting author's own words above it.
|
||||
*
|
||||
* The content region (not the author line) is the click target, so opening
|
||||
* the profile and opening the quoted note stay independent affordances
|
||||
* instead of one interactive element nested inside another.
|
||||
*/
|
||||
export function QuotedNotePreview({ id, relays, className }: QuotedNotePreviewProps) {
|
||||
const { openApp } = useWindowManager();
|
||||
const note = useNote(id, relays);
|
||||
|
||||
const open = () => openApp('notes', { id });
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 rounded-lg border border-border p-3', className)}>
|
||||
{note.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
) : note.data ? (
|
||||
<>
|
||||
<AuthorLine pubkey={note.data.pubkey} createdAt={note.data.created_at} size="sm" />
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={open}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 cursor-pointer rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<NoteContent content={note.data.content} className="line-clamp-6 text-sm" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="text-xs text-muted-foreground underline decoration-dotted hover:text-foreground"
|
||||
>
|
||||
Quoted note unavailable here — it may live on another relay. Open it anyway.
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/nostr/RepostButton.tsx
Normal file
114
src/components/nostr/RepostButton.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Quote, Repeat2 } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { QuoteDialog } from './QuoteDialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useReposts, summarizeReposts, useToggleRepost } from '@/hooks/useReposts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RepostButtonProps {
|
||||
target: NostrEvent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reposts or quote-posts `target` per NIP-18, from the feed or a thread. A
|
||||
* plain repost re-shares the note verbatim without losing its context; a
|
||||
* quote post opens a composer for the user's own commentary alongside a
|
||||
* clear, navigable reference back to the original.
|
||||
*/
|
||||
export function RepostButton({ target, className }: RepostButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const reposts = useReposts(target.id);
|
||||
const toggle = useToggleRepost();
|
||||
|
||||
const { count, byAuthor } = summarizeReposts(reposts.data);
|
||||
const own = user ? byAuthor.get(user.pubkey) : undefined;
|
||||
const reposted = Boolean(own);
|
||||
// Kind 6/16 isn't replaceable, so un-reposting needs to clear every one of
|
||||
// the viewer's own repost events on this note, not just the latest.
|
||||
const ownReposts = user ? (reposts.data ?? []).filter((event) => event.pubkey === user.pubkey) : [];
|
||||
|
||||
const requiresAuth = () => {
|
||||
if (!user) {
|
||||
setAuthOpen(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRepost = () => {
|
||||
if (requiresAuth()) return;
|
||||
toggle.mutate(
|
||||
{ target, ownReposts: reposted ? ownReposts : undefined },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: reposted ? 'Could not undo repost' : 'Could not repost',
|
||||
description: error instanceof Error ? error.message : 'No relay accepted the update.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleQuote = () => {
|
||||
if (requiresAuth()) return;
|
||||
setQuoteOpen(true);
|
||||
};
|
||||
|
||||
const label = reposted
|
||||
? `Reposted${count > 0 ? ` — ${count} ${count === 1 ? 'repost' : 'reposts'}` : ''}`
|
||||
: `Repost${count > 0 ? ` — ${count} ${count === 1 ? 'repost' : 'reposts'}` : ''}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs text-muted-foreground', reposted && 'text-emerald-500', className)}
|
||||
disabled={toggle.isPending}
|
||||
aria-pressed={reposted}
|
||||
aria-label={label}
|
||||
>
|
||||
{toggle.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Repeat2 className="size-3.5" aria-hidden />
|
||||
)}
|
||||
<span aria-hidden>{count > 0 ? count : ''}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={handleRepost}>
|
||||
<Repeat2 className="size-3.5" aria-hidden />
|
||||
{reposted ? 'Undo repost' : 'Repost'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleQuote}>
|
||||
<Quote className="size-3.5" aria-hidden />
|
||||
Quote post
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
<QuoteDialog target={target} isOpen={quoteOpen} onClose={() => setQuoteOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
src/hooks/useNote.ts
Normal file
21
src/hooks/useNote.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** A single event by id, e.g. a thread root, a reposted note, or a quoted note. */
|
||||
export function useNote(id: string | undefined, relays: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['nostr', 'note', id ?? '', relays?.join(',') ?? ''],
|
||||
enabled: Boolean(id),
|
||||
queryFn: async ({ signal }) => {
|
||||
const [event] = await nostr.query([{ ids: [id!] }], {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
|
||||
relays,
|
||||
});
|
||||
return event ?? null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
87
src/hooks/useReposts.test.ts
Normal file
87
src/hooks/useReposts.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { buildQuoteReference, parseEmbeddedRepost, repostReference, summarizeReposts } from './useReposts';
|
||||
|
||||
function repost(pubkey: string, createdAt: number, tags: string[][] = [], content = ''): NostrEvent {
|
||||
return { id: `${pubkey}-${createdAt}`, pubkey, created_at: createdAt, kind: 6, tags, content, sig: '' };
|
||||
}
|
||||
|
||||
function note(overrides: Partial<NostrEvent> = {}): NostrEvent {
|
||||
return {
|
||||
id: 'note-id',
|
||||
pubkey: 'note-author',
|
||||
created_at: 100,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
sig: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeReposts', () => {
|
||||
it('counts each author once', () => {
|
||||
const events = [repost('alice', 1), repost('bob', 2)];
|
||||
expect(summarizeReposts(events).count).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps only the latest repost per author', () => {
|
||||
const events = [repost('alice', 1), repost('alice', 2)];
|
||||
const summary = summarizeReposts(events);
|
||||
expect(summary.count).toBe(1);
|
||||
expect(summary.byAuthor.get('alice')?.created_at).toBe(2);
|
||||
});
|
||||
|
||||
it('returns zero for no reposts', () => {
|
||||
expect(summarizeReposts(undefined).count).toBe(0);
|
||||
expect(summarizeReposts([]).byAuthor.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repostReference', () => {
|
||||
it('reads the id and relay hint from the required e tag', () => {
|
||||
const event = repost('alice', 1, [['e', 'target-id', 'wss://relay.example']]);
|
||||
expect(repostReference(event)).toEqual({ id: 'target-id', relay: 'wss://relay.example' });
|
||||
});
|
||||
|
||||
it('omits relay when the e tag has no third entry', () => {
|
||||
const event = repost('alice', 1, [['e', 'target-id']]);
|
||||
expect(repostReference(event)).toEqual({ id: 'target-id', relay: undefined });
|
||||
});
|
||||
|
||||
it('returns null when there is no e tag', () => {
|
||||
const event = repost('alice', 1, [['p', 'someone']]);
|
||||
expect(repostReference(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEmbeddedRepost', () => {
|
||||
it('parses a valid embedded note', () => {
|
||||
const original = note();
|
||||
const event = repost('alice', 1, [['e', original.id]], JSON.stringify(original));
|
||||
expect(parseEmbeddedRepost(event)).toEqual(original);
|
||||
});
|
||||
|
||||
it('returns null for empty content, per NIP-18 (e.g. NIP-70-protected notes)', () => {
|
||||
const event = repost('alice', 1, [['e', 'target-id']], '');
|
||||
expect(parseEmbeddedRepost(event)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed JSON instead of throwing', () => {
|
||||
const event = repost('alice', 1, [['e', 'target-id']], '{not json');
|
||||
expect(parseEmbeddedRepost(event)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the JSON is not a well-formed event', () => {
|
||||
const event = repost('alice', 1, [['e', 'target-id']], JSON.stringify({ foo: 'bar' }));
|
||||
expect(parseEmbeddedRepost(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuoteReference', () => {
|
||||
it('produces a nostr: nevent reference', () => {
|
||||
const target = note({ id: '1'.repeat(64), pubkey: '2'.repeat(64) });
|
||||
const reference = buildQuoteReference(target, []);
|
||||
expect(reference).toMatch(/^nostr:nevent1[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
196
src/hooks/useReposts.ts
Normal file
196
src/hooks/useReposts.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useRelayHints } from './useRelayHints';
|
||||
|
||||
/**
|
||||
* NIP-18 reposts: kind 6 is reserved for reposting kind-1 notes; kind 16 is
|
||||
* the "generic repost" used for anything else.
|
||||
*/
|
||||
export const REPOST_KIND = 6;
|
||||
export const GENERIC_REPOST_KIND = 16;
|
||||
const DELETION_KIND = 5;
|
||||
|
||||
function repostsQueryKey(eventId: string) {
|
||||
return ['nostr', 'reposts', eventId] as const;
|
||||
}
|
||||
|
||||
/** All kind-6/16 reposts of `eventId`, newest first. */
|
||||
export function useReposts(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: repostsQueryKey(eventId ?? ''),
|
||||
enabled: Boolean(eventId),
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [REPOST_KIND, GENERIC_REPOST_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,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RepostSummary {
|
||||
/** Distinct authors who currently have a repost of the target. */
|
||||
count: number;
|
||||
byAuthor: Map<string, NostrEvent>;
|
||||
}
|
||||
|
||||
/** Only the most recent repost per author — an author can end up with more
|
||||
* than one over time (retries, multiple devices), but it's a single repost. */
|
||||
export function summarizeReposts(events: NostrEvent[] | undefined): RepostSummary {
|
||||
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 { count: byAuthor.size, byAuthor };
|
||||
}
|
||||
|
||||
/**
|
||||
* The event embedded in a NIP-18 repost's `content`, if the reposter chose to
|
||||
* include one. Content is optional per spec (and always empty for reposts of
|
||||
* NIP-70-protected notes), and a repost of a NIP-70-protected note or a
|
||||
* malformed/mismatched blob is treated the same as "not included" — callers
|
||||
* fall back to fetching the note by its `e` tag instead.
|
||||
*/
|
||||
export function parseEmbeddedRepost(event: NostrEvent): NostrEvent | null {
|
||||
const trimmed = event.content.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Partial<NostrEvent>;
|
||||
if (
|
||||
typeof parsed.id === 'string' &&
|
||||
typeof parsed.pubkey === 'string' &&
|
||||
typeof parsed.content === 'string' &&
|
||||
typeof parsed.created_at === 'number' &&
|
||||
typeof parsed.kind === 'number' &&
|
||||
Array.isArray(parsed.tags)
|
||||
) {
|
||||
return parsed as NostrEvent;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The id and relay hint carried by a repost's required `e` tag, or null if missing. */
|
||||
export function repostReference(event: NostrEvent): { id: string; relay?: string } | null {
|
||||
const tag = event.tags.find(([name, value]) => name === 'e' && Boolean(value));
|
||||
if (!tag) return null;
|
||||
return { id: tag[1], relay: tag[2] || undefined };
|
||||
}
|
||||
|
||||
interface ToggleRepostInput {
|
||||
/** The note being reposted. */
|
||||
target: NostrEvent;
|
||||
/** The signed-in user's own repost events on `target`, if any — pass to un-repost. */
|
||||
ownReposts?: NostrEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reposts or un-reposts `target`. Un-reposting publishes a NIP-09 deletion
|
||||
* covering *all* of the viewer's own repost events on the target (kind 6/16
|
||||
* is not replaceable, so more than one can accumulate), the same approach
|
||||
* `useToggleReaction` takes for likes.
|
||||
*/
|
||||
export function useToggleRepost() {
|
||||
const { user } = useCurrentUser();
|
||||
const publish = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const hints = useRelayHints();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ target, ownReposts }: ToggleRepostInput) => {
|
||||
if (!user) throw new Error('Sign in to repost');
|
||||
if (ownReposts && ownReposts.length > 0) {
|
||||
return publish.mutateAsync({
|
||||
kind: DELETION_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
...ownReposts.map((event): [string, string] => ['e', event.id]),
|
||||
['k', String(ownReposts[0].kind)],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const relay = hints[0] ?? '';
|
||||
const isKind1 = target.kind === 1;
|
||||
return publish.mutateAsync({
|
||||
kind: isKind1 ? REPOST_KIND : GENERIC_REPOST_KIND,
|
||||
// Recommended by NIP-18 so the repost stands on its own; omitted
|
||||
// entirely for NIP-70-protected notes would be a further refinement,
|
||||
// but this app doesn't yet surface that protection marker.
|
||||
content: JSON.stringify(target),
|
||||
tags: [
|
||||
['e', target.id, relay],
|
||||
['p', target.pubkey],
|
||||
...(isKind1 ? [] : [['k', String(target.kind)]]),
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: (_publishedEvent, { target }) => {
|
||||
// Reposts are eventually consistent across relays like reactions are;
|
||||
// a plain invalidate (rather than an optimistic merge) is enough here
|
||||
// since — unlike likes — there's no immediate toggle-back interaction
|
||||
// that depends on the freshly published event's real id.
|
||||
queryClient.invalidateQueries({ queryKey: repostsQueryKey(target.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `nostr:nevent...` reference a quote post embeds in its `content` so
|
||||
* that clients without `q`-tag support still render a clickable mention of
|
||||
* the quoted note.
|
||||
*/
|
||||
export function buildQuoteReference(target: NostrEvent, relays: string[]): string {
|
||||
const nevent = nip19.neventEncode({
|
||||
id: target.id,
|
||||
author: target.pubkey,
|
||||
kind: target.kind,
|
||||
relays,
|
||||
});
|
||||
return `nostr:${nevent}`;
|
||||
}
|
||||
|
||||
interface CreateQuotePostInput {
|
||||
/** The note being quoted. */
|
||||
target: NostrEvent;
|
||||
/** The quoting user's own commentary; may be empty. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a NIP-18 quote post: a regular kind-1 note carrying a `q` tag to
|
||||
* the quoted event (so it isn't mistaken for a reply in threads) plus an
|
||||
* embedded `nostr:` reference in the content for wider compatibility.
|
||||
*/
|
||||
export function useCreateQuotePost() {
|
||||
const publish = useNostrPublish();
|
||||
const hints = useRelayHints();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ target, content }: CreateQuotePostInput) => {
|
||||
const trimmed = content.trim();
|
||||
const reference = buildQuoteReference(target, hints);
|
||||
return publish.mutateAsync({
|
||||
kind: 1,
|
||||
content: trimmed ? `${trimmed}\n\n${reference}` : reference,
|
||||
tags: [
|
||||
['q', target.id, hints[0] ?? '', target.pubkey],
|
||||
['p', target.pubkey],
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user