feat: RTL support

This commit is contained in:
Alejandro Gómez
2025-12-10 13:00:39 +01:00
parent cd41034b2f
commit 5b00e42ddf
18 changed files with 688 additions and 352 deletions

View File

@@ -1,5 +1,4 @@
import { getKindInfo } from "@/constants/kinds";
import { KindBadge } from "./KindBadge";
import Command from "./Command";
import { ExternalLink } from "lucide-react";
@@ -25,16 +24,13 @@ export default function KindRenderer({ kind }: { kind: number }) {
return (
<div className="h-full w-full overflow-y-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<div className="flex items-center gap-4">
{Icon && (
<div className="w-12 h-12 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
<Icon className="w-6 h-6 text-accent" />
<div className="w-14 h-14 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
<Icon className="w-8 h-8 text-accent" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="mb-2">
<KindBadge kind={kind} variant="full" />
</div>
<h1 className="text-2xl font-bold mb-1">{kindInfo.name}</h1>
<p className="text-muted-foreground">{kindInfo.description}</p>
</div>
@@ -86,7 +82,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
<a
href={`https://github.com/nostr-protocol/nips/blob/master/${kindInfo.nip.padStart(
2,
"0"
"0",
)}.md`}
target="_blank"
rel="noopener noreferrer"

View File

@@ -1,5 +1,6 @@
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { MediaEmbed } from "@/components/nostr/MediaEmbed";
@@ -11,146 +12,212 @@ interface MarkdownProps {
export function Markdown({ content, className = "" }: MarkdownProps) {
const { addWindow } = useGrimoire();
const components: Components = {
// Headings
h1: ({ children }) => (
<h1 className="text-lg font-bold mt-4 mb-3 first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-bold mt-4 mb-2 first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-bold mt-3 mb-2 first:mt-0">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-bold mt-3 mb-2 first:mt-0">{children}</h4>
),
h5: ({ children }) => (
<h5 className="text-xs font-bold mt-2 mb-1 first:mt-0">{children}</h5>
),
h6: ({ children }) => (
<h6 className="text-xs font-bold mt-2 mb-1 first:mt-0">{children}</h6>
),
// Paragraphs and text
p: ({ children }) => (
<p className="mb-3 leading-relaxed text-sm last:mb-0 break-words">
{children}
</p>
),
// Links
a: ({ href, children }) => {
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md")
if (href && (href.endsWith(".md") || href.includes(".md#"))) {
// Extract NIP number from various formats
const nipMatch = href.match(/(\d{2})\.md/);
if (nipMatch) {
const nipNumber = nipMatch[1];
return (
<span
onClick={(e) => {
e.preventDefault();
addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
}}
className="text-primary underline decoration-dotted cursor-pointer hover:text-primary/80 transition-colors"
>
{children}
</span>
);
}
}
// Regular external link
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline decoration-dotted cursor-crosshair hover:text-primary/80 transition-colors"
const components: Components = useMemo(
() => ({
// Headings
h1: ({ children }) => (
<h1
dir="auto"
className="text-lg font-bold mt-4 mb-3 first:mt-0 text-start"
>
{children}
</a>
);
},
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside mb-3 space-y-1 text-sm">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-3 space-y-1 text-sm">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-muted-foreground/30 pl-3 py-2 my-3 italic text-muted-foreground text-sm">
{children}
</blockquote>
),
// Code
code: (props) => {
const { children, className } = props;
const inline = !className?.includes("language-");
return inline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
</h1>
),
h2: ({ children }) => (
<h2
dir="auto"
className="text-base font-bold mt-4 mb-2 first:mt-0 text-start"
>
{children}
</code>
) : (
<code className="block bg-muted p-3 rounded-lg my-3 overflow-x-auto text-xs leading-relaxed max-w-full">
</h2>
),
h3: ({ children }) => (
<h3
dir="auto"
className="text-sm font-bold mt-3 mb-2 first:mt-0 text-start"
>
{children}
</code>
);
},
pre: ({ children }) => <pre className="my-3">{children}</pre>,
// Horizontal rule
hr: () => <hr className="my-4 border-border" />,
// Tables
table: ({ children }) => (
<div className="overflow-x-auto my-3">
<table className="min-w-full border-collapse border border-border text-sm">
</h3>
),
h4: ({ children }) => (
<h4
dir="auto"
className="text-sm font-bold mt-3 mb-2 first:mt-0 text-start"
>
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-3 py-1.5 text-left font-bold border border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 border border-border">{children}</td>
),
</h4>
),
h5: ({ children }) => (
<h5
dir="auto"
className="text-xs font-bold mt-2 mb-1 first:mt-0 text-start"
>
{children}
</h5>
),
h6: ({ children }) => (
<h6
dir="auto"
className="text-xs font-bold mt-2 mb-1 first:mt-0 text-start"
>
{children}
</h6>
),
// Images - Inline with zoom
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-3"
/>
) : null,
// Paragraphs and text
p: ({ children }) => (
<p
dir="auto"
className="mb-3 leading-relaxed text-sm last:mb-0 break-words text-start"
>
{children}
</p>
),
// Emphasis
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
};
// Links
a: ({ href, children }) => {
console.log("[Markdown Link]", { href, children });
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md" or "30.md")
if (href && (href.endsWith(".md") || href.includes(".md#"))) {
console.log("[Markdown] Detected .md link:", href);
// Extract NIP number from various formats (1-3 digits)
const nipMatch = href.match(/(\d{1,3})\.md/);
console.log("[Markdown] Regex match result:", nipMatch);
if (nipMatch) {
const nipNumber = nipMatch[1];
console.log("[Markdown] Creating NIP link for NIP-" + nipNumber);
return (
<a
href={`#nip-${nipNumber}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("[Markdown] NIP link clicked! NIP-" + nipNumber);
console.log("[Markdown] Calling addWindow directly");
addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
console.log("[Markdown] addWindow called");
}}
className="text-accent underline decoration-dotted cursor-pointer hover:text-accent/80 transition-colors"
>
{children}
</a>
);
}
}
// Regular external link
console.log("[Markdown] Creating regular external link for:", href);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline decoration-dotted cursor-crosshair hover:text-primary/80 transition-colors"
>
{children}
</a>
);
},
// Lists
ul: ({ children }) => (
<ul
dir="auto"
className="list-disc list-inside mb-3 space-y-1 text-sm text-start"
>
{children}
</ul>
),
ol: ({ children }) => (
<ol
dir="auto"
className="list-decimal list-inside mb-3 space-y-1 text-sm text-start"
>
{children}
</ol>
),
li: ({ children }) => (
<li dir="auto" className="leading-relaxed text-start">
{children}
</li>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote
dir="auto"
className="border-s-4 border-muted-foreground/30 ps-3 py-2 my-3 italic text-muted-foreground text-sm text-start"
>
{children}
</blockquote>
),
// Code
code: (props) => {
const { children, className } = props;
const inline = !className?.includes("language-");
return inline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{children}
</code>
) : (
<code className="block bg-muted p-3 rounded-lg my-3 overflow-x-auto text-xs leading-relaxed max-w-full">
{children}
</code>
);
},
pre: ({ children }) => <pre className="my-3">{children}</pre>,
// Horizontal rule
hr: () => <hr className="my-4 border-border" />,
// Tables
table: ({ children }) => (
<div className="overflow-x-auto my-3">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-3 py-1.5 text-left font-bold border border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 border border-border">{children}</td>
),
// Images - Inline with zoom
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-3"
/>
) : null,
// Emphasis
strong: ({ children }) => (
<strong className="font-bold">{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
}),
[addWindow],
);
return (
<div className={className}>

View File

@@ -1,12 +1,14 @@
import type { NostrEvent } from "@/types/nostr";
import { useRenderedContent } from "applesauce-react/hooks";
import { cn } from "@/lib/utils";
import { Hooks } from "applesauce-react";
import { Text } from "./RichText/Text";
import { Hashtag } from "./RichText/Hashtag";
import { Mention } from "./RichText/Mention";
import { Link } from "./RichText/Link";
import { Emoji } from "./RichText/Emoji";
import { Gallery } from "./RichText/Gallery";
import type { NostrEvent } from "@/types/nostr";
const { useRenderedContent } = Hooks;
interface RichTextProps {
event?: NostrEvent;
@@ -32,15 +34,15 @@ const contentComponents = {
export function RichText({ event, content, className = "" }: RichTextProps) {
// If plain content is provided, just render it
if (content && !event) {
const lines = content.trim().split("\n");
return (
<span
className={cn(
"whitespace-pre-line leading-tight break-words",
className,
)}
>
{content.trim()}
</span>
<div className={cn("leading-tight break-words", className)}>
{lines.map((line, idx) => (
<div key={idx} dir="auto">
{line || "\u00A0"}
</div>
))}
</div>
);
}
@@ -52,14 +54,9 @@ export function RichText({ event, content, className = "" }: RichTextProps) {
};
const renderedContent = useRenderedContent(trimmedEvent, contentComponents);
return (
<span
className={cn(
"whitespace-pre-line leading-tight break-words",
className,
)}
>
<div className={cn("leading-tight break-words", className)}>
{renderedContent}
</span>
</div>
);
}

View File

@@ -1,9 +1,38 @@
import type { TextNode } from "applesauce-content";
interface TextNodeProps {
node: {
type: "text";
value: string;
};
}
export function Text({ node }: TextNodeProps) {
return <>{node.value}</>;
// Check if text contains RTL characters (Arabic, Hebrew, Persian, etc.)
function hasRTLCharacters(text: string): boolean {
return /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(text);
}
export function Text({ node }: TextNodeProps) {
const text = node.value;
// If no newlines, render as inline span
if (!text.includes("\n")) {
const isRTL = hasRTLCharacters(text);
return <span dir={isRTL ? "rtl" : "auto"}>{text || "\u00A0"}</span>;
}
// If has newlines, use divs for proper RTL per line
const lines = text.split("\n");
return (
<>
{lines.map((line, idx) => {
const isRTL = hasRTLCharacters(line);
return (
<div key={idx} dir={isRTL ? "rtl" : "auto"}>
{line || "\u00A0"}
</div>
);
})}
</>
);
}

View File

@@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
return (
<span
dir="auto"
className={cn("cursor-pointer hover:underline", className)}
onClick={handleClick}
>

View File

@@ -0,0 +1,115 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import {
parseFileMetadata,
isImageMime,
isVideoMime,
isAudioMime,
formatFileSize,
} from "@/lib/imeta";
import { FileText, Download } from "lucide-react";
/**
* Renderer for Kind 1063 - File Metadata (NIP-94)
* Displays file metadata with appropriate preview for images, videos, and audio
*/
export function Kind1063Renderer({ event, showTimestamp }: BaseEventProps) {
const metadata = parseFileMetadata(event);
// Determine file type from MIME
const isImage = isImageMime(metadata.m);
const isVideo = isVideoMime(metadata.m);
const isAudio = isAudioMime(metadata.m);
// Get additional metadata
const fileName =
event.tags.find((t) => t[0] === "name")?.[1] || "Unknown file";
const summary =
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-3">
{/* File preview */}
{metadata.url && (isImage || isVideo || isAudio) ? (
<div>
{isImage && (
<MediaEmbed
url={metadata.url}
type="image"
alt={metadata.alt || fileName}
preset="preview"
enableZoom
/>
)}
{isVideo && (
<MediaEmbed
url={metadata.url}
type="video"
preset="preview"
showControls
/>
)}
{isAudio && (
<MediaEmbed url={metadata.url} type="audio" showControls />
)}
</div>
) : (
/* Non-media file preview */
<div className="flex items-center gap-3 p-4 border border-border rounded-lg bg-muted/20">
<FileText className="w-8 h-8 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{metadata.m && (
<p className="text-xs text-muted-foreground">{metadata.m}</p>
)}
</div>
{metadata.url && (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<Download className="w-4 h-4" />
Download
</a>
)}
</div>
)}
{/* File metadata */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{metadata.m && (
<>
<span className="text-muted-foreground">Type</span>
<code className="font-mono text-xs">{metadata.m}</code>
</>
)}
{metadata.size && (
<>
<span className="text-muted-foreground">Size</span>
<span>{formatFileSize(metadata.size)}</span>
</>
)}
{metadata.dim && (
<>
<span className="text-muted-foreground">Dimensions</span>
<span>{metadata.dim}</span>
</>
)}
{metadata.x && (
<>
<span className="text-muted-foreground">Hash</span>
<code className="font-mono text-xs truncate">{metadata.x}</code>
</>
)}
</div>
{/* Description/Summary */}
{summary && <RichText content={summary} className="text-sm" />}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import { parseImetaTags } from "@/lib/imeta";
/**
* Renderer for Kind 20 - Picture Event (NIP-68)
* Picture-first feed events with imeta tags for image metadata
*/
export function Kind20Renderer({ event, showTimestamp }: BaseEventProps) {
// Parse imeta tags to get image URLs and metadata
const images = parseImetaTags(event);
// Get title from tags
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && (
<h3 dir="auto" className="text-base font-semibold text-start">
{title}
</h3>
)}
{/* Images */}
{images.length > 0 && (
<div className="flex flex-col gap-2">
{images.map((img, i) => (
<MediaEmbed
key={i}
url={img.url}
alt={img.alt || title || "Picture"}
preset="preview"
enableZoom
/>
))}
</div>
)}
{/* Description */}
{event.content && <RichText event={event} className="text-sm" />}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,38 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import { parseImetaTags } from "@/lib/imeta";
/**
* Renderer for Kind 21 - Video Event (NIP-71)
* Horizontal/landscape video events with imeta tags
*/
export function Kind21Renderer({ event, showTimestamp }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
// Get title from tags
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && <h3 className="text-base font-semibold">{title}</h3>}
{/* Video - use first video from imeta or preview image if video fails */}
{videos.length > 0 && (
<MediaEmbed
url={videos[0].url}
type="video"
preset="preview"
showControls
/>
)}
{/* Description */}
{event.content && <RichText event={event} className="text-sm" />}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,38 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import { parseImetaTags } from "@/lib/imeta";
/**
* Renderer for Kind 22 - Short Video Event (NIP-71)
* Short-form portrait video events (like TikTok/Reels)
*/
export function Kind22Renderer({ event, showTimestamp }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
// Get title from tags
const title = event.tags.find((t) => t[0] === "title")?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Title if present */}
{title && <h3 className="text-base font-semibold">{title}</h3>}
{/* Short video - optimized for portrait */}
{videos.length > 0 && (
<MediaEmbed
url={videos[0].url}
type="video"
preset="preview"
showControls
/>
)}
{/* Description */}
{event.content && <RichText event={event} className="text-sm" />}
</div>
</BaseEventContainer>
);
}

View File

@@ -30,7 +30,7 @@ export function Kind7Renderer({ event, showTimestamp }: BaseEventProps) {
// Parse reaction content to detect custom emoji shortcodes
// Format: :shortcode: in the content
const parsedReaction = useMemo(() => {
const match = reaction.match(/^:([a-zA-Z0-9_-]+):$/);
const match = reaction.match(/^:([a-zA-Z0-9_#-]+):$/);
if (match && customEmojis[match[1]]) {
return {
type: "custom" as const,

View File

@@ -1,13 +1,16 @@
import type { BaseEventProps } from "./BaseEventRenderer";
import { Kind0Renderer } from "./Kind0Renderer";
import { Kind1Renderer } from "./Kind1Renderer";
import { Kind6Renderer } from "./Kind6Renderer";
import { Kind7Renderer } from "./Kind7Renderer";
import { Kind20Renderer } from "./Kind20Renderer";
import { Kind21Renderer } from "./Kind21Renderer";
import { Kind22Renderer } from "./Kind22Renderer";
import { Kind1063Renderer } from "./Kind1063Renderer";
import { Kind9735Renderer } from "./Kind9735Renderer";
import { Kind9802Renderer } from "./Kind9802Renderer";
import { Kind30023Renderer } from "./Kind30023Renderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer } from "./BaseEventRenderer";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
/**
* Registry of kind-specific renderers
@@ -18,6 +21,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1: Kind1Renderer, // Short Text Note
6: Kind6Renderer, // Repost
7: Kind7Renderer, // Reaction
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
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
@@ -68,4 +75,8 @@ export type { BaseEventProps } from "./BaseEventRenderer";
export { Kind1Renderer } from "./Kind1Renderer";
export { Kind6Renderer } from "./Kind6Renderer";
export { Kind7Renderer } from "./Kind7Renderer";
export { Kind20Renderer } from "./Kind20Renderer";
export { Kind21Renderer } from "./Kind21Renderer";
export { Kind22Renderer } from "./Kind22Renderer";
export { Kind1063Renderer } from "./Kind1063Renderer";
export { Kind9735Renderer } from "./Kind9735Renderer";

View File

@@ -53,10 +53,6 @@ export default function UserMenu() {
const { state, addWindow } = useGrimoire();
const relays = state.activeAccount?.relays;
console.log("UserMenu: account", account?.pubkey);
console.log("UserMenu: state.activeAccount", state.activeAccount);
console.log("UserMenu: relays", relays);
function openProfile() {
if (!account?.pubkey) return;
addWindow(