From 6481f1e04f050640f22d21c20c5984a4a80ba9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 21 Dec 2025 20:36:53 +0100 Subject: [PATCH] feat: add voice message support (NIP-A0) and legacy video kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VoiceMessageRenderer for kinds 1222 (voice message) and 1244 (voice reply) - Add compact preview components with mic icon for voice messages - Support legacy NIP-71 video kinds 34235/34236 via existing video renderers - Add fallback URL tag parsing for video events - Replace music note icon with mic icon for audio embeds - Remove border/padding from audio player for cleaner display - Add kind metadata (names, icons) for voice and legacy video kinds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/nostr/CompactEventRow.tsx | 105 +++++++++++++ src/components/nostr/JsonEventRow.tsx | 52 +++++++ .../nostr/LinkPreview/AudioLink.tsx | 4 +- src/components/nostr/MediaEmbed.tsx | 9 +- .../compact/GenericRepostCompactPreview.tsx | 69 +++++++++ .../nostr/compact/ReactionCompactPreview.tsx | 138 ++++++++++++++++++ .../nostr/compact/RepostCompactPreview.tsx | 56 +++++++ .../compact/VoiceMessageCompactPreview.tsx | 14 ++ .../nostr/compact/ZapCompactPreview.tsx | 76 ++++++++++ src/components/nostr/compact/index.tsx | 101 +++++++++++++ .../nostr/kinds/ShortVideoRenderer.tsx | 48 +++++- src/components/nostr/kinds/VideoRenderer.tsx | 40 ++++- .../nostr/kinds/VoiceMessageRenderer.tsx | 114 +++++++++++++++ src/constants/kinds.ts | 14 ++ 14 files changed, 821 insertions(+), 19 deletions(-) create mode 100644 src/components/nostr/CompactEventRow.tsx create mode 100644 src/components/nostr/JsonEventRow.tsx create mode 100644 src/components/nostr/compact/GenericRepostCompactPreview.tsx create mode 100644 src/components/nostr/compact/ReactionCompactPreview.tsx create mode 100644 src/components/nostr/compact/RepostCompactPreview.tsx create mode 100644 src/components/nostr/compact/VoiceMessageCompactPreview.tsx create mode 100644 src/components/nostr/compact/ZapCompactPreview.tsx create mode 100644 src/components/nostr/compact/index.tsx create mode 100644 src/components/nostr/kinds/VoiceMessageRenderer.tsx diff --git a/src/components/nostr/CompactEventRow.tsx b/src/components/nostr/CompactEventRow.tsx new file mode 100644 index 0000000..1f08bdc --- /dev/null +++ b/src/components/nostr/CompactEventRow.tsx @@ -0,0 +1,105 @@ +import { memo, useCallback } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { useGrimoire } from "@/core/state"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { getTagValue } from "applesauce-core/helpers"; +import { KindBadge } from "@/components/KindBadge"; +import { UserName } from "./UserName"; +import { compactRenderers, DefaultCompactPreview } from "./compact"; + +// NIP-01 Kind ranges for replaceable events +const REPLACEABLE_START = 10000; +const REPLACEABLE_END = 20000; +const PARAMETERIZED_REPLACEABLE_START = 30000; +const PARAMETERIZED_REPLACEABLE_END = 40000; + +interface CompactEventRowProps { + event: NostrEvent; +} + +/** + * Compact single-line event representation + * Layout: [KindBadge] [Author] [Preview] [Time] + */ +export function CompactEventRow({ event }: CompactEventRowProps) { + const { addWindow } = useGrimoire(); + const { locale } = useGrimoire(); + + // Get the compact preview renderer for this kind, or use default + const PreviewRenderer = compactRenderers[event.kind] || DefaultCompactPreview; + + // Format relative time + const relativeTime = formatTimestamp( + event.created_at, + "relative", + locale.locale, + ); + + // Format absolute time for tooltip + const absoluteTime = formatTimestamp( + event.created_at, + "absolute", + locale.locale, + ); + + // Click handler to open event detail + const handleClick = useCallback(() => { + // Determine if event is addressable/replaceable + const isAddressable = + (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || + (event.kind >= PARAMETERIZED_REPLACEABLE_START && + event.kind < PARAMETERIZED_REPLACEABLE_END); + + let pointer; + + if (isAddressable) { + const dTag = getTagValue(event, "d") || ""; + pointer = { + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }; + } else { + pointer = { + id: event.id, + }; + } + + addWindow("open", { pointer }); + }, [event, addWindow]); + + return ( +
+ {/* Kind badge - compact/icon only */} + + + {/* Author */} + + + {/* Kind-specific or default preview */} +
+ +
+ + {/* Timestamp */} + + {relativeTime} + +
+ ); +} + +// Memoized version for scroll performance +export const MemoizedCompactEventRow = memo( + CompactEventRow, + (prev, next) => prev.event.id === next.event.id, +); diff --git a/src/components/nostr/JsonEventRow.tsx b/src/components/nostr/JsonEventRow.tsx new file mode 100644 index 0000000..360d6a1 --- /dev/null +++ b/src/components/nostr/JsonEventRow.tsx @@ -0,0 +1,52 @@ +import { memo } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { useCopy } from "@/hooks/useCopy"; +import { SyntaxHighlight } from "@/components/SyntaxHighlight"; +import { CodeCopyButton } from "@/components/CodeCopyButton"; + +interface JsonEventRowProps { + event: NostrEvent; +} + +/** + * JSON view for a single event + * Shows syntax-highlighted, copyable JSON + */ +export function JsonEventRow({ event }: JsonEventRowProps) { + const { copy, copied } = useCopy(); + const jsonString = JSON.stringify(event, null, 2); + + return ( +
+ {/* Event ID header for reference */} +
+ + {event.id.slice(0, 16)}... + + kind {event.kind} +
+ + {/* JSON content */} + + + {/* Copy button - visible on hover */} +
+ copy(jsonString)} + copied={copied} + label="Copy event JSON" + /> +
+
+ ); +} + +// Memoized version for scroll performance +export const MemoizedJsonEventRow = memo( + JsonEventRow, + (prev, next) => prev.event.id === next.event.id, +); diff --git a/src/components/nostr/LinkPreview/AudioLink.tsx b/src/components/nostr/LinkPreview/AudioLink.tsx index 0e81b71..7e00cbf 100644 --- a/src/components/nostr/LinkPreview/AudioLink.tsx +++ b/src/components/nostr/LinkPreview/AudioLink.tsx @@ -1,4 +1,4 @@ -import { Music } from "lucide-react"; +import { Mic } from "lucide-react"; interface AudioLinkProps { url: string; @@ -11,7 +11,7 @@ export function AudioLink({ url, onClick }: AudioLinkProps) { onClick={onClick} className="inline-flex items-baseline gap-1 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all line-clamp-1" > - + {url} ); diff --git a/src/components/nostr/MediaEmbed.tsx b/src/components/nostr/MediaEmbed.tsx index b39b787..f7291ae 100644 --- a/src/components/nostr/MediaEmbed.tsx +++ b/src/components/nostr/MediaEmbed.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import Zoom from "react-medium-image-zoom"; import "react-medium-image-zoom/dist/styles.css"; -import { Music, AlertCircle, Play, RotateCw } from "lucide-react"; +import { Mic, AlertCircle, Play, RotateCw } from "lucide-react"; import { isImageURL, isVideoURL, @@ -362,14 +362,13 @@ export function MediaEmbed({ return (
- + {!onAudioClick ? (