import { Link, useNavigate } from 'react-router-dom'; import { MessageCircle, Repeat2, Zap, MoreHorizontal, Play } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { NoteContent } from '@/components/NoteContent'; import { ReactionButton } from '@/components/ReactionButton'; import { useAuthor } from '@/hooks/useAuthor'; import { useEventStats } from '@/hooks/useTrending'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { nip19 } from 'nostr-tools'; import { useMemo, useState, useRef } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; interface NoteCardProps { event: NostrEvent; className?: string; } /** Formats a sats amount into a compact human-readable string. */ function formatSats(sats: number): string { if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`; return sats.toString(); } /** Extracts image URLs from note content. */ function extractImages(content: string): string[] { const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi; const matches = content.match(urlRegex); return matches || []; } /** Gets a tag value by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } /** Parse imeta tag into structured object for kind 34236. */ function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } { const imetaTag = tags.find(([name]) => name === 'imeta'); if (!imetaTag) return {}; const result: Record = {}; for (let i = 1; i < imetaTag.length; i++) { const part = imetaTag[i]; const spaceIdx = part.indexOf(' '); if (spaceIdx === -1) continue; const key = part.slice(0, spaceIdx); const value = part.slice(spaceIdx + 1); if (key === 'url') result.url = value; else if (key === 'image') result.thumbnail = value; } return result; } /** Encodes the NIP-19 identifier for navigating to an event. */ function encodeEventId(event: NostrEvent): string { // Addressable events use naddr if (event.kind >= 30000 && event.kind < 40000) { const dTag = getTag(event.tags, 'd'); if (dTag) { return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); } } return nip19.neventEncode({ id: event.id, author: event.pubkey }); } export function NoteCard({ event, className }: NoteCardProps) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name || genUserName(event.pubkey); const nip05 = metadata?.nip05; const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); const encodedId = useMemo(() => encodeEventId(event), [event]); const { data: stats } = useEventStats(event.id); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const isVine = event.kind === 34236; // Kind 1 specific const images = useMemo(() => isVine ? [] : extractImages(event.content), [event.content, isVine]); const isReply = !isVine && event.tags.some(([name]) => name === 'e'); const replyTo = !isVine ? event.tags.find(([name, , , marker]) => name === 'p' && marker !== 'mention') : undefined; // Kind 34236 specific const imeta = useMemo(() => isVine ? parseImeta(event.tags) : undefined, [event.tags, isVine]); const vineTitle = isVine ? getTag(event.tags, 'title') : undefined; const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : []; return (
navigate(`/${encodedId}`)} > {/* Reply context (kind 1 only) */} {isReply && replyTo && ( )}
{/* Avatar */} e.stopPropagation()}> {displayName[0]?.toUpperCase()} {/* Content */}
{/* Header */}
e.stopPropagation()} > {displayName} {nip05 && ( @{nip05} )} {metadata?.bot && ( ๐Ÿค– )} ยท {timeAgo(event.created_at)}
{/* Body โ€” kind-specific content */} {isVine ? ( ) : ( )} {/* Action buttons โ€” shared across all kinds */}
); } /** Body content for kind 1 text notes. */ function NoteBody({ event, images }: { event: NostrEvent; images: string[] }) { return ( <>
{images.length > 0 && (
1 && 'grid grid-cols-2 gap-0.5', )}> {images.slice(0, 4).map((url, i) => ( e.stopPropagation()} > ))}
)} ); } /** Body content for kind 34236 vine events. */ function VineBody({ title, imeta, hashtags }: { title?: string; imeta?: { url?: string; thumbnail?: string }; hashtags: string[] }) { const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const handlePlayToggle = (e: React.MouseEvent) => { e.stopPropagation(); const video = videoRef.current; if (!video) return; if (video.paused) { video.play(); setIsPlaying(true); } else { video.pause(); setIsPlaying(false); } }; return ( <> {title && (

{title}

)} {imeta?.url && (
)} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( e.stopPropagation()} > #{tag} ))}
)} ); } function ReplyContext({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); return (
Replying to @{name}
); }