From 238ff4426c57a1158b2f2dc65411c56127c41609 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 11:21:01 +0000 Subject: [PATCH] feat: add voice note renderers for kinds 1222/1244 (NIP-A0) Implements rendering for voice messages and voice message replies: - Kind 1222: Voice Message with audio player and optional waveform - Kind 1244: Voice Message Reply with threading support (NIP-22) Features: - Custom audio player with play/pause controls - Waveform visualization from imeta tags - Progress bar with seek support - Duration display from imeta or audio metadata - Reply context display for kind 1244 --- .../nostr/kinds/VoiceNoteRenderer.tsx | 431 ++++++++++++++++++ src/components/nostr/kinds/index.tsx | 9 + tsconfig.node.tsbuildinfo | 2 +- 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 src/components/nostr/kinds/VoiceNoteRenderer.tsx diff --git a/src/components/nostr/kinds/VoiceNoteRenderer.tsx b/src/components/nostr/kinds/VoiceNoteRenderer.tsx new file mode 100644 index 0000000..b8249e8 --- /dev/null +++ b/src/components/nostr/kinds/VoiceNoteRenderer.tsx @@ -0,0 +1,431 @@ +import { useState, useRef, useEffect } from "react"; +import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; +import { Mic, Play, Pause, Reply } from "lucide-react"; +import { getNip10References } from "applesauce-core/helpers/threading"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { UserName } from "../UserName"; +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 { RichText } from "../RichText"; +import { cn } from "@/lib/utils"; + +/** + * Parse voice note metadata from imeta tags (NIP-92) + * Returns waveform data and duration if present + */ +function parseVoiceNoteMetadata(event: NostrEvent): { + waveform?: number[]; + duration?: number; +} { + const result: { waveform?: number[]; duration?: number } = {}; + + for (const tag of event.tags) { + if (tag[0] !== "imeta") continue; + + 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 === "waveform") { + // Waveform is space-separated amplitude integers + result.waveform = value.split(" ").map((v) => parseInt(v, 10)); + } else if (key === "duration") { + result.duration = parseFloat(value); + } + } + } + + return result; +} + +/** + * Get audio URL from event content + */ +function getAudioUrl(event: NostrEvent): string | null { + const content = event.content.trim(); + // Content MUST be a URL pointing to an audio file + if (content.startsWith("http://") || content.startsWith("https://")) { + return content; + } + return null; +} + +/** + * Format duration in seconds to MM:SS format + */ +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +/** + * Waveform visualization component + */ +function WaveformVisualization({ + waveform, + progress, + onClick, +}: { + waveform: number[]; + progress: number; // 0-1 + onClick?: (progress: number) => void; +}) { + const containerRef = useRef(null); + + // Normalize waveform to 0-1 range + const maxAmplitude = Math.max(...waveform, 1); + const normalizedWaveform = waveform.map((v) => v / maxAmplitude); + + // Limit to ~50 bars for display + const targetBars = 50; + const step = Math.max(1, Math.floor(waveform.length / targetBars)); + const displayBars: number[] = []; + for (let i = 0; i < waveform.length; i += step) { + const chunk = normalizedWaveform.slice(i, i + step); + displayBars.push(Math.max(...chunk)); + } + + const handleClick = (e: React.MouseEvent) => { + if (!containerRef.current || !onClick) return; + const rect = containerRef.current.getBoundingClientRect(); + const clickProgress = (e.clientX - rect.left) / rect.width; + onClick(Math.max(0, Math.min(1, clickProgress))); + }; + + return ( +
+ {displayBars.map((amplitude, i) => { + const barProgress = i / displayBars.length; + const isPlayed = barProgress < progress; + return ( +
+ ); + })} +
+ ); +} + +/** + * Simple progress bar fallback when no waveform is available + */ +function SimpleProgressBar({ + progress, + onClick, +}: { + progress: number; + onClick?: (progress: number) => void; +}) { + const containerRef = useRef(null); + + const handleClick = (e: React.MouseEvent) => { + if (!containerRef.current || !onClick) return; + const rect = containerRef.current.getBoundingClientRect(); + const clickProgress = (e.clientX - rect.left) / rect.width; + onClick(Math.max(0, Math.min(1, clickProgress))); + }; + + return ( +
+
+
+ ); +} + +/** + * Voice note audio player component + */ +function VoiceNotePlayer({ + url, + waveform, + initialDuration, +}: { + url: string; + waveform?: number[]; + initialDuration?: number; +}) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [duration, setDuration] = useState(initialDuration || 0); + const [currentTime, setCurrentTime] = useState(0); + const [error, setError] = useState(false); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const handleTimeUpdate = () => setCurrentTime(audio.currentTime); + const handleDurationChange = () => { + if (audio.duration && !isNaN(audio.duration)) { + setDuration(audio.duration); + } + }; + const handleEnded = () => { + setIsPlaying(false); + setCurrentTime(0); + }; + const handleError = () => setError(true); + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("durationchange", handleDurationChange); + audio.addEventListener("loadedmetadata", handleDurationChange); + audio.addEventListener("ended", handleEnded); + audio.addEventListener("error", handleError); + audio.addEventListener("play", handlePlay); + audio.addEventListener("pause", handlePause); + + return () => { + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("durationchange", handleDurationChange); + audio.removeEventListener("loadedmetadata", handleDurationChange); + audio.removeEventListener("ended", handleEnded); + audio.removeEventListener("error", handleError); + audio.removeEventListener("play", handlePlay); + audio.removeEventListener("pause", handlePause); + }; + }, []); + + const togglePlayback = () => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + audio.play(); + } + }; + + const handleSeek = (progress: number) => { + const audio = audioRef.current; + if (!audio || !duration) return; + audio.currentTime = progress * duration; + }; + + const progress = duration > 0 ? currentTime / duration : 0; + + if (error) { + return ( +
+ + Failed to load audio + + Open in new tab + +
+ ); + } + + return ( +
+
+ ); +} + +/** + * Parent event card component for reply references + */ +function ParentEventCard({ + parentEvent, + onClickHandler, +}: { + parentEvent: NostrEvent; + onClickHandler: () => void; +}) { + // Don't show kind badge for common note types + const showKindBadge = parentEvent.kind !== 1 && parentEvent.kind !== 1222; + + return ( +
+ + + + + +

Replying to

+
+
+ {showKindBadge && } + +
+ {showKindBadge ? ( + getEventDisplayTitle(parentEvent, false) + ) : parentEvent.kind === 1222 || parentEvent.kind === 1244 ? ( + Voice note + ) : ( + + )} +
+
+ ); +} + +/** + * Renderer for Kind 1222 - Voice Message (NIP-A0) + * Short voice messages with optional waveform visualization + */ +export function VoiceNoteRenderer({ event }: BaseEventProps) { + const audioUrl = getAudioUrl(event); + const { waveform, duration } = parseVoiceNoteMetadata(event); + + if (!audioUrl) { + return ( + +
+ + Invalid voice note (no audio URL) +
+
+ ); + } + + return ( + + + + ); +} + +/** + * Renderer for Kind 1244 - Voice Message Comment (NIP-A0) + * Voice message replies following NIP-22 threading + */ +export function VoiceNoteReplyRenderer({ event }: BaseEventProps) { + const { addWindow } = useGrimoire(); + const audioUrl = getAudioUrl(event); + const { waveform, duration } = parseVoiceNoteMetadata(event); + + // Use NIP-10 threading helpers (NIP-22 follows same structure) + const refs = getNip10References(event); + const replyPointer = refs.reply?.e || refs.reply?.a; + const replyEvent = useNostrEvent(replyPointer, event); + + const handleReplyClick = () => { + if (!replyEvent || !replyPointer) return; + addWindow( + "open", + { pointer: replyPointer }, + `Reply to ${replyEvent.pubkey.slice(0, 8)}...` + ); + }; + + if (!audioUrl) { + return ( + +
+ + Invalid voice note (no audio URL) +
+
+ ); + } + + return ( + + + {/* Show reply reference */} + {replyPointer && !replyEvent && ( + } /> + )} + + {replyPointer && replyEvent && ( + + )} + + + + + ); +} + +// Named exports for the registry +export { VoiceNoteRenderer as Kind1222Renderer }; +export { VoiceNoteReplyRenderer as Kind1244Renderer }; diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index a10da0b..a8193f2 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -35,6 +35,7 @@ import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; import { LiveActivityRenderer } from "./LiveActivityRenderer"; import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer"; import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer"; +import { VoiceNoteRenderer, VoiceNoteReplyRenderer } from "./VoiceNoteRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -57,6 +58,8 @@ const kindRenderers: Record> = { 22: Kind22Renderer, // Short Video (NIP-71) 1063: Kind1063Renderer, // File Metadata (NIP-94) 1111: Kind1111Renderer, // Post (NIP-22) + 1222: VoiceNoteRenderer, // Voice Message (NIP-A0) + 1244: VoiceNoteReplyRenderer, // Voice Message Reply (NIP-A0) 1337: Kind1337Renderer, // Code Snippet (NIP-C0) 1617: PatchRenderer, // Patch (NIP-34) 1618: PullRequestRenderer, // Pull Request (NIP-34) @@ -176,4 +179,10 @@ export { Kind20Renderer } from "./PictureRenderer"; export { Kind21Renderer } from "./VideoRenderer"; export { Kind22Renderer } from "./ShortVideoRenderer"; export { Kind1063Renderer } from "./FileMetadataRenderer"; +export { + VoiceNoteRenderer, + VoiceNoteReplyRenderer, + Kind1222Renderer, + Kind1244Renderer, +} from "./VoiceNoteRenderer"; export { Kind9735Renderer } from "./ZapReceiptRenderer"; diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 75ea001..5e39d3d 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file