-
+
+
)}
-
-
-
{kindInfo.name}
{kindInfo.description}
@@ -86,7 +82,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
(
- {children}
- ),
- h2: ({ children }) => (
- {children}
- ),
- h3: ({ children }) => (
- {children}
- ),
- h4: ({ children }) => (
- {children}
- ),
- h5: ({ children }) => (
- {children}
- ),
- h6: ({ children }) => (
- {children}
- ),
-
- // Paragraphs and text
- p: ({ children }) => (
-
- {children}
-
- ),
-
- // 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 (
- {
- e.preventDefault();
- addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
- }}
- className="text-primary underline decoration-dotted cursor-pointer hover:text-primary/80 transition-colors"
- >
- {children}
-
- );
- }
- }
-
- // Regular external link
- return (
- ({
+ // Headings
+ h1: ({ children }) => (
+
{children}
-
- );
- },
-
- // Lists
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => (
-
- {children}
-
- ),
- li: ({ children }) =>
{children},
-
- // Blockquotes
- blockquote: ({ children }) => (
-
- {children}
-
- ),
-
- // Code
- code: (props) => {
- const { children, className } = props;
- const inline = !className?.includes("language-");
- return inline ? (
-
+
+ ),
+ h2: ({ children }) => (
+
{children}
-
- ) : (
-
+
+ ),
+ h3: ({ children }) => (
+
{children}
-
- );
- },
- pre: ({ children }) =>
{children},
-
- // Horizontal rule
- hr: () =>
,
-
- // Tables
- table: ({ children }) => (
-
-
+
+ ),
+ h4: ({ children }) => (
+
{children}
-
-
- ),
- thead: ({ children }) =>
{children},
- tbody: ({ children }) =>
{children},
- tr: ({ children }) => (
-
{children}
- ),
- th: ({ children }) => (
-
- {children}
- |
- ),
- td: ({ children }) => (
-
{children} |
- ),
+
+ ),
+ h5: ({ children }) => (
+
+ {children}
+
+ ),
+ h6: ({ children }) => (
+
+ {children}
+
+ ),
- // Images - Inline with zoom
- img: ({ src, alt }) =>
- src ? (
-
- ) : null,
+ // Paragraphs and text
+ p: ({ children }) => (
+
+ {children}
+
+ ),
- // Emphasis
- strong: ({ children }) =>
{children},
- em: ({ children }) =>
{children},
- };
+ // 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 (
+
{
+ 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}
+
+ );
+ }
+ }
+
+ // Regular external link
+ console.log("[Markdown] Creating regular external link for:", href);
+ return (
+
+ {children}
+
+ );
+ },
+
+ // Lists
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => (
+
+ {children}
+
+ ),
+ li: ({ children }) => (
+
+ {children}
+
+ ),
+
+ // Blockquotes
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+
+ // Code
+ code: (props) => {
+ const { children, className } = props;
+ const inline = !className?.includes("language-");
+
+ return inline ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) =>
{children},
+
+ // Horizontal rule
+ hr: () =>
,
+
+ // Tables
+ table: ({ children }) => (
+
+ ),
+ thead: ({ children }) =>
{children},
+ tbody: ({ children }) =>
{children},
+ tr: ({ children }) => (
+
{children}
+ ),
+ th: ({ children }) => (
+
+ {children}
+ |
+ ),
+ td: ({ children }) => (
+
{children} |
+ ),
+
+ // Images - Inline with zoom
+ img: ({ src, alt }) =>
+ src ? (
+
+ ) : null,
+
+ // Emphasis
+ strong: ({ children }) => (
+
{children}
+ ),
+ em: ({ children }) =>
{children},
+ }),
+ [addWindow],
+ );
return (
diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx
index ab1c043..2541be5 100644
--- a/src/components/nostr/RichText.tsx
+++ b/src/components/nostr/RichText.tsx
@@ -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 (
-
- {content.trim()}
-
+
+ {lines.map((line, idx) => (
+
+ {line || "\u00A0"}
+
+ ))}
+
);
}
@@ -52,14 +54,9 @@ export function RichText({ event, content, className = "" }: RichTextProps) {
};
const renderedContent = useRenderedContent(trimmedEvent, contentComponents);
return (
-
+
{renderedContent}
-
+
);
}
diff --git a/src/components/nostr/RichText/Text.tsx b/src/components/nostr/RichText/Text.tsx
index 299b5b8..fda1be3 100644
--- a/src/components/nostr/RichText/Text.tsx
+++ b/src/components/nostr/RichText/Text.tsx
@@ -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 {text || "\u00A0"};
+ }
+
+ // 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 (
+
+ {line || "\u00A0"}
+
+ );
+ })}
+ >
+ );
}
diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx
index 9c5e3f2..4bc65e9 100644
--- a/src/components/nostr/UserName.tsx
+++ b/src/components/nostr/UserName.tsx
@@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
return (
diff --git a/src/components/nostr/kinds/Kind1063Renderer.tsx b/src/components/nostr/kinds/Kind1063Renderer.tsx
new file mode 100644
index 0000000..e553c24
--- /dev/null
+++ b/src/components/nostr/kinds/Kind1063Renderer.tsx
@@ -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 (
+
+
+ {/* File preview */}
+ {metadata.url && (isImage || isVideo || isAudio) ? (
+
+ {isImage && (
+
+ )}
+ {isVideo && (
+
+ )}
+ {isAudio && (
+
+ )}
+
+ ) : (
+ /* Non-media file preview */
+
+
+
+
{fileName}
+ {metadata.m && (
+
{metadata.m}
+ )}
+
+ {metadata.url && (
+
+
+ Download
+
+ )}
+
+ )}
+
+ {/* File metadata */}
+
+ {metadata.m && (
+ <>
+ Type
+ {metadata.m}
+ >
+ )}
+ {metadata.size && (
+ <>
+ Size
+ {formatFileSize(metadata.size)}
+ >
+ )}
+ {metadata.dim && (
+ <>
+ Dimensions
+ {metadata.dim}
+ >
+ )}
+ {metadata.x && (
+ <>
+ Hash
+ {metadata.x}
+ >
+ )}
+
+
+ {/* Description/Summary */}
+ {summary &&
}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind20Renderer.tsx b/src/components/nostr/kinds/Kind20Renderer.tsx
new file mode 100644
index 0000000..5405ca5
--- /dev/null
+++ b/src/components/nostr/kinds/Kind20Renderer.tsx
@@ -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 (
+
+
+ {/* Title if present */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {/* Images */}
+ {images.length > 0 && (
+
+ {images.map((img, i) => (
+
+ ))}
+
+ )}
+
+ {/* Description */}
+ {event.content &&
}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind21Renderer.tsx b/src/components/nostr/kinds/Kind21Renderer.tsx
new file mode 100644
index 0000000..6ec00f5
--- /dev/null
+++ b/src/components/nostr/kinds/Kind21Renderer.tsx
@@ -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 (
+
+
+ {/* Title if present */}
+ {title &&
{title}
}
+
+ {/* Video - use first video from imeta or preview image if video fails */}
+ {videos.length > 0 && (
+
+ )}
+
+ {/* Description */}
+ {event.content && }
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind22Renderer.tsx b/src/components/nostr/kinds/Kind22Renderer.tsx
new file mode 100644
index 0000000..a066fa3
--- /dev/null
+++ b/src/components/nostr/kinds/Kind22Renderer.tsx
@@ -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 (
+
+
+ {/* Title if present */}
+ {title &&
{title}
}
+
+ {/* Short video - optimized for portrait */}
+ {videos.length > 0 && (
+
+ )}
+
+ {/* Description */}
+ {event.content && }
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind7Renderer.tsx b/src/components/nostr/kinds/Kind7Renderer.tsx
index f802081..8a7e354 100644
--- a/src/components/nostr/kinds/Kind7Renderer.tsx
+++ b/src/components/nostr/kinds/Kind7Renderer.tsx
@@ -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,
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 8ff8e72..f5078b9 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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";
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index 8a66c85..2fdf39f 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -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(
diff --git a/src/core/logic.ts b/src/core/logic.ts
index 4c3c526..e7c2e9e 100644
--- a/src/core/logic.ts
+++ b/src/core/logic.ts
@@ -219,6 +219,11 @@ export const setActiveAccount = (
state: GrimoireState,
pubkey: string | undefined,
): GrimoireState => {
+ // If pubkey is already set to the same value, return state unchanged
+ if (state.activeAccount?.pubkey === pubkey) {
+ return state;
+ }
+
if (!pubkey) {
return {
...state,
@@ -244,6 +249,12 @@ export const setActiveAccountRelays = (
if (!state.activeAccount) {
return state;
}
+
+ // If relays reference hasn't changed, return state unchanged
+ if (state.activeAccount.relays === relays) {
+ return state;
+ }
+
return {
...state,
activeAccount: {
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
index e2edc9e..cc2843e 100644
--- a/src/hooks/useAccountSync.ts
+++ b/src/hooks/useAccountSync.ts
@@ -10,7 +10,7 @@ import type { RelayInfo, UserRelays } from "@/types/app";
* Hook that syncs active account with Grimoire state and fetches relay lists
*/
export function useAccountSync() {
- const { state, setActiveAccount, setActiveAccountRelays } = useGrimoire();
+ const { setActiveAccount, setActiveAccountRelays } = useGrimoire();
const eventStore = useEventStore();
// Watch active account from accounts service
@@ -18,25 +18,17 @@ export function useAccountSync() {
// Sync active account pubkey to state
useEffect(() => {
- console.log("useAccountSync: activeAccount changed", activeAccount?.pubkey);
- if (activeAccount?.pubkey !== state.activeAccount?.pubkey) {
- console.log(
- "useAccountSync: setting active account",
- activeAccount?.pubkey,
- );
- setActiveAccount(activeAccount?.pubkey);
- }
- }, [activeAccount?.pubkey, state.activeAccount?.pubkey, setActiveAccount]);
+ setActiveAccount(activeAccount?.pubkey);
+ }, [activeAccount?.pubkey, setActiveAccount]);
// Fetch and watch relay list (kind 10002) when account changes
useEffect(() => {
if (!activeAccount?.pubkey) {
- console.log("useAccountSync: no active account, skipping relay fetch");
return;
}
const pubkey = activeAccount.pubkey;
- console.log("useAccountSync: fetching relay list for", pubkey);
+ let lastRelayEventId: string | undefined;
// Subscribe to kind 10002 (relay list)
const subscription = addressLoader({
@@ -49,12 +41,12 @@ export function useAccountSync() {
const storeSubscription = eventStore
.replaceable(10002, pubkey, "")
.subscribe((relayListEvent) => {
- console.log(
- "useAccountSync: relay list event received",
- relayListEvent,
- );
if (!relayListEvent) return;
+ // Only process if this is a new event
+ if (relayListEvent.id === lastRelayEventId) return;
+ lastRelayEventId = relayListEvent.id;
+
// Parse inbox and outbox relays
const inboxRelays = getInboxes(relayListEvent);
const outboxRelays = getOutboxes(relayListEvent);
@@ -92,7 +84,6 @@ export function useAccountSync() {
all: allRelays,
};
- console.log("useAccountSync: parsed relays", relays);
setActiveAccountRelays(relays);
});
@@ -100,5 +91,5 @@ export function useAccountSync() {
subscription.unsubscribe();
storeSubscription.unsubscribe();
};
- }, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
+ }, [activeAccount?.pubkey, eventStore]);
}
diff --git a/src/lib/imeta.ts b/src/lib/imeta.ts
new file mode 100644
index 0000000..62630bc
--- /dev/null
+++ b/src/lib/imeta.ts
@@ -0,0 +1,158 @@
+/**
+ * Utilities for parsing imeta tags (NIP-92) and file metadata tags (NIP-94)
+ */
+
+import type { NostrEvent } from "@/types/nostr";
+
+export interface ImetaEntry {
+ url: string;
+ m?: string; // MIME type
+ blurhash?: string;
+ dim?: string; // dimensions (e.g., "1920x1080")
+ alt?: string; // alt text
+ x?: string; // SHA-256 hash
+ size?: string; // file size in bytes
+ fallback?: string[]; // fallback URLs
+}
+
+/**
+ * Parse an imeta tag into structured data
+ * Format: ["imeta", "url https://...", "m image/jpeg", "blurhash U...]
+ */
+export function parseImetaTag(tag: string[]): ImetaEntry | null {
+ if (tag[0] !== "imeta" || tag.length < 2) return null;
+
+ const entry: Partial = {};
+
+ // Parse each key-value pair
+ for (let i = 1; i < tag.length; i++) {
+ const parts = tag[i].split(" ");
+ if (parts.length < 2) continue;
+
+ const key = parts[0];
+ const value = parts.slice(1).join(" ");
+
+ if (key === "url") {
+ entry.url = value;
+ } else if (key === "fallback") {
+ if (!entry.fallback) entry.fallback = [];
+ entry.fallback.push(value);
+ } else {
+ (entry as any)[key] = value;
+ }
+ }
+
+ // URL is required
+ if (!entry.url) return null;
+
+ return entry as ImetaEntry;
+}
+
+/**
+ * Parse all imeta tags from an event
+ */
+export function parseImetaTags(event: NostrEvent): ImetaEntry[] {
+ return event.tags
+ .filter((tag) => tag[0] === "imeta")
+ .map(parseImetaTag)
+ .filter((entry): entry is ImetaEntry => entry !== null);
+}
+
+/**
+ * Parse file metadata from NIP-94 kind 1063 event tags
+ */
+export function parseFileMetadata(event: NostrEvent): ImetaEntry {
+ const metadata: Partial = {};
+
+ for (const tag of event.tags) {
+ const [key, value] = tag;
+ if (!value) continue;
+
+ switch (key) {
+ case "url":
+ metadata.url = value;
+ break;
+ case "m":
+ metadata.m = value;
+ break;
+ case "x":
+ metadata.x = value;
+ break;
+ case "size":
+ metadata.size = value;
+ break;
+ case "dim":
+ metadata.dim = value;
+ break;
+ case "blurhash":
+ metadata.blurhash = value;
+ break;
+ case "alt":
+ metadata.alt = value;
+ break;
+ }
+ }
+
+ return metadata as ImetaEntry;
+}
+
+/**
+ * Get the primary image URL from a picture event (kind 20)
+ * Tries imeta tags first, then falls back to content
+ */
+export function getPictureUrl(event: NostrEvent): string | null {
+ // Try imeta tags first
+ const imeta = parseImetaTags(event);
+ if (imeta.length > 0 && imeta[0].url) {
+ return imeta[0].url;
+ }
+
+ // Fallback: try to extract URL from content
+ const urlMatch = event.content.match(/https?:\/\/[^\s]+/);
+ return urlMatch ? urlMatch[0] : null;
+}
+
+/**
+ * Check if a MIME type is an image
+ */
+export function isImageMime(mime?: string): boolean {
+ if (!mime) return false;
+ return mime.startsWith("image/");
+}
+
+/**
+ * Check if a MIME type is a video
+ */
+export function isVideoMime(mime?: string): boolean {
+ if (!mime) return false;
+ return mime.startsWith("video/");
+}
+
+/**
+ * Check if a MIME type is audio
+ */
+export function isAudioMime(mime?: string): boolean {
+ if (!mime) return false;
+ return mime.startsWith("audio/");
+}
+
+/**
+ * Format file size for display
+ */
+export function formatFileSize(bytes?: string | number): string {
+ if (!bytes) return "Unknown size";
+
+ const size = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
+ if (isNaN(size)) return "Unknown size";
+
+ const units = ["B", "KB", "MB", "GB"];
+ let unitIndex = 0;
+ let displaySize = size;
+
+ while (displaySize >= 1024 && unitIndex < units.length - 1) {
+ displaySize /= 1024;
+ unitIndex++;
+ }
+
+ return `${displaySize.toFixed(1)} ${units[unitIndex]}`;
+}
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index d4ecbc5..f2459b4 100644
--- a/tsconfig.app.tsbuildinfo
+++ b/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1063renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind20renderer.tsx","./src/components/nostr/kinds/kind21renderer.tsx","./src/components/nostr/kinds/kind22renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/imeta.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"errors":true,"version":"5.6.3"}
\ No newline at end of file