import type { NostrEvent } from "@nostrify/nostrify"; import { Award, Camera, FileCode, FileText, GitBranch, GitPullRequest, Mail, Megaphone, MessageCircle, Rocket, MoreHorizontal, Package, Play, Radio, Share2, SmilePlus, PartyPopper, Users, Zap, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; /** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */ const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent }))); import { MusicPlaylistContent, MusicTrackContent, PodcastEpisodeContent, PodcastTrailerContent, } from "@/components/AudioKindContent"; import { ActionContent } from "@/components/ActionContent"; import { BadgeContent } from "@/components/BadgeContent"; import { CommunityContent } from "@/components/CommunityContent"; import { CalendarEventContent } from "@/components/CalendarEventContent"; import { ColorMomentContent, ColorMomentEyeButton, } from "@/components/ColorMomentContent"; import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext"; import { CommunityContentWarning } from "@/components/CommunityContentWarning"; import { ContentWarningGuard } from "@/components/ContentWarningGuard"; import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji"; const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard }))); import { EmojiPackContent } from "@/components/EmojiPackContent"; import { FileMetadataContent } from "@/components/FileMetadataContent"; import { PeopleListContent } from "@/components/PeopleListContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { GoalCard } from "@/components/GoalCard"; import { NsiteCard } from "@/components/NsiteCard"; 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 { NoteMoreMenu } from "@/components/NoteMoreMenu"; import { PatchCard } from "@/components/PatchCard"; import { PollContent } from "@/components/PollContent"; import { ProfileBadgesContent } from "@/components/ProfileBadgesContent"; import { ProfileCard } from "@/components/ProfileCard"; import { ProfileHoverCard } from "@/components/ProfileHoverCard"; const PullRequestCard = lazy(() => import("@/components/PullRequestCard").then(m => ({ default: m.PullRequestCard }))); import { ReactionButton } from "@/components/ReactionButton"; import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { EncryptedLetterContent } from "@/components/EncryptedLetterContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; import { ZapstoreReleaseContent, ZapstoreAssetContent } from "@/components/ZapstoreReleaseContent"; import { AppHandlerContent } from "@/components/AppHandlerContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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 { extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions"; import { getZapAmountSats } from "@/lib/zapHelpers"; import { satsToUSD } from "@/lib/bitcoin"; import { useBtcPrice } from "@/hooks/useBtcPrice"; import { getContentWarning } from "@/lib/contentWarning"; import { genUserName } from "@/lib/genUserName"; import { getDisplayName } from "@/lib/getDisplayName"; import { usePollVoteLabel } from "@/hooks/usePollVoteLabel"; import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents"; import { isSingleImagePost } from "@/lib/noteContent"; import { shareOrCopy } from "@/lib/share"; import { impactLight } from "@/lib/haptics"; import { timeAgo } from "@/lib/timeAgo"; import { formatNumber } from "@/lib/formatNumber"; import { publishedAtAction } from "@/lib/publishedAtAction"; import { getEffectiveStreamStatus } from "@/lib/streamStatus"; import { cn } from "@/lib/utils"; import { hasGoalZapSplits } from "@/lib/goalUtils"; /** Profile card for use in feeds (kind 0). */ function ProfileCardContent({ event }: { event: NostrEvent }) { let metadata: Record = {}; try { metadata = JSON.parse(event.content); } catch { /* ignore */ } return (
e.stopPropagation()}>
); } /* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */ interface ActivityCardProps { /** The round element in the left column (icon bubble or avatar). */ icon: ReactNode; /** The actor row content (avatar + name + label + timestamp). */ actorRow: ReactNode; /** Optional extra content below the actor row (zap message, vote label, etc.). */ children?: ReactNode; /** Threaded mode: connector line below icon, no bottom border. */ threaded?: boolean; /** Last item in thread — no connector line, has bottom border. */ threadedLast?: boolean; /** Custom connector line class. */ threadedLineClassName?: string; className?: string; onClick?: React.MouseEventHandler; onAuxClick?: React.MouseEventHandler; } export function ActivityCard({ icon, actorRow, children, threaded, threadedLast, threadedLineClassName, className, onClick, onAuxClick, }: ActivityCardProps) { const isThreaded = threaded || threadedLast; return (
{icon} {threaded && (
)}
{actorRow} {children}
); } /** Reusable actor row: small avatar + display name + action label + timestamp. */ export interface ActorRowProps { pubkey: string; profileUrl: string; picture?: string; displayName: string; authorEvent?: NostrEvent; isLoading?: boolean; label: string; /** Extra inline elements after the label (e.g. zap amount). */ extra?: ReactNode; /** Formatted timestamp string (e.g. timeAgo or full date). */ timestampLabel: string; } export function ActorRow({ pubkey, profileUrl, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) { if (isLoading) { return (
); } return (
e.stopPropagation()}> {displayName[0]?.toUpperCase()} e.stopPropagation()}> {authorEvent ? {displayName} : displayName} {label} {extra} {timestampLabel}
); } 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; /** Custom class for the threaded connector line (overrides the default color). */ threadedLineClassName?: string; /** Like threaded but without the connector line — used for the last item in a thread (e.g. sub-reply hint). */ threadedLast?: boolean; /** If true, briefly highlight this card (e.g. newly loaded post). */ highlight?: boolean; /** If true, suppress the kind-derived action header (e.g. "created a badge"). Used when the parent already provides context. */ hideKindHeader?: boolean; /** Override the NIP-22 context row prefix. Used by synthetic zap cards. */ commentContextPrefix?: string; /** Event used for actions/navigation when the displayed card is synthetic. */ actionEvent?: NostrEvent; } /** 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 (30000-39999) use naddr with their d-tag 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, }); } } // Replaceable events (10000-19999) use naddr with an empty identifier if (event.kind >= 10000 && event.kind < 20000) { return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: "", }); } 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; } function isStringTag(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } function getZapRequestTags(event: NostrEvent): string[][] { if (event.kind !== 9735) return []; const description = event.tags.find(([name]) => name === "description")?.[1]; if (!description) return []; try { const parsed = JSON.parse(description) as unknown; if (!parsed || typeof parsed !== "object" || !("tags" in parsed)) return []; const tags = (parsed as { tags?: unknown }).tags; if (!Array.isArray(tags)) return []; return tags.filter(isStringTag); } catch { return []; } } function findZapTargetTag(event: NostrEvent, requestTags: string[][], name: string): string[] | undefined { return event.tags.find(([tagName]) => tagName === name) ?? requestTags.find(([tagName]) => tagName === name); } function buildZapCommentEvent(event: NostrEvent, requestTags: string[][], senderPubkey: string, content: string): NostrEvent { const tags: string[][] = []; const aTag = findZapTargetTag(event, requestTags, "a"); const eTag = findZapTargetTag(event, requestTags, "e"); const recipientTag = findZapTargetTag(event, requestTags, "p"); const kindTag = findZapTargetTag(event, requestTags, "K") ?? findZapTargetTag(event, requestTags, "k"); if (aTag?.[1]) { tags.push(["A", aTag[1], aTag[2] ?? ""]); const targetKind = kindTag?.[1] ?? aTag[1].split(":")[0]; if (targetKind) tags.push(["K", targetKind]); } else if (eTag?.[1]) { tags.push(["E", eTag[1], eTag[2] ?? "", eTag[3] ?? recipientTag?.[1] ?? ""]); if (kindTag?.[1]) tags.push(["K", kindTag[1]]); if (recipientTag?.[1]) tags.push(["P", recipientTag[1]]); } else if (recipientTag?.[1]) { tags.push(["A", `0:${recipientTag[1]}:`], ["K", "0"]); } return { ...event, pubkey: senderPubkey, kind: 1111, content, tags, }; } export const NoteCard = memo(function NoteCard({ event, className, repostedBy, compact, threaded, threadedLineClassName, threadedLast, highlight, hideKindHeader, commentContextPrefix, actionEvent, }: NoteCardProps) { const actionTarget = actionEvent ?? event; const { config } = useAppContext(); const { user } = useCurrentUser(); const author = useAuthor(event.pubkey); const actionAuthor = useAuthor(actionEvent?.pubkey); // Kind 9735 (Lightning zap) sender lives in the receipt's `P` tag / embedded // zap-request `pubkey`; kind 8333 (on-chain Bitcoin zap) is signed by the // donor directly so the event's own pubkey IS the sender. const zapSenderPubkey = useMemo(() => { if (event.kind === 9735) return extractZapSender(event); if (event.kind === 8333) return event.pubkey; return ''; }, [event]); const zapRequestTags = useMemo(() => getZapRequestTags(event), [event]); const pollVoteLabel = usePollVoteLabel(event); const metadata = author.data?.metadata; const actionMetadata = actionEvent ? actionAuthor.data?.metadata : 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(actionTarget), [actionTarget]); const { data: stats } = useEventStats(actionTarget.id, actionTarget); // Cached BTC→USD spot price. Always queried (cheap, shared cache key) so the // zap-card layout below can render amounts as USD when available. const { data: btcPrice } = useBtcPrice(); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); // Check if the current user can zap this event's author // TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported. const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget); 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"]') || target.closest("button") || target.closest("a") ) { 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"]') || target.closest("button") || target.closest("a") ) { 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 === 3 || 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 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 === 10008 || event.kind === 30008; const isBadge = isBadgeDefinition || isProfileBadges; const isCommunity = event.kind === 34550; const isZapGoal = event.kind === 9041; const isAction = event.kind === 36639; const isReaction = event.kind === 7; const isPollVote = event.kind === 1018; const isRepost = event.kind === 6 || event.kind === 16; 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 isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; const isZapstoreRelease = event.kind === 30063; const isZapstoreAsset = event.kind === 3063; const isAppHandler = event.kind === 31990; const isEncryptedDM = event.kind === 4; const isLetter = event.kind === 8211; const isVanish = event.kind === 62; const isZap = event.kind === 9735 || event.kind === 8333; const isProfile = event.kind === 0; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream && !isFileMetadata && !isVoiceMessage && !isCalendarEvent && !isEmojiPack && !isBadge && !isCommunity && !isZapGoal && !isAction && !isReaction && !isPollVote && !isRepost && !isPhoto && !isVideo && !isAudioKind && !isDevKind && !isZapstoreApp && !isZapstoreRelease && !isZapstoreAsset && !isAppHandler && !isEncryptedDM && !isLetter && !isVanish && !isZap && !isProfile; 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 + relay/author hints for reply hover card preview const parentHints = useMemo(() => { if (!isReply) return undefined; return getParentEventHints(event); }, [event, isReply]); const parentEventId = parentHints?.id; // 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 treasures 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. // Wrapped in `CommunityContentWarning`, which subscribes to the community // moderation context internally and is a no-op outside community surfaces. 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 ? ( ) : isCommunity ? ( ) : isZapGoal ? ( ) : isAction ? ( ) : isVoiceMessage ? ( ) : isCalendarEvent ? ( ) : isMusicTrack ? ( ) : isMusicPlaylist ? ( ) : isPodcastEpisode ? ( ) : isPodcastTrailer ? ( ) : isGitRepo ? ( ) : isPatch ? ( ) : isPullRequest ? ( }> ) : isCustomNip ? ( }> ) : isNsite ? ( ) : isZapstoreApp ? (
) : isZapstoreRelease ? (
) : isZapstoreAsset ? (
) : isAppHandler ? ( ) : isEncryptedDM ? ( ) : isLetter ? ( ) : isProfile ? ( ) : ( )}
); // 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) ── if (isReaction) { const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return (
} actorRow={ } threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName} className={className} onClick={handleCardClick} onAuxClick={handleAuxClick} /> ); } // ── Repost layout (kind 6 / 16) ── if (isRepost) { const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( } actorRow={ } threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName} className={className} onClick={handleCardClick} onAuxClick={handleAuxClick} /> ); } // ── Zap receipt layout (kind 9735 Lightning, kind 8333 on-chain Bitcoin) ── // Render as a synthetic NIP-22 card so spacing, header, body, and actions // stay identical to comments while keeping actions tied to the zap receipt. if (isZap) { const zapAmountSats = getZapAmountSats(event); const zapMessage = (event.kind === 8333 ? event.content : extractZapMessage(event)).trim(); const usdLabel = btcPrice ? satsToUSD(zapAmountSats, btcPrice) : undefined; const satsLabel = `${formatNumber(zapAmountSats)} ${zapAmountSats === 1 ? 'sat' : 'sats'}`; const amountText = usdLabel ?? satsLabel; const donationPrefix = zapAmountSats > 0 ? `Donated ${amountText} to` : "Donated to"; const zapCommentEvent = buildZapCommentEvent( event, zapRequestTags, zapSenderPubkey || event.pubkey, zapMessage, ); return ( ); } // ── Poll vote layout (kind 1018) ── if (isPollVote) { const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( e.stopPropagation()}> {displayName[0]?.toUpperCase()} } actorRow={
e.stopPropagation()}> {author.data?.event ? {displayName} : displayName} voted {timeAgo(event.created_at)}
} threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName} className={className} onClick={handleCardClick} onAuxClick={handleAuxClick} > {pollVoteLabel &&

{pollVoteLabel}

}
); } // ── Threaded layout (with or without connector line) ── if (threaded || threadedLast) { // Kind action header (e.g. "updated their badges") — same logic as normal layout const threadedKindHeader = !repostedBy && !hideKindHeader && KIND_HEADER_MAP[event.kind] ? (() => { const cfg = KIND_HEADER_MAP[event.kind]; const isLive = event.kind === 30311 && getEffectiveStreamStatus(event) === "live"; return ( ); })() : null; return (
{/* Foreground wrapper — `relative` lifts the entire post above the absolute backdrop layer rendered by CountryFlagBackdrop. */}
{threadedKindHeader && (
{threadedKindHeader}
)}
{avatarElement} {threaded && (
)}
{authorInfo}
{contentBlock} {actionButtons}
); } // ── Normal layout ── return (
{/* Foreground wrapper — `relative` lifts the entire post above the absolute backdrop layer rendered by CountryFlagBackdrop. */}
{/* Action header — repost takes priority, otherwise derived from event kind */} {repostedBy ? ( ) : ( !hideKindHeader && KIND_HEADER_MAP[event.kind] && (() => { const cfg = KIND_HEADER_MAP[event.kind]; const isLive = event.kind === 30311 && getEffectiveStreamStatus(event) === "live"; return ( ); })() )} {/* Header: avatar + name/handle stacked. The country pill is appended outside this flag-mode wrapper as a flex sibling, so it keeps its own surface treatment. */}
{avatarElement} {authorInfo} {isColor && } {/* Country pill — rendered outside the flag-mode color flip via `[&]:` to escape the parent's color rules. It's wrapped in its own flex slot so the row layout matches the non-flag case (pill anchored right). */}
{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 render inline within NoteContent at their original content position. */ function TruncatedNoteContent({ event, }: { event: NostrEvent; }) { 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]); return (
{!expanded && overflows && (
)}
{overflows && ( )}
); } // ── 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 */}
); } export 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. */ action: string | ((event: NostrEvent) => string); noun?: string; nounRoute?: string; } const KIND_HEADER_MAP: Record = { 20: { icon: Camera, action: "shared a", noun: "photo", nounRoute: "/photos", }, 4: { icon: Mail, action: "sent an", noun: "encrypted message", }, 8211: { icon: Mail, action: "sent a", noun: "letter", nounRoute: "/letters", }, 37516: { icon: ChestIcon, action: (event) => publishedAtAction(event, { created: "hid a", updated: "updated a", fallback: "hid a" }), noun: "treasure", nounRoute: "/treasures", }, 7516: { icon: ChestIcon, action: "found a", noun: "treasure", nounRoute: "/treasures", }, 37381: { icon: CardsIcon, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), noun: "deck", nounRoute: "/decks", }, 30030: { icon: SmilePlus, action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }), noun: "emoji pack", nounRoute: "/emojis", }, 34550: { icon: Users, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), noun: "community", nounRoute: "/communities", }, 30009: { icon: Award, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }), noun: "badge", nounRoute: "/badges", }, 10008: { icon: Award, action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }), noun: "badges", nounRoute: "/badges", }, 30008: { icon: Award, action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }), noun: "badges", nounRoute: "/badges", }, 30311: { icon: Radio, iconClassName: undefined, // computed dynamically below action: (event) => getEffectiveStreamStatus(event) === "live" ? "is streaming" : "streamed", }, 32267: { icon: Package, action: (event) => publishedAtAction(event, { created: "published a Zapstore app", updated: "updated a Zapstore app", fallback: "published a Zapstore app" }), }, 30063: { icon: Package, action: (event) => publishedAtAction(event, { created: "published a Zapstore release", updated: "updated a Zapstore release", fallback: "published a Zapstore release" }), }, 3063: { icon: Package, action: "published a Zapstore asset", }, 31990: { icon: Package, action: (event) => publishedAtAction(event, { created: "published an app", updated: "updated an app", fallback: "published an app" }), }, 30617: { icon: GitBranch, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "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: (event) => publishedAtAction(event, { created: "proposed a", updated: "updated a", fallback: "proposed a" }), noun: "NIP", nounRoute: "/development", }, 15128: { icon: Rocket, action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }), noun: "nsite", nounRoute: "/development", }, 35128: { icon: Rocket, action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }), noun: "nsite", nounRoute: "/development", }, 9735: { icon: Zap, action: "zapped", }, 36639: { icon: Megaphone, action: (event) => publishedAtAction(event, { created: "posted an", updated: "updated an", fallback: "posted an" }), noun: "action", nounRoute: "/actions", }, 39089: { icon: PartyPopper, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), noun: "follow pack", nounRoute: "/packs", }, 30000: { icon: PartyPopper, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), noun: "follow set", nounRoute: "/packs", }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ export 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}}
); }