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 */}
@@ -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,