diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index 294c182..889fdb2 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -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)]), diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index c706c25..3472f41 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -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({ - 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) {

{absoluteTime(event.created_at)}

+ diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index e851c01..5db8052 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -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 ; + } + + 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) {
+ {quoteTag && } + {!compact && (
+
@@ -90,3 +116,55 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { ); } + +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 ( +
+
+ + + reposted +
+ + {original ? ( + + ) : fetched.isLoading ? ( +
+ + +
+ ) : ( +

+ This note is unavailable — it may live on another relay. +

+ )} +
+ ); +} diff --git a/src/components/nostr/QuoteDialog.tsx b/src/components/nostr/QuoteDialog.tsx new file mode 100644 index 0000000..1f12c7f --- /dev/null +++ b/src/components/nostr/QuoteDialog.tsx @@ -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 ( + + + + Quote post + + +