feat: add voice message support (NIP-A0) and legacy video kinds

- 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2025-12-21 20:36:53 +01:00
parent 3346a2077d
commit 6481f1e04f
14 changed files with 821 additions and 19 deletions

View File

@@ -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 (
<div
className="flex items-center gap-1.5 px-2 py-1 text-sm border-b border-border/50 last:border-0 cursor-crosshair hover:bg-muted/30 transition-colors"
onClick={handleClick}
>
{/* Kind badge - compact/icon only */}
<KindBadge kind={event.kind} variant="compact" className="shrink-0" />
{/* Author */}
<UserName
pubkey={event.pubkey}
className="text-sm shrink-0 max-w-[100px] truncate"
/>
{/* Kind-specific or default preview */}
<div className="flex-1 min-w-0 truncate">
<PreviewRenderer event={event} />
</div>
{/* Timestamp */}
<span
className="text-xs text-muted-foreground shrink-0 cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
</div>
);
}
// Memoized version for scroll performance
export const MemoizedCompactEventRow = memo(
CompactEventRow,
(prev, next) => prev.event.id === next.event.id,
);

View File

@@ -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 (
<div className="border-b border-border/50 last:border-0 relative group">
{/* Event ID header for reference */}
<div className="px-3 py-1.5 bg-muted/30 border-b border-border/30 flex items-center justify-between">
<code className="text-xs text-muted-foreground font-mono">
{event.id.slice(0, 16)}...
</code>
<span className="text-xs text-muted-foreground">kind {event.kind}</span>
</div>
{/* JSON content */}
<SyntaxHighlight
code={jsonString}
language="json"
className="p-3 pr-12 text-xs"
/>
{/* Copy button - visible on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<CodeCopyButton
onCopy={() => copy(jsonString)}
copied={copied}
label="Copy event JSON"
/>
</div>
</div>
);
}
// Memoized version for scroll performance
export const MemoizedJsonEventRow = memo(
JsonEventRow,
(prev, next) => prev.event.id === next.event.id,
);

View File

@@ -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"
>
<Music className="h-3 w-3 flex-shrink-0" />
<Mic className="h-3 w-3 flex-shrink-0" />
<span>{url}</span>
</button>
);

View File

@@ -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 (
<div
className={cn(
"flex items-center gap-3 p-3 border border-border rounded-lg bg-muted/20",
onAudioClick &&
"cursor-crosshair hover:bg-muted/30 transition-colors",
"flex items-center gap-3",
onAudioClick && "cursor-crosshair hover:opacity-80 transition-opacity",
className,
)}
onClick={onAudioClick ? handleAudioClick : undefined}
>
<Music className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Mic className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{!onAudioClick ? (
<audio
src={url}

View File

@@ -0,0 +1,69 @@
import type { NostrEvent } from "@/types/nostr";
import { useMemo } from "react";
import { Repeat2 } from "lucide-react";
import { getTagValue } from "applesauce-core/helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getContentPreview } from "./index";
import { getKindInfo } from "@/constants/kinds";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
/**
* Compact preview for Kind 16 (Generic Repost)
* Shows kind label + content preview of reposted event
*/
export function GenericRepostCompactPreview({ event }: { event: NostrEvent }) {
// Get the kind of the original event from k tag
const originalKindStr = getTagValue(event, "k");
const originalKind = originalKindStr ? parseInt(originalKindStr, 10) : null;
// Get the event being reposted (e tag)
const eTag = event.tags.find((tag) => tag[0] === "e");
const repostedEventId = eTag?.[1];
const repostedRelay = eTag?.[2];
// Create event pointer for fetching
const eventPointer = useMemo(() => {
if (!repostedEventId) return undefined;
return {
id: repostedEventId,
relays: repostedRelay ? [repostedRelay] : undefined,
};
}, [repostedEventId, repostedRelay]);
// Fetch the reposted event
const repostedEvent = useNostrEvent(eventPointer);
// Get kind name for display
const kindInfo = originalKind ? getKindInfo(originalKind) : null;
const kindLabel = kindInfo?.name || (originalKind ? `k${originalKind}` : "");
// Get content preview
const preview = repostedEvent ? getContentPreview(repostedEvent, 50) : null;
return (
<span className="flex items-center gap-1 text-sm text-muted-foreground truncate">
<Repeat2 className="size-3 shrink-0" />
{kindLabel && (
<span className="shrink-0 text-xs opacity-70">{kindLabel}</span>
)}
{repostedEvent ? (
<>
<UserName
pubkey={repostedEvent.pubkey}
className="text-sm shrink-0"
/>
<span className="truncate">
<RichText
content={preview || ""}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
</>
) : (
<span className="truncate opacity-50">Loading...</span>
)}
</span>
);
}

View File

@@ -0,0 +1,138 @@
import type { NostrEvent } from "@/types/nostr";
import { useMemo } from "react";
import { Heart, ThumbsUp, ThumbsDown, Flame, Smile } from "lucide-react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getContentPreview } from "./index";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
/**
* Compact preview for Kind 7 (Reaction)
* Shows the reaction emoji/content + preview of reacted content
*/
export function ReactionCompactPreview({ event }: { event: NostrEvent }) {
const reaction = event.content || "+";
// NIP-30: Custom emoji support
const emojiTags = event.tags.filter((tag) => tag[0] === "emoji");
const customEmojis = useMemo(() => {
const map: Record<string, string> = {};
emojiTags.forEach((tag) => {
if (tag[1] && tag[2]) {
map[tag[1]] = tag[2];
}
});
return map;
}, [emojiTags]);
// Parse reaction content for custom emoji
const parsedReaction = useMemo(() => {
const match = reaction.match(/^:([a-zA-Z0-9_#-]+):$/);
if (match && customEmojis[match[1]]) {
return {
type: "custom" as const,
shortcode: match[1],
url: customEmojis[match[1]],
};
}
return {
type: "unicode" as const,
emoji: reaction,
};
}, [reaction, customEmojis]);
// Get the event being reacted to (e tag for regular events)
const eTag = event.tags.find((tag) => tag[0] === "e");
const reactedEventId = eTag?.[1];
const reactedRelay = eTag?.[2];
// Get the address being reacted to (a tag for replaceable events)
const aTag = event.tags.find((tag) => tag[0] === "a");
const reactedAddress = aTag?.[1];
// Parse a tag into components
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
// Create event pointer for fetching
const eventPointer = useMemo(() => {
if (reactedEventId) {
return {
id: reactedEventId,
relays: reactedRelay ? [reactedRelay] : undefined,
};
}
if (addressParts) {
return {
kind: addressParts.kind,
pubkey: addressParts.pubkey,
identifier: addressParts.dTag || "",
relays: [],
};
}
return undefined;
}, [reactedEventId, reactedRelay, addressParts]);
// Fetch the reacted event
const reactedEvent = useNostrEvent(eventPointer);
// Get content preview
const preview = reactedEvent ? getContentPreview(reactedEvent, 50) : null;
// Map common reactions to icons for compact display
const getReactionDisplay = (content: string) => {
switch (content) {
case "❤️":
case "♥️":
case "+":
return <Heart className="size-4 fill-red-500 text-red-500" />;
case "👍":
return <ThumbsUp className="size-4 fill-green-500 text-green-500" />;
case "👎":
return <ThumbsDown className="size-4 fill-red-500 text-red-500" />;
case "🔥":
return <Flame className="size-4 fill-orange-500 text-orange-500" />;
case "😄":
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <span className="text-base">{content}</span>;
}
};
return (
<span className="flex items-center gap-1 text-sm truncate">
{parsedReaction.type === "custom" ? (
<img
src={parsedReaction.url}
alt={`:${parsedReaction.shortcode}:`}
title={`:${parsedReaction.shortcode}:`}
className="size-3.5 inline-block shrink-0"
/>
) : (
<span className="shrink-0">
{getReactionDisplay(parsedReaction.emoji)}
</span>
)}
{reactedEvent && (
<>
<UserName pubkey={reactedEvent.pubkey} className="text-sm shrink-0" />
<span className="text-muted-foreground truncate">
<RichText
content={preview || ""}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
</>
)}
</span>
);
}

View File

@@ -0,0 +1,56 @@
import type { NostrEvent } from "@/types/nostr";
import { useMemo } from "react";
import { Repeat2 } from "lucide-react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getContentPreview } from "./index";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
/**
* Compact preview for Kind 6 (Repost)
* Shows author + content preview of the reposted event
*/
export function RepostCompactPreview({ event }: { event: NostrEvent }) {
// Get the event being reposted (e tag)
const eTag = event.tags.find((tag) => tag[0] === "e");
const repostedEventId = eTag?.[1];
const repostedRelay = eTag?.[2];
// Create event pointer for fetching
const eventPointer = useMemo(() => {
if (!repostedEventId) return undefined;
return {
id: repostedEventId,
relays: repostedRelay ? [repostedRelay] : undefined,
};
}, [repostedEventId, repostedRelay]);
// Fetch the reposted event
const repostedEvent = useNostrEvent(eventPointer);
// Get content preview
const preview = repostedEvent ? getContentPreview(repostedEvent, 50) : null;
return (
<span className="flex items-center gap-1 text-sm text-muted-foreground truncate">
<Repeat2 className="size-3 shrink-0" />
{repostedEvent ? (
<>
<UserName
pubkey={repostedEvent.pubkey}
className="text-sm shrink-0"
/>
<span className="truncate">
<RichText
content={preview || ""}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
</>
) : (
<span className="truncate opacity-50">Loading...</span>
)}
</span>
);
}

View File

@@ -0,0 +1,14 @@
import type { NostrEvent } from "@/types/nostr";
import { Mic } from "lucide-react";
/**
* Compact preview for Kind 1222 (Voice Message) and Kind 1244 (Voice Reply)
* Shows mic icon only
*/
export function VoiceMessageCompactPreview(_props: { event: NostrEvent }) {
return (
<span className="flex items-center text-muted-foreground">
<Mic className="size-3.5" />
</span>
);
}

View File

@@ -0,0 +1,76 @@
import type { NostrEvent } from "@/types/nostr";
import { Zap } from "lucide-react";
import { useMemo } from "react";
import {
getZapAmount,
getZapEventPointer,
getZapAddressPointer,
getZapRequest,
} from "applesauce-core/helpers/zap";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getContentPreview } from "./index";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
/**
* Compact preview for Kind 9735 (Zap Receipt)
* Layout: [amount] [zap message] [target pubkey] [preview]
*/
export function ZapCompactPreview({ event }: { event: NostrEvent }) {
const zapAmount = useMemo(() => getZapAmount(event), [event]);
const zapRequest = useMemo(() => getZapRequest(event), [event]);
// Get zap comment from request
const zapMessage = useMemo(() => {
if (!zapRequest) return null;
return zapRequest.content || null;
}, [zapRequest]);
// Get zapped content pointers
const eventPointer = useMemo(() => getZapEventPointer(event), [event]);
const addressPointer = useMemo(() => getZapAddressPointer(event), [event]);
// Fetch the zapped event (prefer address pointer for replaceable events)
const zappedByEvent = useNostrEvent(eventPointer || undefined);
const zappedByAddress = useNostrEvent(addressPointer || undefined);
const zappedEvent = zappedByAddress || zappedByEvent;
// Convert from msats to sats
const amountInSats = useMemo(() => {
if (!zapAmount) return 0;
return Math.floor(zapAmount / 1000);
}, [zapAmount]);
// Get content preview
const preview = zappedEvent ? getContentPreview(zappedEvent, 40) : null;
return (
<span className="flex items-center gap-1 text-sm truncate">
<Zap className="size-3 fill-yellow-500 text-yellow-500 shrink-0" />
<span className="text-yellow-500 font-medium shrink-0">
{amountInSats.toLocaleString("en", { notation: "compact" })}
</span>
{zapMessage && (
<span className="flex-1">
<RichText
content={zapMessage}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
)}
{zappedEvent && (
<>
<UserName pubkey={zappedEvent.pubkey} className="text-sm shrink-0" />
<span className="text-muted-foreground truncate">
<RichText
content={preview || ""}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
</>
)}
</span>
);
}

View File

@@ -0,0 +1,101 @@
import type { NostrEvent } from "@/types/nostr";
import { getEventDisplayTitle } from "@/lib/event-title";
import { getTagValue } from "applesauce-core/helpers";
import { RichText } from "../RichText";
// Compact preview renderer type - receives event, returns content for preview area
export type CompactPreviewRenderer = React.ComponentType<{ event: NostrEvent }>;
/**
* Extract a short content preview from an event
* Used for showing what was reposted/reacted/zapped
*/
export function getContentPreview(event: NostrEvent, maxLength = 50): string {
// Handle voice messages specially - content is just a URL
if (event.kind === 1222 || event.kind === 1244) {
return "Voice message";
}
// Try to get title first (for articles, etc.)
const title = getTagValue(event, "title") || getTagValue(event, "subject");
if (title) {
return title.length > maxLength ? title.slice(0, maxLength) + "..." : title;
}
// Fall back to content
const content = event.content || "";
if (!content) {
return getEventDisplayTitle(event, true);
}
// Clean up content (remove markdown, links, etc.)
const cleaned = content
.replace(/https?:\/\/\S+/g, "") // Remove URLs
.replace(/nostr:[a-z0-9]+/gi, "") // Remove nostr: references
.replace(/\n+/g, " ") // Replace newlines with spaces
.trim();
if (!cleaned) {
return getEventDisplayTitle(event, true);
}
return cleaned.length > maxLength
? cleaned.slice(0, maxLength) + "..."
: cleaned;
}
// Registry for kind-specific compact renderers
// Keys are kind numbers, values are components that render the preview content
export const compactRenderers: Partial<Record<number, CompactPreviewRenderer>> =
{};
/**
* Register a compact preview renderer for a specific kind
* @param kind - The event kind number
* @param renderer - The component to render the preview
*/
export function registerCompactRenderer(
kind: number,
renderer: CompactPreviewRenderer,
) {
compactRenderers[kind] = renderer;
}
/**
* Default compact preview for events without a specific renderer
* Shows event title/content preview with RichText
*/
export function DefaultCompactPreview({ event }: { event: NostrEvent }) {
// Try to get a title, fall back to content preview
const title = getEventDisplayTitle(event, false);
// If title is the content itself, truncate it
const displayText =
title === event.content && title.length > 80
? title.slice(0, 80) + "..."
: title;
return (
<span className="truncate text-muted-foreground text-sm">
<RichText
content={displayText}
className="inline text-sm leading-none"
options={{ showMedia: false, showEventEmbeds: false }}
/>
</span>
);
}
// Import and register compact renderers
import { RepostCompactPreview } from "./RepostCompactPreview";
import { ReactionCompactPreview } from "./ReactionCompactPreview";
import { GenericRepostCompactPreview } from "./GenericRepostCompactPreview";
import { ZapCompactPreview } from "./ZapCompactPreview";
import { VoiceMessageCompactPreview } from "./VoiceMessageCompactPreview";
registerCompactRenderer(6, RepostCompactPreview);
registerCompactRenderer(7, ReactionCompactPreview);
registerCompactRenderer(16, GenericRepostCompactPreview);
registerCompactRenderer(9735, ZapCompactPreview);
registerCompactRenderer(1222, VoiceMessageCompactPreview);
registerCompactRenderer(1244, VoiceMessageCompactPreview);

View File

@@ -3,17 +3,44 @@ import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import { parseImetaTags } from "@/lib/imeta";
/**
* Get video URL from event - tries imeta first, then url tag
*/
function getVideoUrl(event: {
tags: string[][];
content: string;
}): string | null {
// Try imeta tags first (NIP-92)
const videos = parseImetaTags(event as any);
if (videos.length > 0 && videos[0].url) {
return videos[0].url;
}
// Fallback: try url tag (older NIP-71 format)
const urlTag = event.tags.find((t) => t[0] === "url")?.[1];
if (urlTag) {
return urlTag;
}
return null;
}
/**
* Renderer for Kind 22 - Short Video Event (NIP-71)
* Also handles Kind 34236 - Vertical Video (legacy NIP-71)
*
* Short-form portrait video events (like TikTok/Reels)
*/
export function Kind22Renderer({ event }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
// Get video URL (imeta or url tag fallback)
const videoUrl = getVideoUrl(event);
// Get title from tags
const title = event.tags.find((t) => t[0] === "title")?.[1];
// Get alt text for accessibility
const altText = event.tags.find((t) => t[0] === "alt")?.[1];
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
@@ -21,17 +48,26 @@ export function Kind22Renderer({ event }: BaseEventProps) {
{title && <h3 className="text-base font-semibold">{title}</h3>}
{/* Short video - optimized for portrait */}
{videos.length > 0 && (
{videoUrl ? (
<MediaEmbed
url={videos[0].url}
url={videoUrl}
type="video"
preset="preview"
showControls
alt={altText}
/>
) : (
<p className="text-sm text-muted-foreground italic">
No video URL found
</p>
)}
{/* Description */}
{event.content && <RichText event={event} className="text-sm" />}
{/* Description - from content or alt tag */}
{event.content ? (
<RichText event={event} className="text-sm" />
) : (
altText && <p className="text-sm text-muted-foreground">{altText}</p>
)}
</div>
</BaseEventContainer>
);

View File

@@ -3,13 +3,37 @@ import { MediaEmbed } from "../MediaEmbed";
import { RichText } from "../RichText";
import { parseImetaTags } from "@/lib/imeta";
/**
* Get video URL from event - tries imeta first, then url tag
*/
function getVideoUrl(event: {
tags: string[][];
content: string;
}): string | null {
// Try imeta tags first (NIP-92)
const videos = parseImetaTags(event as any);
if (videos.length > 0 && videos[0].url) {
return videos[0].url;
}
// Fallback: try url tag (older NIP-71 format)
const urlTag = event.tags.find((t) => t[0] === "url")?.[1];
if (urlTag) {
return urlTag;
}
return null;
}
/**
* Renderer for Kind 21 - Video Event (NIP-71)
* Horizontal/landscape video events with imeta tags
* Also handles Kind 34235 - Horizontal Video (legacy NIP-71)
*
* Horizontal/landscape video events with imeta tags or url tag
*/
export function Kind21Renderer({ event }: BaseEventProps) {
// Parse imeta tags to get video URLs and metadata
const videos = parseImetaTags(event);
// Get video URL (imeta or url tag fallback)
const videoUrl = getVideoUrl(event);
// Get title from tags
const title = event.tags.find((t) => t[0] === "title")?.[1];
@@ -20,14 +44,18 @@ export function Kind21Renderer({ event }: BaseEventProps) {
{/* 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 && (
{/* Video player */}
{videoUrl ? (
<MediaEmbed
url={videos[0].url}
url={videoUrl}
type="video"
preset="preview"
showControls
/>
) : (
<p className="text-sm text-muted-foreground italic">
No video URL found
</p>
)}
{/* Description */}

View File

@@ -0,0 +1,114 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
import { getKindName } from "@/constants/kinds";
import type { NostrEvent } from "@/types/nostr";
/**
* Get reply pointer from voice message reply (kind 1244)
* Parses the e tag manually since getCommentReplyPointer only works for kind 1111
*/
function getVoiceReplyPointer(event: NostrEvent): { id: string } | undefined {
if (event.kind !== 1244) return undefined;
// Find the e tag - format: ["e", "<event-id>", "<relay>", "<pubkey>"]
const eTag = event.tags.find((t) => t[0] === "e");
if (!eTag || !eTag[1]) return undefined;
return { id: eTag[1] };
}
/**
* Parent event preview - compact inline display
*/
function ParentPreview({
parentEvent,
onClickHandler,
}: {
parentEvent: NostrEvent;
onClickHandler: () => void;
}) {
const kindName = getKindName(parentEvent.kind);
const isVoiceMessage = parentEvent.kind === 1222;
return (
<div
onClick={onClickHandler}
className="flex items-center gap-2 text-xs cursor-crosshair hover:opacity-80 transition-opacity"
>
<UserName
pubkey={parentEvent.pubkey}
className="text-accent font-medium flex-shrink-0"
/>
<span className="text-muted-foreground shrink-0">[{kindName}]</span>
{!isVoiceMessage && (
<div className="text-muted-foreground truncate min-w-0 flex-1">
<RichText
event={parentEvent}
className="line-clamp-1"
options={{
showMedia: false,
showEventEmbeds: false,
}}
/>
</div>
)}
</div>
);
}
/**
* Renderer for Kind 1222 - Voice Message (NIP-A0)
* and Kind 1244 - Voice Message Reply (NIP-A0)
*
* Simple display: just the audio player, with reply context for 1244
*/
export function VoiceMessageRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
// Audio URL is in event.content per NIP-A0
const audioUrl = event.content.trim();
// For kind 1244 (voice reply), get the reply pointer
const isReply = event.kind === 1244;
const replyPointer = isReply ? getVoiceReplyPointer(event) : undefined;
const replyEvent = useNostrEvent(replyPointer, event);
const handleReplyClick = () => {
if (!replyEvent || !replyPointer) return;
addWindow("open", { pointer: replyPointer });
};
// Validate URL
const isValidUrl =
audioUrl.startsWith("http://") || audioUrl.startsWith("https://");
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Reply context for kind 1244 */}
{isReply && replyPointer && !replyEvent && <InlineReplySkeleton />}
{isReply && replyPointer && replyEvent && (
<ParentPreview
parentEvent={replyEvent}
onClickHandler={handleReplyClick}
/>
)}
{/* Audio player */}
{isValidUrl ? (
<MediaEmbed url={audioUrl} type="audio" showControls />
) : (
<p className="text-sm text-muted-foreground italic">
Invalid audio URL
</p>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -1343,6 +1343,20 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
// nip: "",
// icon: AppWindow,
// },
34235: {
kind: 34235,
name: "Video",
description: "Horizontal Video (legacy)",
nip: "71",
icon: Video,
},
34236: {
kind: 34236,
name: "Short Video",
description: "Vertical Video (legacy)",
nip: "71",
icon: Video,
},
34550: {
kind: 34550,
name: "Community",