mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat: kind 11 and 1111 renderer, show reply and root
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
196
src/components/nostr/kinds/Kind1111Renderer.tsx
Normal file
196
src/components/nostr/kinds/Kind1111Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user