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 ? (
+
+
+
) : (