feat: kind 11 and 1111 renderer, show reply and root

This commit is contained in:
Alejandro Gómez
2025-12-16 23:34:28 +01:00
parent 63121f6233
commit ce18567900
4 changed files with 462 additions and 111 deletions

View File

@@ -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
<div
className={cn("relative overflow-hidden", presetStyles.rounded)}
style={
effectiveAspectRatio
? {
aspectRatio: effectiveAspectRatio,
maxHeight: presetStyles.maxHeight,
maxWidth: presetStyles.maxWidth,
contain: "content", // Performance optimization
}
: {
maxHeight: presetStyles.maxHeight,
maxWidth: presetStyles.maxWidth,
}
}
className={cn(
"relative overflow-hidden contain-content",
presetStyles.maxWidthClass,
presetStyles.roundedClass,
)}
>
<div className={aspectClass}>
{/* Skeleton placeholder */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder rounded={presetStyles.roundedClass} />
)}
{/* Image with fade-in */}
<img
key={retryCount} // Force remount on retry
src={url}
alt={alt || "Image"}
loading="lazy"
className={cn(
"w-full h-full object-contain",
presetStyles.roundedClass,
enableZoom && "cursor-zoom-in",
fadeIn && "transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className,
)}
onLoad={handleLoad}
onError={handleError}
aria-label={alt || "Image"}
/>
</div>
</div>
) : (
// Without aspect ratio: direct image with size constraints
<div
className={cn(
"relative overflow-hidden contain-content flex items-center justify-center",
presetStyles.maxWidthClass,
presetStyles.maxHeightClass,
presetStyles.roundedClass,
)}
>
{/* Skeleton placeholder */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder
aspectRatio={effectiveAspectRatio}
rounded={presetStyles.rounded}
/>
<SkeletonPlaceholder rounded={presetStyles.roundedClass} />
)}
{/* 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 (
<div
className={cn("relative overflow-hidden", presetStyles.rounded)}
style={{
aspectRatio: effectiveAspectRatio,
maxHeight: presetStyles.maxHeight,
maxWidth: "100%",
contain: "content", // Performance optimization
}}
>
{/* Skeleton placeholder with play icon */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder
aspectRatio={effectiveAspectRatio}
rounded={presetStyles.rounded}
>
<Play className="w-12 h-12 text-muted-foreground/50" />
</SkeletonPlaceholder>
className={cn(
"relative overflow-hidden contain-content",
presetStyles.maxWidthClass,
presetStyles.roundedClass,
)}
{/* Video with fade-in */}
<video
key={retryCount} // Force remount on retry
src={url}
className={cn(
"w-full h-full",
presetStyles.rounded,
fadeIn && "transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className,
>
<div className={aspectClass || "aspect-video"}>
{/* Skeleton placeholder with play icon */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder rounded={presetStyles.roundedClass}>
<Play className="w-12 h-12 text-muted-foreground/50" />
</SkeletonPlaceholder>
)}
preload="metadata"
controls={showControls}
onLoadedMetadata={handleLoad}
onError={handleError}
aria-label={alt || "Video"}
/>
{/* Video with fade-in */}
<video
key={retryCount} // Force remount on retry
src={url}
className={cn(
"w-full h-full",
presetStyles.roundedClass,
fadeIn && "transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className,
)}
preload="metadata"
controls={showControls}
onLoadedMetadata={handleLoad}
onError={handleError}
aria-label={alt || "Video"}
/>
</div>
</div>
);
}

View File

@@ -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 (
<div
onClick={onClickHandler}
className="flex items-center gap-2 p-1 bg-muted/20 text-xs hover:bg-muted/30 cursor-crosshair rounded transition-colors"
>
<Tooltip>
<TooltipTrigger asChild>
<Icon className="size-3 flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
<KindBadge kind={parentEvent.kind} variant="compact" />
<UserName
pubkey={parentEvent.pubkey}
className="text-accent font-semibold flex-shrink-0"
/>
<div className="text-muted-foreground truncate min-w-0 flex-1">
{getEventDisplayTitle(parentEvent, false) || (
<RichText
event={parentEvent}
options={{ showMedia: false, showEventEmbeds: false }}
/>
)}
</div>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<TooltipProvider>
<div className="flex flex-col gap-0.5">
{/* Show root event (thread origin) */}
{rootPointer && !rootEvent && (
<InlineReplySkeleton icon={<MessageSquare className="size-3" />} />
)}
{rootPointer && rootEvent && (
<ParentEventCard
parentEvent={rootEvent}
icon={MessageSquare}
tooltipText="Thread root"
onClickHandler={handleRootClick}
/>
)}
{/* Show reply event (immediate parent) if different from root */}
{hasDistinctReply && replyPointer && (
<ParentEventCard
parentEvent={replyEvent}
icon={Reply}
tooltipText="Replying to"
onClickHandler={handleReplyClick}
/>
)}
</div>
</TooltipProvider>
<RichText event={event} className="text-sm" depth={depth} />
</BaseEventContainer>
);
}

View File

@@ -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 (
<div
onClick={onClickHandler}
className="flex items-center gap-2 p-1 bg-muted/20 text-xs hover:bg-muted/30 cursor-crosshair rounded transition-colors"
>
<Tooltip>
<TooltipTrigger asChild>
<Icon className="size-3 flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
<KindBadge kind={parentEvent.kind} variant="compact" />
<UserName
pubkey={parentEvent.pubkey}
className="text-accent font-semibold flex-shrink-0"
/>
<div className="text-muted-foreground truncate min-w-0 flex-1">
{getEventDisplayTitle(parentEvent, false) || (
<RichText
event={parentEvent}
options={{ showMedia: false, showEventEmbeds: false }}
/>
)}
</div>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
{/* Show parent message loading state */}
{pointer && !parentEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
<TooltipProvider>
<div className="flex flex-col gap-0.5">
{/* Show root event (thread origin) */}
{rootPointer && !rootEvent && (
<InlineReplySkeleton icon={<MessageSquare className="size-3" />} />
)}
{/* Show parent message once loaded */}
{pointer && parentEvent && (
<div
onClick={handleReplyClick}
className="flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground hover:bg-muted/30 cursor-crosshair rounded transition-colors"
>
<Reply className="size-3 flex-shrink-0 mt-0.5" />
<div className="flex items-baseline gap-1 min-w-0 flex-1">
<UserName
pubkey={parentEvent.pubkey}
className="flex-shrink-0 text-accent"
{rootPointer && rootEvent && (
<ParentEventCard
parentEvent={rootEvent}
icon={MessageSquare}
tooltipText="Thread root"
onClickHandler={handleRootClick}
/>
<div className="truncate line-clamp-1">
<RichText
event={parentEvent}
options={{ showMedia: false, showEventEmbeds: false }}
/>
</div>
</div>
)}
{/* Show reply event (immediate parent) if different from root */}
{hasDistinctReply && replyPointer && (
<ParentEventCard
parentEvent={replyEvent}
icon={Reply}
tooltipText="Replying to"
onClickHandler={handleReplyClick}
/>
)}
</div>
)}
</TooltipProvider>
<RichText event={event} className="text-sm" depth={depth} />
</BaseEventContainer>
);

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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,