From cf2f46677213fd0278160c49e5b71703fdb8ef0b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Apr 2026 14:44:48 -0500 Subject: [PATCH] Restrict unknown-kind previews to the NIP-31 alt tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alt-tag fallback shipped in 9813a226 let several display paths fall through to other tags (title, name, summary, description, d) or to the raw content when alt was absent. For an unknown kind like attestr.xyz (31871), that surfaced the opaque d-tag identifier 'e5272de9:289bce03a0b7:1777206698' as the preview and leaked raw content into the hover-card reply indicator — both worse than the 'This event kind is not supported' tombstone the feature was meant to produce. Tighten the fallback everywhere an unknown kind might render: - getEventFallbackText: only the NIP-31 alt tag; no title/name/d. - CommentContext.getEventDisplayName: known kinds keep title/name/d, unknown kinds consider only alt. getKindLabel returns 'an unsupported event' instead of 'a post', so 'Commenting on ...' never implies the root is a text note. - EmbeddedNote tagMeta: alt only, no title/name/description fallback. - ExternalContentHeader.AddressableEventPreview: drop d-tag fallback. - EmbeddedNaddr: gate rich title/description/content rendering behind a known-kind check; unknown kinds render UnknownKindContent instead of extractMetadata, which was leaking plaintext content as the description when the body wasn't JSON. Regression-of: 9813a226 --- package-lock.json | 4 +- src/components/CommentContext.tsx | 18 +++++++-- src/components/EmbeddedNaddr.tsx | 48 ++++++++++++++++-------- src/components/EmbeddedNote.tsx | 18 ++++----- src/components/ExternalContentHeader.tsx | 5 +-- src/lib/extraKinds.ts | 31 +++++---------- 6 files changed, 65 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51ee3c6c..1c60cfb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.11.0", + "version": "2.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.11.0", + "version": "2.11.1", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index a8ebb646..a9f6181f 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -208,9 +208,11 @@ const KIND_ICONS: Partial name === 'title')?.[1]; const name = event.tags.find(([name]) => name === 'name')?.[1]; const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + const alt = event.tags.find(([name]) => name === 'alt')?.[1]?.trim(); const displayTitle = title || name || dTag; // Kinds with a custom postfix (e.g. "Ditto on Zapstore") @@ -291,10 +294,17 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co return { text: `${displayTitle} ${suffix}`, icon }; } - // Generic: just use the title if available - if (displayTitle) return { text: displayTitle, icon }; + // Known kinds: use the conventional title/name/d tag if available. + if (KIND_LABELS[event.kind] && displayTitle) { + return { text: displayTitle, icon }; + } - // Fall back to kind label + // Unknown kinds: only trust the NIP-31 `alt` tag. title/name/d have + // kind-specific semantics we can't interpret; `d` in particular is often + // an opaque compound identifier. + if (alt) return { text: alt, icon }; + + // Fall back to kind label ("an unsupported event" for unknown kinds). return { text: getKindLabel(event.kind), icon }; } diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 407606c9..455306fd 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -25,6 +25,7 @@ import { isProfileBadgesKind } from '@/lib/badgeUtils'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; +import { UnknownKindContent } from '@/components/UnknownKindContent'; interface EmbeddedNaddrProps { /** The decoded naddr coordinates. */ @@ -353,7 +354,17 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); }, [event]); - const { title, description, image } = useMemo(() => extractMetadata(event), [event]); + // Known kinds (articles, streams, themes, etc.) get rich title/description/ + // content rendering. Unknown kinds never do — we can't assume arbitrary + // content is safe user-facing text, so we fall back to a NIP-31 `alt` + // preview or a tombstone. + const kindLabel = getKindLabel(event.kind); + const isKnownKind = kindLabel !== undefined; + + const { title, description, image } = useMemo( + () => (isKnownKind ? extractMetadata(event) : { title: undefined, description: undefined, image: undefined }), + [isKnownKind, event], + ); const truncatedDesc = useMemo(() => { if (!description) return undefined; @@ -363,10 +374,9 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos // Kind label for context (e.g. "nsite" with icon) const kindMeta = useMemo(() => { - const label = getKindLabel(event.kind); - if (!label) return undefined; - return { label, Icon: getKindIcon(event.kind) }; - }, [event.kind]); + if (!kindLabel) return undefined; + return { label: kindLabel, Icon: getKindIcon(event.kind) }; + }, [kindLabel, event.kind]); return ( - {/* Title */} - {title && ( -

- {title} -

- )} + {isKnownKind ? ( + <> + {/* Title */} + {title && ( +

+ {title} +

+ )} - {/* Description */} - {truncatedDesc && ( -

- {truncatedDesc} -

+ {/* Description */} + {truncatedDesc && ( +

+ {truncatedDesc} +

+ )} + + ) : ( + )} {/* Kind label and attachment indicators */} diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 7e636209..ca357ec1 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -346,17 +346,13 @@ function EmbeddedNoteCard({ const tagMeta = useMemo(() => { // Content kinds with real content always render that content below. if (isContentKind && hasContent) return undefined; - const getTag = (name: string) => { - const v = event.tags.find(([n]) => n === name)?.[1]; - return v && v.trim().length > 0 ? v.trim() : undefined; - }; - // NIP-31 `alt` is the author's own fallback. Prefer title/name for - // addressable content that has those conventional tags; otherwise - // use alt or the d-tag identifier. - const title = getTag('title') || getTag('name') || getTag('alt') || getTag('d'); - const description = getTag('summary') || getTag('description'); - if (!title && !description) return undefined; - return { title, description }; + // NIP-31 `alt` is the author's own fallback for clients that can't + // render the kind. Other tags (title, name, d, …) have kind-specific + // semantics and are not reliably safe as user-facing preview text. + const altTag = event.tags.find(([n]) => n === 'alt')?.[1]; + const title = altTag && altTag.trim().length > 0 ? altTag.trim() : undefined; + if (!title) return undefined; + return { title, description: undefined as string | undefined }; }, [isContentKind, hasContent, event.tags]); // Unknown/unsupported kind with no displayable tags and no content-kind body. diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index b7d561ad..25775733 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1255,10 +1255,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey return FileText; }, [kindDef, addr.kind]); - const title = event?.tags.find(([n]) => n === 'title')?.[1] - || event?.tags.find(([n]) => n === 'name')?.[1] - || event?.tags.find(([n]) => n === 'alt')?.[1] - || event?.tags.find(([n]) => n === 'd')?.[1] + const title = event?.tags.find(([n]) => n === 'alt')?.[1] || kindLabel; const thumbnail = event ? extractThumbnail(event.tags) : undefined; const isVideo = event ? hasVideo(event.tags) : false; diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 95fa312f..81b20e85 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -693,30 +693,17 @@ export function getAllExtraKindNumbers(): number[] { } /** - * Extract a human-readable fallback label from an event for display by clients - * that don't know how to render the event's kind. + * Extract the NIP-31 `alt` tag — the author's own human-readable fallback + * text for clients that don't know how to render the event's kind. * - * Resolution order (per NIP-31): - * 1. NIP-31 `alt` tag — the author's own fallback text - * 2. `title` tag — common on articles, calendar events, streams, podcasts, etc. - * 3. `name` tag — common on badges, emoji packs, people lists - * 4. `summary` or `description` tag - * 5. `d` tag — the addressable identifier (last resort, often a slug) + * Only `alt` is consulted. Other tags (`title`, `name`, `summary`, + * `description`, `d`) are intentionally excluded: they have kind-specific + * semantics and are not guaranteed to be safe user-facing text. When `alt` + * is missing, callers should render a neutral "unsupported kind" tombstone. * - * Returns `undefined` if the event carries no displayable text at all. + * Returns `undefined` if the event has no `alt` tag (or it's blank). */ export function getEventFallbackText(event: NostrEvent): string | undefined { - const getTag = (name: string) => { - const value = event.tags.find(([n]) => n === name)?.[1]; - return value && value.trim().length > 0 ? value.trim() : undefined; - }; - - return ( - getTag('alt') || - getTag('title') || - getTag('name') || - getTag('summary') || - getTag('description') || - getTag('d') - ); + const alt = event.tags.find(([n]) => n === 'alt')?.[1]; + return alt && alt.trim().length > 0 ? alt.trim() : undefined; }