Render unknown event kinds with a NIP-31 alt-tag fallback

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.
This commit is contained in:
Alex Gleason
2026-04-28 13:50:20 -05:00
parent 00412385c8
commit 9813a226ec
8 changed files with 144 additions and 7 deletions
+2 -2
View File
@@ -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",
+6
View File
@@ -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 };
}
+27 -5
View File
@@ -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({
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
)}
</>
) : isUnsupportedKind ? (
<p className="text-sm italic text-muted-foreground">
This event kind is not supported
</p>
) : (
<EmbedTruncatedContent event={event} expanded={contentExpanded} onOverflowChange={setContentOverflows} />
)}
+1
View File
@@ -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;
+9
View File
@@ -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({
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} lookMode="follow-pointer" />
</Suspense>
) : isUnknownKind ? (
<UnknownKindContent event={event} />
) : (
<TruncatedNoteContent
event={event}
+58
View File
@@ -0,0 +1,58 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { getEventFallbackText } from '@/lib/extraKinds';
import { cn } from '@/lib/utils';
interface UnknownKindContentProps {
event: NostrEvent;
/** When true, renders a larger variant for the detail page. */
expanded?: boolean;
className?: string;
}
/**
* Fallback renderer for event kinds this client doesn't know how to display.
*
* Never runs the text-note tokenizer (URLs, hashtags, nostr: mentions) over
* arbitrary content — that would misinterpret JSON or empty bodies as kind 1.
* Surfaces the NIP-31 `alt` tag (with fallbacks to title/name/summary/d), or a
* neutral tombstone when nothing is available.
*/
export function UnknownKindContent({ event, expanded = false, className }: UnknownKindContentProps) {
const fallbackText = getEventFallbackText(event);
if (fallbackText) {
return (
<div
className={cn(
'rounded-xl border border-border bg-secondary/30 overflow-hidden',
expanded ? 'mt-3 p-4' : 'mt-2 p-3',
className,
)}
>
<p
className={cn(
'whitespace-pre-wrap break-words text-foreground',
expanded ? 'text-[15px] leading-relaxed' : 'text-sm leading-relaxed',
)}
>
{fallbackText}
</p>
</div>
);
}
return (
<div
className={cn(
'rounded-2xl border border-dashed border-border',
expanded ? 'mt-3' : 'mt-2',
className,
)}
>
<div className="px-3.5 py-4 text-center text-sm text-muted-foreground">
This event kind is not supported
</div>
</div>
);
}
+30
View File
@@ -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')
);
}
+11
View File
@@ -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 && <PeopleListContent event={event} />}
{isEmojiPack && <EmojiPackContent event={event} />}
</>
) : isUnknownKind ? (
<div className="mt-3">
<UnknownKindContent event={event} expanded />
</div>
) : (
<div className="mt-3">
<NoteContent