mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
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:
105
src/components/nostr/CompactEventRow.tsx
Normal file
105
src/components/nostr/CompactEventRow.tsx
Normal 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,
|
||||
);
|
||||
52
src/components/nostr/JsonEventRow.tsx
Normal file
52
src/components/nostr/JsonEventRow.tsx
Normal 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,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
69
src/components/nostr/compact/GenericRepostCompactPreview.tsx
Normal file
69
src/components/nostr/compact/GenericRepostCompactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/nostr/compact/ReactionCompactPreview.tsx
Normal file
138
src/components/nostr/compact/ReactionCompactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/nostr/compact/RepostCompactPreview.tsx
Normal file
56
src/components/nostr/compact/RepostCompactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/nostr/compact/VoiceMessageCompactPreview.tsx
Normal file
14
src/components/nostr/compact/VoiceMessageCompactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/nostr/compact/ZapCompactPreview.tsx
Normal file
76
src/components/nostr/compact/ZapCompactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/nostr/compact/index.tsx
Normal file
101
src/components/nostr/compact/index.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
114
src/components/nostr/kinds/VoiceMessageRenderer.tsx
Normal file
114
src/components/nostr/kinds/VoiceMessageRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user