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