From 9813a226ecde7b3db2b61340ddaf43b73007b1ba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Apr 2026 13:50:20 -0500 Subject: [PATCH] Render unknown event kinds with a NIP-31 alt-tag fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any kind not explicitly handled by NoteCard or PostDetailContent fell through to the kind-1 text-note renderer, which ran the URL/hashtag/nostr: tokenizer over arbitrary content — broken for events whose content is JSON or empty. Add an UnknownKindContent component that displays the NIP-31 'alt' tag (falling back to title/name/summary/d) in a rounded card, or a dashed 'This event kind is not supported' tombstone when the event carries no fallback text. Route to it from both dispatchers when the kind isn't 1, 11, or 1111. Extend the same handling to embedded quote previews (EmbeddedNote, EmbeddedNaddr, AddressableEventPreview) so reply-context hover cards, compose previews, more-menu previews, notification references, and inline nostr: mentions all display unknown kinds consistently instead of feeding JSON or arbitrary content to the kind-1 tokenizer. --- package-lock.json | 4 +- src/components/EmbeddedNaddr.tsx | 6 +++ src/components/EmbeddedNote.tsx | 32 +++++++++++-- src/components/ExternalContentHeader.tsx | 1 + src/components/NoteCard.tsx | 9 ++++ src/components/UnknownKindContent.tsx | 58 ++++++++++++++++++++++++ src/lib/extraKinds.ts | 30 ++++++++++++ src/pages/PostDetailPage.tsx | 11 +++++ 8 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 src/components/UnknownKindContent.tsx diff --git a/package-lock.json b/package-lock.json index c58ca950..51ee3c6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.10.5", + "version": "2.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.10.5", + "version": "2.11.0", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 0e1c7c50..407606c9 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -48,6 +48,8 @@ function extractMetadata(event: NostrEvent): { let title = getTag('title') || getTag('name'); let description = getTag('summary') || getTag('description'); let image = getTag('image') || getTag('thumb') || getTag('banner'); + // NIP-31 fallback — the author's own "display me like this" string. + const alt = getTag('alt'); // Try parsing JSON content for additional metadata if (event.content) { @@ -67,6 +69,10 @@ function extractMetadata(event: NostrEvent): { } } + // Final NIP-31 fallback — use alt for whichever field is still missing. + if (!title && alt) title = alt; + else if (!description && alt) description = alt; + return { title, description, image }; } diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index c858c090..7e636209 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -309,6 +309,13 @@ function EmbeddedNoteCard({ const isBlobbiState = event.kind === 31124; const isPhoto = event.kind === 20; + // Kinds whose `content` is a human-readable body/caption and can safely + // be fed through the kind-1 tokenizer for preview. Everything else + // (articles, streams, videos, calendar events, themes, polls, voice + // messages, unknown custom kinds, …) should prefer a tag-based summary + // — otherwise we'd parse JSON or arbitrary content as text. + const isContentKind = + event.kind === 1 || event.kind === 11 || event.kind === 1111 || isPhoto; // Attachment counts for indicator chips const attachments = useMemo(() => { @@ -333,16 +340,27 @@ function EmbeddedNoteCard({ return { label, Icon: getKindIcon(event.kind) }; }, [event.kind]); - // Tag-based fallback metadata for events with empty content (articles, custom kinds, etc.) + // Tag-based fallback metadata for non-content kinds (articles, custom + // kinds, etc.) and for text notes that happen to have empty content. const hasContent = event.content.trim().length > 0; const tagMeta = useMemo(() => { - if (hasContent) return undefined; - const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; - const title = getTag('title') || getTag('name') || getTag('d'); + // 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 }; - }, [hasContent, event.tags]); + }, [isContentKind, hasContent, event.tags]); + + // Unknown/unsupported kind with no displayable tags and no content-kind body. + const isUnsupportedKind = !isContentKind && !isBlobbiState && !tagMeta; // NIP-36 content-warning check const cwTag = event.tags.find(([name]) => name === 'content-warning'); @@ -385,6 +403,10 @@ function EmbeddedNoteCard({

{tagMeta.description}

)} + ) : isUnsupportedKind ? ( +

+ This event kind is not supported +

) : ( )} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index af1eb525..b7d561ad 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1257,6 +1257,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey 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] || kindLabel; const thumbnail = event ? extractThumbnail(event.tags) : undefined; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 534959e7..8f1a96a9 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -80,6 +80,7 @@ import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { ThemeContent } from "@/components/ThemeContent"; +import { UnknownKindContent } from "@/components/UnknownKindContent"; import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { EncryptedLetterContent } from "@/components/EncryptedLetterContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; @@ -468,6 +469,12 @@ export const NoteCard = memo(function NoteCard({ const isComment = event.kind === 1111; const isReply = isTextNote && !isComment && isReplyEvent(event); + // Unknown kinds land in the `isTextNote` branch (it's the negation of every + // known-kind flag above). For anything other than real text-note kinds + // (1 / 11 / 1111), render a NIP-31 fallback instead of feeding arbitrary + // content into the kind-1 tokenizer. + const isUnknownKind = + isTextNote && event.kind !== 1 && event.kind !== 11 && event.kind !== 1111; // Find all people being replied to (for "Replying to @user1 and @user2") const replyToPubkeys = useMemo(() => { @@ -666,6 +673,8 @@ export const NoteCard = memo(function NoteCard({ }> + ) : isUnknownKind ? ( + ) : ( +

+ {fallbackText} +

+ + ); + } + + return ( +
+
+ This event kind is not supported +
+
+ ); +} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 9d8f2f35..95fa312f 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,4 +1,5 @@ import type { FeedSettings } from '@/contexts/AppContext'; +import type { NostrEvent } from '@nostrify/nostrify'; import type { ComponentType } from 'react'; import { Bird, Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert, Stars, UserCheck, Users } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; @@ -690,3 +691,32 @@ export function getAllExtraKindNumbers(): number[] { return kinds; } + +/** + * Extract a human-readable fallback label from an event for display by 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) + * + * Returns `undefined` if the event carries no displayable text at all. + */ +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') + ); +} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 790e8054..d155505c 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -60,6 +60,7 @@ import { MusicDetailContent } from "@/components/MusicDetailContent"; import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard"; import { publishedAtAction } from "@/lib/publishedAtAction"; import { NoteContent } from "@/components/NoteContent"; +import { UnknownKindContent } from "@/components/UnknownKindContent"; import { NsiteCard } from "@/components/NsiteCard"; import { NoteMoreMenu } from "@/components/NoteMoreMenu"; import { PostActionBar } from "@/components/PostActionBar"; @@ -1081,6 +1082,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isBlobbiState && !isBadgeAward; + // Unknown kinds land in the `isTextNote` branch (negation of every known flag + // above). For anything other than real text-note kinds (1 / 11 / 1111) we + // render a NIP-31 fallback instead of treating arbitrary content as kind 1. + const isUnknownKind = + isTextNote && event.kind !== 1 && event.kind !== 11 && event.kind !== 1111; + const { data: stats } = useEventStats(event.id, event); const { data: interactions } = useEventInteractions(event.id); @@ -2205,6 +2212,10 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isPeopleList && } {isEmojiPack && } + ) : isUnknownKind ? ( +
+ +
) : (