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:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user