Add NIP-18 reposts and quote posts for notes

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:04:53 +00:00
committed by highperfocused
parent d7d7e2ab9b
commit 64280470b5
9 changed files with 666 additions and 24 deletions

View File

@@ -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)]),

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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,
});
}

View 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
View 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],
],
});
},
});
}