import type { NostrEvent } from "@nostrify/nostrify"; import { Award, FileCode, FileText, GitBranch, GitPullRequest, MessageCircle, MoreHorizontal, Package, Palette, Play, Radio, Share2, SmilePlus, Users, Zap, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { ArticleContent } from "@/components/ArticleContent"; import { MusicPlaylistContent, MusicTrackContent, PodcastEpisodeContent, PodcastTrailerContent, } from "@/components/AudioKindContent"; import { BadgeContent } from "@/components/BadgeContent"; import { CalendarEventContent } from "@/components/CalendarEventContent"; import { ColorMomentContent, ColorMomentEyeButton, } from "@/components/ColorMomentContent"; import { CommentContext } from "@/components/CommentContext"; import { ContentWarningGuard } from "@/components/ContentWarningGuard"; import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji"; import { CustomNipCard } from "@/components/CustomNipCard"; import { EmojiPackContent } from "@/components/EmojiPackContent"; import { FileMetadataContent } from "@/components/FileMetadataContent"; import { FollowPackContent } from "@/components/FollowPackContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { ImageGallery } from "@/components/ImageGallery"; import { CardsIcon } from "@/components/icons/CardsIcon"; import { ChestIcon } from "@/components/icons/ChestIcon"; import { RepostIcon } from "@/components/icons/RepostIcon"; import { LiveStreamPlayer } from "@/components/LiveStreamPlayer"; import { MagicDeckContent } from "@/components/MagicDeckContent"; import { Nip05Badge } from "@/components/Nip05Badge"; import { NoteContent } from "@/components/NoteContent"; import { NoteMedia } from "@/components/NoteMedia"; import { NoteMoreMenu } from "@/components/NoteMoreMenu"; import { PatchCard } from "@/components/PatchCard"; import { PollContent } from "@/components/PollContent"; import { ProfileBadgesContent } from "@/components/ProfileBadgesContent"; import { ProfileHoverCard } from "@/components/ProfileHoverCard"; import { PullRequestCard } from "@/components/PullRequestCard"; import { ReactionButton } from "@/components/ReactionButton"; import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { ThemeContent } from "@/components/ThemeContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getAvatarShape } from "@/lib/avatarShape"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { VideoPlayer } from "@/components/VideoPlayer"; import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; import { ZapDialog } from "@/components/ZapDialog"; import { useAppContext } from "@/hooks/useAppContext"; import { useAuthor } from "@/hooks/useAuthor"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useNip05Verify } from "@/hooks/useNip05Verify"; import { useOpenPost } from "@/hooks/useOpenPost"; import { useProfileUrl } from "@/hooks/useProfileUrl"; import { toast } from "@/hooks/useToast"; import { useEventStats } from "@/hooks/useTrending"; import { canZap } from "@/lib/canZap"; import { getContentWarning } from "@/lib/contentWarning"; import { genUserName } from "@/lib/genUserName"; import { getDisplayName } from "@/lib/getDisplayName"; import { type ImetaEntry, parseImetaMap } from "@/lib/imeta"; import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls"; import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents"; import { isSingleImagePost } from "@/lib/noteContent"; import { shareOrCopy } from "@/lib/share"; import { timeAgo } from "@/lib/timeAgo"; import { formatNumber } from "@/lib/formatNumber"; import { getEffectiveStreamStatus } from "@/lib/streamStatus"; import { cn } from "@/lib/utils"; interface NoteCardProps { event: NostrEvent; className?: string; /** If set, shows a "Reposted by" header with this pubkey. */ repostedBy?: string; /** If true, hide action buttons (used for embeds). */ compact?: boolean; /** If true, render in threaded ancestor style: connector line below avatar, no bottom border. */ threaded?: boolean; /** Like threaded but without the connector line — used for the last item in a thread (e.g. sub-reply hint). */ threadedLast?: boolean; } /** Gets a tag value by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } /** Parse single imeta tag into structured object (legacy, for kind 34236 vines). */ 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 }); } /** d-tags reserved by NIP-51 for other purposes — hide these kind 30000 events. */ const DEPRECATED_DTAGS = new Set(["mute", "pin", "bookmark", "communities"]); /** Returns true if a kind 30000 event is a deprecated/junk list that should be hidden. */ function isDeprecatedFollowSet(event: NostrEvent): boolean { if (event.kind !== 30000) return false; const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? ""; if (DEPRECATED_DTAGS.has(dTag)) return true; // Filter empty lists with no p-tags or title const hasPTags = event.tags.some(([n]) => n === "p"); const hasTitle = event.tags.some(([n]) => n === "title" || n === "name"); if (!hasPTags && !hasTitle) return true; return false; } export const NoteCard = memo(function NoteCard({ event, className, repostedBy, compact, threaded, threadedLast, }: NoteCardProps) { const { config } = useAppContext(); const { user } = useCurrentUser(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const displayName = getDisplayName(metadata, event.pubkey); const nip05 = metadata?.nip05; const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify( nip05, event.pubkey, ); const profileUrl = useProfileUrl(event.pubkey, metadata); const encodedId = useMemo(() => encodeEventId(event), [event]); const { data: stats } = useEventStats(event.id, event); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); // Check if the current user can zap this event's author const canZapAuthor = user && canZap(metadata); const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost( `/${encodedId}`, ); // Handler to navigate to post detail, but only if click didn't originate from a modal const handleCardClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; if ( target.closest('[role="dialog"]') || target.closest("[data-radix-dialog-overlay]") || target.closest("[data-radix-dialog-content]") || target.closest("[data-vaul-drawer]") || target.closest("[data-vaul-drawer-overlay]") || target.closest('[data-testid="zap-modal"]') ) { return; } openPost(); }; const handleAuxClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; if ( target.closest('[role="dialog"]') || target.closest("[data-radix-dialog-overlay]") || target.closest("[data-radix-dialog-content]") || target.closest("[data-vaul-drawer]") || target.closest("[data-vaul-drawer-overlay]") || target.closest('[data-testid="zap-modal"]') ) { return; } auxOpenPost(e); }; const isVine = event.kind === 34236; const isPoll = event.kind === 1068; const isGeocache = event.kind === 37516; const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; const isFollowPack = event.kind === 39089 || event.kind === 30000; const isArticle = event.kind === 30023; const isMagicDeck = event.kind === 37381; const isStream = event.kind === 30311; const isFileMetadata = event.kind === 1063; const isThemeDefinition = event.kind === 36767; const isActiveTheme = event.kind === 16767; const isTheme = isThemeDefinition || isActiveTheme; const isVoiceMessage = event.kind === 1222 || event.kind === 1244; const isCalendarEvent = event.kind === 31922 || event.kind === 31923; const isEmojiPack = event.kind === 30030; const isBadgeDefinition = event.kind === 30009; const isProfileBadges = event.kind === 30008; const isBadge = isBadgeDefinition || isProfileBadges; const isReaction = event.kind === 7; const isPhoto = event.kind === 20; const isNormalVideo = event.kind === 21; const isShortVideo = event.kind === 22; const isVideo = isNormalVideo || isShortVideo; const isMusicTrack = event.kind === 36787; const isMusicPlaylist = event.kind === 34139; const isPodcastEpisode = event.kind === 30054; const isPodcastTrailer = event.kind === 30055; const isAudioKind = isMusicTrack || isMusicPlaylist || isPodcastEpisode || isPodcastTrailer; const isGitRepo = event.kind === 30617; const isPatch = event.kind === 1617; const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; const isZapstoreApp = event.kind === 32267; const isVanish = event.kind === 62; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream && !isFileMetadata && !isTheme && !isVoiceMessage && !isCalendarEvent && !isEmojiPack && !isBadge && !isReaction && !isPhoto && !isVideo && !isAudioKind && !isDevKind && !isZapstoreApp && !isVanish; // Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia const videos = useMemo( () => (isTextNote ? extractVideoUrls(event.content) : []), [event.content, isTextNote], ); const audios = useMemo(() => { if (!isTextNote) return []; // Prefer imeta-declared audio over URL scraping const imetaAudios = Array.from(parseImetaMap(event.tags).values()) .filter((e) => e.mime?.startsWith("audio/")) .map((e) => e.url); if (imetaAudios.length > 0) return imetaAudios; return extractAudioUrls(event.content); }, [event.content, event.tags, isTextNote]); const imetaMap = useMemo( () => isTextNote ? parseImetaMap(event.tags) : new Map(), [event.tags, isTextNote], ); // Extract webxdc attachments from imeta tags const webxdcApps = useMemo(() => { if (!isTextNote) return []; return Array.from(imetaMap.values()).filter( (entry) => entry.mime === "application/x-webxdc" || entry.mime === "application/vnd.webxdc+zip", ); }, [imetaMap, isTextNote]); const isComment = event.kind === 1111; const isReply = isTextNote && !isComment && isReplyEvent(event); // Find all people being replied to (for "Replying to @user1 and @user2") const replyToPubkeys = useMemo(() => { if (!isTextNote || !isReply) return []; // Get all p tags that aren't marked as mentions const pTags = event.tags.filter( ([name, , , marker]) => name === "p" && marker !== "mention", ); if (pTags.length > 0) { // Remove duplicates and filter out undefined/empty pubkeys return [ ...new Set(pTags.map(([, pubkey]) => pubkey).filter(Boolean)), ] as string[]; } // Fallback: if all p tags are mentions, use all p tags anyway const allPTags = event.tags.filter(([name]) => name === "p"); if (allPTags.length > 0) { return [ ...new Set(allPTags.map(([, pubkey]) => pubkey).filter(Boolean)), ] as string[]; } // Self-reply fallback: when replying to own post, no p tags are added (the // author's own pubkey is excluded during compose). Try to extract the parent // author from the reply/root e-tag's 5th element (NIP-10 pubkey hint), and // ultimately fall back to the event author (self-reply). const eTags = event.tags.filter( ([name, , , marker]) => name === "e" && marker !== "mention", ); const replyTag = eTags.find(([, , , marker]) => marker === "reply"); const rootTag = eTags.find(([, , , marker]) => marker === "root"); const parentAuthor = replyTag?.[4] || rootTag?.[4] || event.pubkey; return [parentAuthor]; }, [event.tags, isTextNote, isReply, event.pubkey]); // Extract the parent event ID for reply hover card preview const parentEventId = useMemo(() => { if (!isReply) return undefined; return getParentEventId(event); }, [event, isReply]); // 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) : []; // Filter out deprecated/junk kind 30000 events if (isDeprecatedFollowSet(event)) { return null; } // NIP-36: If the event has a content-warning and the policy is "hide", skip rendering entirely if ( getContentWarning(event) !== undefined && config.contentWarningPolicy === "hide" ) { return null; } // Hide magic decks tagged t:unlisted and geocaches tagged t:hidden if ( isMagicDeck && event.tags.some(([n, v]) => n === "t" && v === "unlisted") ) { return null; } if (isGeocache && event.tags.some(([n, v]) => n === "t" && v === "hidden")) { return null; } // Shared content block used in both normal and threaded layouts const contentBlock = ( <> {/* Reply context (kind 1) or comment context (kind 1111) — shown above content */} {isComment && } {isReply && ( )} {/* Content — kind-based dispatch, guarded by NIP-36 content-warning */} {isPhoto ? ( ) : isVideo ? ( ) : isVine ? ( <> {vineTitle && (

{vineTitle}

)} ) : isPoll ? ( ) : isGeocache ? ( ) : isFoundLog ? ( ) : isColor ? ( ) : isFollowPack ? ( ) : isArticle ? ( ) : isMagicDeck ? ( ) : isStream ? ( ) : isFileMetadata ? ( ) : isEmojiPack ? ( ) : isBadgeDefinition ? ( ) : isProfileBadges ? ( ) : isTheme ? ( ) : isVoiceMessage ? ( ) : isCalendarEvent ? ( ) : isMusicTrack ? ( ) : isMusicPlaylist ? ( ) : isPodcastEpisode ? ( ) : isPodcastTrailer ? ( ) : isGitRepo ? ( ) : isPatch ? ( ) : isPullRequest ? ( ) : isCustomNip ? ( ) : isZapstoreApp ? ( ) : ( )}
); // Shared author info block — min-h-[42px] keeps the container the same height // whether the skeleton or the resolved profile is rendered, preventing layout shifts. const authorInfo = author.isLoading ? (
) : (
e.stopPropagation()} > {author.data?.event ? ( {displayName} ) : ( displayName )} {metadata?.bot && ( 🤖 )}
{nip05 && nip05Pending && } {nip05 && nip05Pending && ·} {nip05 && nip05Verified && ( )} {nip05 && nip05Verified && ·} {timeAgo(event.created_at)}
); // Shared avatar element const avatarElement = author.isLoading ? ( ) : ( e.stopPropagation()} > {displayName[0]?.toUpperCase()} ); // ── Shared action buttons (used in all layouts) ── const actionButtons = (
{(isReposted: boolean) => ( )} {canZapAuthor && ( )}
); // ── Vanish layout (kind 62) — dramatic card, no author row ── if (isVanish) { // Threaded vanish (ancestor in a reply thread — needs connector line + avatar column) if (threaded || threadedLast) { return (
{avatarElement} {threaded && (
)}
{!compact && ( <> {actionButtons} )}
); } return (
{!compact && ( <> {actionButtons} )}
); } // ── Reaction layout (kind 7) — compact activity-style card ── if (isReaction) { // Threaded reaction (used in AncestorThread with connector line) if (threaded || threadedLast) { return (
{/* Reaction emoji bubble instead of avatar */}
{threaded && (
)}
{author.isLoading ? ( ) : ( e.stopPropagation()} > {displayName[0]?.toUpperCase()} )} {author.isLoading ? ( ) : ( e.stopPropagation()} > {author.data?.event ? ( {displayName} ) : ( displayName )} )} reacted {timeAgo(event.created_at)}
); } // Normal reaction card (standalone or in feed) return (
{/* Large reaction emoji */}
{/* Author + "reacted" label */}
{author.isLoading ? ( <> ) : ( <> e.stopPropagation()} > {displayName[0]?.toUpperCase()} e.stopPropagation()} > {author.data?.event ? ( {displayName} ) : ( displayName )} reacted {timeAgo(event.created_at)} )}
); } // ── Threaded layout (with or without connector line) ── if (threaded || threadedLast) { return (
{isFollowPack ? (
{contentBlock} {actionButtons}
) : (
{avatarElement} {threaded && (
)}
{authorInfo} {contentBlock} {actionButtons}
)}
); } // ── Normal layout ── return (
{/* Action header — repost takes priority, otherwise derived from event kind */} {repostedBy ? ( ) : ( KIND_HEADER_MAP[event.kind] && (() => { const cfg = KIND_HEADER_MAP[event.kind]; const isLive = event.kind === 30311 && getEffectiveStreamStatus(event) === "live"; return ( ); })() )} {/* For follow packs / lists: content-first layout with subtle author attribution */} {isFollowPack ? ( <> {contentBlock} {!compact && ( <> {actionButtons} )} ) : ( <> {/* Header: avatar + name/handle stacked */}
{avatarElement} {authorInfo} {isColor && }
{contentBlock} {/* Action buttons — hidden in compact/embed mode */} {!compact && ( <> {actionButtons} )} )}
); }); const MAX_HEIGHT = 400; // px — posts taller than this get truncated /** Truncates long text note content with a "Read more" fade + button. * Media attachments are also hidden behind the truncation and revealed on expand. */ function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps = [], }: { event: NostrEvent; videos: string[]; audios?: string[]; imetaMap: Map; webxdcApps?: ImetaEntry[]; }) { const contentRef = useRef(null); const [overflows, setOverflows] = useState(false); const [expanded, setExpanded] = useState(false); const singleImage = isSingleImagePost(event); const measure = useCallback(() => { const el = contentRef.current; if (el) setOverflows(!singleImage && el.scrollHeight > MAX_HEIGHT); }, [singleImage]); useEffect(() => { measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [measure]); // Re-measure after images load — scrollHeight is unreliable before images have rendered. useEffect(() => { const el = contentRef.current; if (!el) return; const imgs = el.querySelectorAll("img"); if (imgs.length === 0) return; imgs.forEach((img) => img.addEventListener("load", measure, { once: true }), ); return () => imgs.forEach((img) => img.removeEventListener("load", measure)); }, [measure]); const showMedia = !overflows || expanded; return (
{!expanded && overflows && (
)}
{overflows && ( )} {showMedia && ( )}
); } // ── NIP-68 Photo content (kind 20) ──────────────────────────────────────────── /** Parse all imeta image URLs from NIP-68 photo events. */ function parsePhotoUrls( tags: string[][], ): Array<{ url: string; alt?: string; blurhash?: string }> { const results: Array<{ url: string; alt?: string; blurhash?: string }> = []; for (const tag of tags) { if (tag[0] !== "imeta") continue; const parts: Record = {}; for (let i = 1; i < tag.length; i++) { const p = tag[i]; const sp = p.indexOf(" "); if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1); } if (parts.url) results.push({ url: parts.url, alt: parts.alt, blurhash: parts.blurhash, }); } return results; } /** Inline photo gallery for NIP-68 kind 20 events. */ function PhotoContent({ event }: { event: NostrEvent }) { const photos = useMemo(() => parsePhotoUrls(event.tags), [event.tags]); const title = getTag(event.tags, "title"); const description = event.content; const hashtags = event.tags.filter(([n]) => n === "t").map(([, v]) => v); // Build imetaMap with dim + blurhash so ImageGallery can show blurhash placeholders const imetaMap = useMemo(() => { const map = new Map(); for (const photo of photos) { map.set(photo.url, { blurhash: photo.blurhash }); } return map; }, [photos]); if (photos.length === 0) return null; return (
{title &&

{title}

} p.url)} maxVisible={4} maxGridHeight="480px" imetaMap={imetaMap} /> {description && (

{description}

)} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( e.stopPropagation()} > #{tag} ))}
)}
); } // ── NIP-71 Video content (kinds 21 & 22) ────────────────────────────────────── /** Parse the primary video url and thumbnail from NIP-71 imeta tags. */ function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; duration?: string; } { for (const tag of tags) { if (tag[0] !== "imeta") continue; const parts: Record = {}; for (let i = 1; i < tag.length; i++) { const p = tag[i]; const sp = p.indexOf(" "); if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1); } if (parts.url) return { url: parts.url, thumbnail: parts.image, duration: parts.duration, }; } // Fallback to plain url/thumb tags return { url: tags.find(([n]) => n === "url")?.[1], thumbnail: tags.find(([n]) => n === "thumb")?.[1] ?? tags.find(([n]) => n === "image")?.[1], }; } /** Format seconds into MM:SS / HH:MM:SS. */ function fmtDuration(seconds: string | undefined): string | undefined { const s = parseFloat(seconds ?? ""); if (isNaN(s) || s <= 0) return undefined; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); const mm = String(m).padStart(2, "0"); const ss = String(sec).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } /** Inline video player for NIP-71 kind 21/22 events. */ function VideoContent({ event }: { event: NostrEvent }) { const { url, thumbnail, duration } = useMemo( () => parseVideoImeta(event.tags), [event.tags], ); const title = getTag(event.tags, "title"); const description = event.content; const isShort = event.kind === 22; const formattedDuration = fmtDuration(duration); const hashtags = event.tags.filter(([n]) => n === "t").map(([, v]) => v); if (!url) return null; return (
{title &&

{title}

}
{formattedDuration && (
{formattedDuration}
)} {isShort && (
Short
)}
{description && (

{description}

)} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( e.stopPropagation()} > #{tag} ))}
)}
); } /** Media content for kind 34236 vine events — rendered at full card width. */ function VineMedia({ imeta, hashtags, }: { imeta?: { url?: string; thumbnail?: string }; hashtags: string[]; }) { const videoRef = useRef(null); const containerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); // Pause video when scrolled out of view useEffect(() => { const video = videoRef.current; const container = containerRef.current; if (!video || !container) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry.isIntersecting && !video.paused) { video.pause(); } }, { threshold: 0.25 }, ); observer.observe(container); return () => observer.disconnect(); }, []); 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 ( <> {imeta?.url && (
)} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( e.stopPropagation()} > #{tag} ))}
)} ); } /** Stream status badge config. */ function getStreamStatusConfig(status: string | undefined) { switch (status) { case "live": return { label: "LIVE", className: "bg-red-600 hover:bg-red-600 text-white border-red-600", }; case "ended": return { label: "ENDED", className: "bg-muted text-muted-foreground border-border", }; case "planned": return { label: "PLANNED", className: "bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600", }; default: return { label: status?.toUpperCase() || "UNKNOWN", className: "bg-muted text-muted-foreground border-border", }; } } /** Inline content for kind 30311 live stream events. */ function StreamContent({ event }: { event: NostrEvent }) { const title = getTag(event.tags, "title") || "Untitled Stream"; const summary = getTag(event.tags, "summary"); const imageUrl = getTag(event.tags, "image"); const streamingUrl = getTag(event.tags, "streaming"); const status = getEffectiveStreamStatus(event); const currentParticipants = getTag(event.tags, "current_participants"); const statusConfig = getStreamStatusConfig(status); const isLive = status === "live" && !!streamingUrl; const encodedId = useMemo(() => { const dTag = getTag(event.tags, "d") || ""; return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag, }); }, [event]); const { onClick: openPost } = useOpenPost(`/${encodedId}`); return (
{/* Stream player / thumbnail */}
{isLive ? ( // Inline live player — clicks on the player are intercepted so they don't navigate away
e.stopPropagation()}> {/* Status + viewer overlay on top of the player */}
{statusConfig.label} {currentParticipants && ( {currentParticipants} )}
) : imageUrl ? (
{ (e.currentTarget.parentElement as HTMLElement).style.display = "none"; }} />
{statusConfig.label}
{currentParticipants && (
{currentParticipants}
)}
) : ( // No image, no live stream — show a minimal placeholder with status
{status === "live" && (
)} {statusConfig.label} {currentParticipants && ( {currentParticipants} )}
)}
{/* Title + summary — clickable to open stream details */}
); } /** Subtle author attribution line for follow pack / list cards. */ function FollowPackAuthorLine({ pubkey, createdAt }: { pubkey: string; createdAt: number }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const displayName = getDisplayName(metadata, pubkey); const profileUrl = useProfileUrl(pubkey, metadata); return (
{author.isLoading ? ( <> ) : ( <> e.stopPropagation()}> {displayName[0]?.toUpperCase()} e.stopPropagation()} > {author.data?.event ? ( {displayName} ) : displayName} · {timeAgo(createdAt)} )}
); } interface EventActionHeaderProps { /** Pubkey of the person performing the action. */ pubkey: string; /** Lucide icon component shown to the left of the author name. */ icon: React.ComponentType<{ className?: string }>; /** Optional className for the icon (defaults to text-primary). */ iconClassName?: string; /** Verb phrase shown after the author name, e.g. "hid a" or "is streaming". */ action: string; /** Optional noun shown after the verb, linked to a page route, e.g. "treasure" → /treasures. */ noun?: string; /** Route to link the noun to, e.g. "/treasures". */ nounRoute?: string; } /** Static config for deriving the action header from an event's kind and tags. */ interface KindHeaderConfig { icon: React.ComponentType<{ className?: string }>; iconClassName?: string; /** Static action string, or a function that computes it from the event's tags (and optionally the full event). */ action: string | ((tags: string[][], event?: NostrEvent) => string); noun?: string; nounRoute?: string; } const KIND_HEADER_MAP: Record = { 37516: { icon: ChestIcon, action: "hid a", noun: "treasure", nounRoute: "/treasures", }, 7516: { icon: ChestIcon, action: "found a", noun: "treasure", nounRoute: "/treasures", }, 37381: { icon: CardsIcon, action: "shared a", noun: "deck", nounRoute: "/decks", }, 36767: { icon: Palette, action: "shared a", noun: "theme", nounRoute: "/themes", }, 16767: { icon: Palette, action: "updated their", noun: "theme", nounRoute: "/themes", }, 30030: { icon: SmilePlus, action: "shared an", noun: "emoji pack", nounRoute: "/emojis", }, 30009: { icon: Award, action: "created a", noun: "badge", nounRoute: "/badges", }, 30008: { icon: Award, action: "updated their", noun: "badges", nounRoute: "/badges", }, 30311: { icon: Radio, iconClassName: undefined, // computed dynamically below action: (_tags, event) => event && getEffectiveStreamStatus(event) === "live" ? "is streaming" : "streamed", }, 32267: { icon: Package, action: "published an app", }, 30617: { icon: GitBranch, action: "shared a", noun: "repository", nounRoute: "/development", }, 1617: { icon: FileText, action: "submitted a", noun: "patch", nounRoute: "/development", }, 1618: { icon: GitPullRequest, action: "opened a", noun: "pull request", nounRoute: "/development", }, 30817: { icon: FileCode, action: "proposed a", noun: "NIP", nounRoute: "/development", }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ function EventActionHeader({ pubkey, icon: Icon, iconClassName, action, noun, nounRoute, }: EventActionHeaderProps) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); const url = useProfileUrl(pubkey, author.data?.metadata); return (
{author.isLoading ? ( ) : ( e.stopPropagation()} > {author.data?.event ? ( {name} ) : ( name )} )} {action} {noun && nounRoute && ( <> {" "} e.stopPropagation()} > {noun} )} {noun && !nounRoute && <> {noun}}
); }