From ce18567900f0762757b82aa3d37d8775306e0d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Tue, 16 Dec 2025 23:34:28 +0100 Subject: [PATCH] feat: kind 11 and 1111 renderer, show reply and root --- src/components/nostr/MediaEmbed.tsx | 185 ++++++++++------- .../nostr/kinds/Kind1111Renderer.tsx | 196 ++++++++++++++++++ src/components/nostr/kinds/NoteRenderer.tsx | 187 +++++++++++++---- src/components/nostr/kinds/index.tsx | 5 +- 4 files changed, 462 insertions(+), 111 deletions(-) create mode 100644 src/components/nostr/kinds/Kind1111Renderer.tsx diff --git a/src/components/nostr/MediaEmbed.tsx b/src/components/nostr/MediaEmbed.tsx index 7d87c86..b39b787 100644 --- a/src/components/nostr/MediaEmbed.tsx +++ b/src/components/nostr/MediaEmbed.tsx @@ -33,24 +33,24 @@ interface MediaEmbedProps { const PRESETS = { inline: { - maxHeight: "300px", - maxWidth: "100%", - rounded: "rounded-lg", + maxHeightClass: "max-h-[300px]", + maxWidthClass: "max-w-full", + roundedClass: "rounded-lg", }, thumbnail: { - maxWidth: "120px", - maxHeight: "120px", - rounded: "rounded-md", + maxHeightClass: "max-h-[120px]", + maxWidthClass: "max-w-[120px]", + roundedClass: "rounded-md", }, preview: { - maxHeight: "500px", - maxWidth: "100%", - rounded: "rounded-lg", + maxHeightClass: "max-h-[500px]", + maxWidthClass: "max-w-full", + roundedClass: "rounded-lg", }, banner: { - maxHeight: "200px", - maxWidth: "100%", - rounded: "rounded-xl", + maxHeightClass: "max-h-[200px]", + maxWidthClass: "max-w-full", + roundedClass: "rounded-xl", }, } as const; @@ -66,15 +66,37 @@ const getDefaultAspectRatio = ( return undefined; // auto for images }; +/** + * Convert aspect ratio string to Tailwind class + */ +const getAspectRatioClass = ( + aspectRatio: string | undefined, +): string | undefined => { + if (!aspectRatio || aspectRatio === "auto") return undefined; + + // Map common ratios to Tailwind utilities + switch (aspectRatio) { + case "16/9": + return "aspect-video"; + case "1/1": + return "aspect-square"; + case "4/3": + return "aspect-[4/3]"; + case "3/2": + return "aspect-[3/2]"; + default: + // For custom ratios, use arbitrary value syntax + return `aspect-[${aspectRatio}]`; + } +}; + /** * Skeleton placeholder component with shimmer effect */ const SkeletonPlaceholder = ({ - aspectRatio, rounded, children, }: { - aspectRatio?: string; rounded: string; children?: React.ReactNode; }) => ( @@ -83,7 +105,6 @@ const SkeletonPlaceholder = ({ "absolute inset-0 bg-muted/20 animate-pulse flex items-center justify-center", rounded, )} - style={aspectRatio ? { aspectRatio } : undefined} aria-busy="true" aria-label="Loading media" > @@ -102,7 +123,7 @@ const SkeletonPlaceholder = ({ * - Aspect ratio preservation to prevent layout shift * - Smooth fade-in animations * - Error handling with retry mechanism - * - Performance optimized with CSS containment + * - Performance optimized with Tailwind classes */ export function MediaEmbed({ url, @@ -142,6 +163,8 @@ export function MediaEmbed({ const effectiveAspectRatio = aspectRatio || getDefaultAspectRatio(mediaType, preset); + const aspectClass = getAspectRatioClass(effectiveAspectRatio); + // Reset states when URL changes useEffect(() => { setIsLoading(true); @@ -211,29 +234,54 @@ export function MediaEmbed({ // Image rendering with zoom, placeholder, and fade-in if (mediaType === "image") { - const imageContent = ( + const imageContent = aspectClass ? ( + // With aspect ratio: use aspect wrapper
+
+ {/* Skeleton placeholder */} + {showPlaceholder && isLoading && ( + + )} + + {/* Image with fade-in */} + {alt +
+
+ ) : ( + // Without aspect ratio: direct image with size constraints +
{/* Skeleton placeholder */} {showPlaceholder && isLoading && ( - + )} {/* Image with fade-in */} @@ -243,8 +291,8 @@ export function MediaEmbed({ alt={alt || "Image"} loading="lazy" className={cn( - "w-full h-full object-contain", - presetStyles.rounded, + "max-w-full max-h-full object-contain", + presetStyles.roundedClass, enableZoom && "cursor-zoom-in", fadeIn && "transition-opacity duration-300", isLoaded ? "opacity-100" : "opacity-0", @@ -268,41 +316,38 @@ export function MediaEmbed({ if (mediaType === "video") { return (
- {/* Skeleton placeholder with play icon */} - {showPlaceholder && isLoading && ( - - - + className={cn( + "relative overflow-hidden contain-content", + presetStyles.maxWidthClass, + presetStyles.roundedClass, )} - - {/* Video with fade-in */} -
); } diff --git a/src/components/nostr/kinds/Kind1111Renderer.tsx b/src/components/nostr/kinds/Kind1111Renderer.tsx new file mode 100644 index 0000000..45469e9 --- /dev/null +++ b/src/components/nostr/kinds/Kind1111Renderer.tsx @@ -0,0 +1,196 @@ +import { RichText } from "../RichText"; +import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; +import { + getCommentReplyPointer, + getCommentRootPointer, + isCommentAddressPointer, + isCommentEventPointer, + type CommentPointer, +} from "applesauce-core/helpers/comment"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { UserName } from "../UserName"; +import { Reply, MessageSquare } from "lucide-react"; +import { useGrimoire } from "@/core/state"; +import { InlineReplySkeleton } from "@/components/ui/skeleton"; +import { KindBadge } from "@/components/KindBadge"; +import { getEventDisplayTitle } from "@/lib/event-title"; +import type { NostrEvent } from "@/types/nostr"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +/** + * Convert CommentPointer to pointer format for useNostrEvent + */ +function convertCommentPointer( + commentPointer: CommentPointer | null, +): + | { id: string } + | { kind: number; pubkey: string; identifier: string } + | undefined { + if (!commentPointer) return undefined; + + if (isCommentEventPointer(commentPointer)) { + return { id: commentPointer.id }; + } else if (isCommentAddressPointer(commentPointer)) { + return { + kind: commentPointer.kind, + pubkey: commentPointer.pubkey, + identifier: commentPointer.identifier, + }; + } + return undefined; +} + +/** + * Check if two pointers reference the same event + */ +function isSamePointer( + pointer1: + | { id: string } + | { kind: number; pubkey: string; identifier: string } + | undefined, + pointer2: + | { id: string } + | { kind: number; pubkey: string; identifier: string } + | undefined, +): boolean { + if (!pointer1 || !pointer2) return false; + + if ("id" in pointer1 && "id" in pointer2) { + return pointer1.id === pointer2.id; + } + + if ("kind" in pointer1 && "kind" in pointer2) { + return ( + pointer1.kind === pointer2.kind && + pointer1.pubkey === pointer2.pubkey && + pointer1.identifier === pointer2.identifier + ); + } + + return false; +} + +/** + * Parent event card component - compact single line + */ +function ParentEventCard({ + parentEvent, + icon: Icon, + tooltipText, + onClickHandler, +}: { + parentEvent: NostrEvent; + icon: typeof Reply; + tooltipText: string; + onClickHandler: () => void; +}) { + return ( +
+ + + + + +

{tooltipText}

+
+
+ + +
+ {getEventDisplayTitle(parentEvent, false) || ( + + )} +
+
+ ); +} + +/** + * Renderer for Kind 1111 - Post (NIP-22) + * Shows parent event with kind icon, author, and title in reply-style format + * If both root and reply exist and are different, shows both (root first, then reply) + */ +export function Kind1111Renderer({ event, depth = 0 }: BaseEventProps) { + const { addWindow } = useGrimoire(); + + // Use NIP-22 specific helpers to get reply/root pointers + const replyPointerRaw = getCommentReplyPointer(event); + const rootPointerRaw = getCommentRootPointer(event); + + // Convert to useNostrEvent format + const rootPointer = convertCommentPointer(rootPointerRaw); + const replyPointer = convertCommentPointer(replyPointerRaw); + + // Fetch both events + const rootEvent = useNostrEvent(rootPointer, event); + const replyEvent = useNostrEvent(replyPointer, event); + + // Check if root and reply are different events + const hasDistinctReply = + rootPointer && + replyPointer && + !isSamePointer(rootPointer, replyPointer) && + rootEvent && + replyEvent; + + const handleRootClick = () => { + if (!rootEvent || !rootPointer) return; + addWindow("open", { pointer: rootPointer }); + }; + + const handleReplyClick = () => { + if (!replyEvent || !replyPointer) return; + addWindow( + "open", + { pointer: replyPointer }, + ); + }; + + return ( + + +
+ {/* Show root event (thread origin) */} + {rootPointer && !rootEvent && ( + } /> + )} + + {rootPointer && rootEvent && ( + + )} + + {/* Show reply event (immediate parent) if different from root */} + {hasDistinctReply && replyPointer && ( + + )} +
+
+ + +
+ ); +} diff --git a/src/components/nostr/kinds/NoteRenderer.tsx b/src/components/nostr/kinds/NoteRenderer.tsx index eb90478..8d08dfa 100644 --- a/src/components/nostr/kinds/NoteRenderer.tsx +++ b/src/components/nostr/kinds/NoteRenderer.tsx @@ -3,63 +3,170 @@ import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; import { getNip10References } from "applesauce-core/helpers/threading"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { UserName } from "../UserName"; -import { Reply } from "lucide-react"; +import { Reply, MessageSquare } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { InlineReplySkeleton } from "@/components/ui/skeleton"; +import { KindBadge } from "@/components/KindBadge"; +import { getEventDisplayTitle } from "@/lib/event-title"; +import type { NostrEvent } from "@/types/nostr"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { EventPointer } from "nostr-tools/nip19"; +import type { AddressPointer } from "nostr-tools/nip19"; /** - * Renderer for Kind 1 - Short Text Note + * Check if two pointers reference the same event + */ +function isSamePointer( + pointer1: + | { e: EventPointer; a: undefined } + | { e: undefined; a: AddressPointer } + | { e: EventPointer; a: AddressPointer } + | undefined, + pointer2: + | { e: EventPointer; a: undefined } + | { e: undefined; a: AddressPointer } + | { e: EventPointer; a: AddressPointer } + | undefined, +): boolean { + if (!pointer1 || !pointer2) return false; + + // Compare event pointers + if (pointer1.e && pointer2.e) { + return pointer1.e.id === pointer2.e.id; + } + + // Compare address pointers + if (pointer1.a && pointer2.a) { + return ( + pointer1.a.kind === pointer2.a.kind && + pointer1.a.pubkey === pointer2.a.pubkey && + pointer1.a.identifier === pointer2.a.identifier + ); + } + + return false; +} + +/** + * Parent event card component - compact single line + */ +function ParentEventCard({ + parentEvent, + icon: Icon, + tooltipText, + onClickHandler, +}: { + parentEvent: NostrEvent; + icon: typeof Reply; + tooltipText: string; + onClickHandler: () => void; +}) { + return ( +
+ + + + + +

{tooltipText}

+
+
+ + +
+ {getEventDisplayTitle(parentEvent, false) || ( + + )} +
+
+ ); +} + +/** + * Renderer for Kind 1 - Short Text Note (NIP-10 threading) + * Shows parent event with kind icon, author, and title in reply-style format + * If both root and reply exist and are different, shows both (root first, then reply) */ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) { const { addWindow } = useGrimoire(); - const refs = getNip10References(event); - const pointer = - refs.reply?.e || refs.reply?.a || refs.root?.e || refs.root?.a; - // Pass full reply event to useNostrEvent for comprehensive relay selection - // This allows eventLoader to extract r/e/p tags for better relay coverage - const parentEvent = useNostrEvent(pointer, event); + // Use NIP-10 threading helpers + const refs = getNip10References(event); + + // Get pointers for root and reply + const rootPointer = refs.root?.e || refs.root?.a; + const replyPointer = refs.reply?.e || refs.reply?.a; + + // Fetch both events + const rootEvent = useNostrEvent(rootPointer, event); + const replyEvent = useNostrEvent(replyPointer, event); + + // Check if root and reply are different events + const hasDistinctReply = + refs.root && + refs.reply && + !isSamePointer(refs.root, refs.reply) && + rootEvent && + replyEvent; + + const handleRootClick = () => { + if (!rootEvent || !rootPointer) return; + addWindow("open", { pointer: rootPointer }, `Thread root`); + }; const handleReplyClick = () => { - if (!parentEvent) return; - - if (pointer) { - addWindow( - "open", - { pointer }, - `Reply to ${parentEvent.pubkey.slice(0, 8)}...`, - ); - } + if (!replyEvent || !replyPointer) return; + addWindow( + "open", + { pointer: replyPointer }, + `Reply to ${replyEvent.pubkey.slice(0, 8)}...`, + ); }; return ( - {/* Show parent message loading state */} - {pointer && !parentEvent && ( - } /> - )} + +
+ {/* Show root event (thread origin) */} + {rootPointer && !rootEvent && ( + } /> + )} - {/* Show parent message once loaded */} - {pointer && parentEvent && ( -
- -
- -
- -
-
+ )} + + {/* Show reply event (immediate parent) if different from root */} + {hasDistinctReply && replyPointer && ( + + )}
- )} + + ); diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 511e340..26b3f1e 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -1,5 +1,6 @@ import { Kind0Renderer } from "./ProfileRenderer"; import { Kind1Renderer } from "./NoteRenderer"; +import { Kind1111Renderer } from "./Kind1111Renderer"; import { Kind3Renderer } from "./ContactListRenderer"; import { RepostRenderer } from "./RepostRenderer"; import { Kind7Renderer } from "./ReactionRenderer"; @@ -34,13 +35,14 @@ const kindRenderers: Record> = { 6: RepostRenderer, // Repost 7: Kind7Renderer, // Reaction 9: Kind9Renderer, // Chat Message (NIP-C7) + 11: Kind1Renderer, // Public Thread Reply (NIP-10) 16: RepostRenderer, // Generic Repost 17: Kind7Renderer, // Reaction (NIP-25) 20: Kind20Renderer, // Picture (NIP-68) 21: Kind21Renderer, // Video Event (NIP-71) 22: Kind22Renderer, // Short Video (NIP-71) 1063: Kind1063Renderer, // File Metadata (NIP-94) - 1111: Kind1Renderer, // Post + 1111: Kind1111Renderer, // Post (NIP-22) 1337: Kind1337Renderer, // Code Snippet (NIP-C0) 1617: PatchRenderer, // Patch (NIP-34) 1618: PullRequestRenderer, // Pull Request (NIP-34) @@ -105,6 +107,7 @@ export { } from "./BaseEventRenderer"; export type { BaseEventProps } from "./BaseEventRenderer"; export { Kind1Renderer } from "./NoteRenderer"; +export { Kind1111Renderer } from "./Kind1111Renderer"; export { RepostRenderer, Kind6Renderer,