mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-18 19:37:19 +02:00
feat: RTL support
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
|
||||
|
||||
return (
|
||||
<span
|
||||
dir="auto"
|
||||
className={cn("cursor-pointer hover:underline", className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
||||
115
src/components/nostr/kinds/Kind1063Renderer.tsx
Normal file
115
src/components/nostr/kinds/Kind1063Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/nostr/kinds/Kind20Renderer.tsx
Normal file
47
src/components/nostr/kinds/Kind20Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/nostr/kinds/Kind21Renderer.tsx
Normal file
38
src/components/nostr/kinds/Kind21Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/nostr/kinds/Kind22Renderer.tsx
Normal file
38
src/components/nostr/kinds/Kind22Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user