Restrict unknown-kind previews to the NIP-31 alt tag

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
This commit is contained in:
Alex Gleason
2026-04-28 14:44:48 -05:00
parent 71fe5aaa3a
commit cf2f466772
6 changed files with 65 additions and 59 deletions
+2 -2
View File
@@ -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",
+14 -4
View File
@@ -208,9 +208,11 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
* Get a singular comment-context label for a kind number.
* Only uses KIND_LABELS (which has proper singular forms with articles).
* Never falls through to EXTRA_KINDS labels since those are plural/categorical.
* Unknown kinds render as "an unsupported event" — never as "a post", which
* would misrepresent arbitrary event kinds as text notes.
*/
function getKindLabel(kind: number): string {
return KIND_LABELS[kind] ?? 'a post';
return KIND_LABELS[kind] ?? 'an unsupported event';
}
/** Parse a rootKind string into a label, handling both numeric and external content kinds. */
@@ -277,6 +279,7 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
const title = event.tags.find(([name]) => 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 };
}
+32 -16
View File
@@ -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 (
<EmbeddedCardShell
@@ -376,18 +386,24 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos
className={className}
disableHoverCards={disableHoverCards}
>
{/* Title */}
{title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{title}
</p>
)}
{isKnownKind ? (
<>
{/* Title */}
{title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{title}
</p>
)}
{/* Description */}
{truncatedDesc && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{truncatedDesc}
</p>
{/* Description */}
{truncatedDesc && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{truncatedDesc}
</p>
)}
</>
) : (
<UnknownKindContent event={event} className="mt-0" />
)}
{/* Kind label and attachment indicators */}
+7 -11
View File
@@ -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.
+1 -4
View File
@@ -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;
+9 -22
View File
@@ -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;
}