Files
eranos/src/components/NoteCard.tsx
T
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00

1925 lines
66 KiB
TypeScript

import type { NostrEvent } from "@nostrify/nostrify";
import {
Award,
Camera,
Egg,
FileCode,
FileText,
GitBranch,
GitPullRequest,
Mail,
MessageCircle,
Rocket,
MoreHorizontal,
Package,
Play,
Radio,
Share2,
SmilePlus,
PartyPopper,
Sparkles,
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 })));
const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard })));
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";
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.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 { 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 { 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 { 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 { ThemeContent } from "@/components/ThemeContent";
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 { 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 { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
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 { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { getParentEventHints, 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 { publishedAtAction } from "@/lib/publishedAtAction";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { cn } from "@/lib/utils";
/** Profile card for use in feeds (kind 0). */
function ProfileCardContent({ event }: { event: NostrEvent }) {
let metadata: Record<string, unknown> = {};
try { metadata = JSON.parse(event.content); } catch { /* ignore */ }
return (
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
<ProfileCard pubkey={event.pubkey} metadata={metadata} showNip05={false} />
</div>
);
}
/* ──── 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 (
<article
className={cn(
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
isThreaded
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
: "py-3 border-b border-border",
className,
)}
onClick={onClick}
onAuxClick={onAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{icon}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
{actorRow}
{children}
</div>
</div>
</article>
);
}
/** Reusable actor row: small avatar + display name + action label + timestamp. */
export interface ActorRowProps {
pubkey: string;
profileUrl: string;
avatarShape: Parameters<typeof Avatar>[0]['shape'];
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, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</div>
);
}
return (
<div className="flex items-center gap-2">
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
{extra}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
</div>
);
}
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;
}
/** 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<string, string> = {};
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;
}
export const NoteCard = memo(function NoteCard({
event,
className,
repostedBy,
compact,
threaded,
threadedLineClassName,
threadedLast,
highlight,
hideKindHeader,
}: NoteCardProps) {
const { config } = useAppContext();
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const zapSenderPubkey = useMemo(() => event.kind === 9735 ? extractZapSender(event) : '', [event]);
const zapSender = useAuthor(zapSenderPubkey || undefined);
const zapSenderMeta = zapSender.data?.metadata;
const zapSenderShape = getAvatarShape(zapSenderMeta);
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
const pollVoteLabel = usePollVoteLabel(event);
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"]') ||
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 === 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 === 10008 || event.kind === 30008;
const isBadge = isBadgeDefinition || isProfileBadges;
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;
const isProfile = event.kind === 0;
const isBlobbiState = event.kind === 31124;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
!isVine &&
!isPoll &&
!isGeocache &&
!isFoundLog &&
!isColor &&
!isFollowPack &&
!isArticle &&
!isMagicDeck &&
!isStream &&
!isFileMetadata &&
!isTheme &&
!isVoiceMessage &&
!isCalendarEvent &&
!isEmojiPack &&
!isBadge &&
!isReaction &&
!isPollVote &&
!isRepost &&
!isPhoto &&
!isVideo &&
!isAudioKind &&
!isDevKind &&
!isZapstoreApp &&
!isZapstoreRelease &&
!isZapstoreAsset &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
!isZap &&
!isProfile &&
!isBlobbiState;
// 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<string, ImetaEntry>(),
[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 + 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
const contentBlock = (
<>
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
{isComment && <CommentContext event={event} />}
{isReply && (
<ReplyContext
pubkeys={replyToPubkeys}
parentEventId={parentEventId}
parentRelayHint={parentHints?.relayHint}
parentAuthorHint={parentHints?.authorHint}
/>
)}
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
<ContentWarningGuard event={event}>
{isPhoto ? (
<PhotoContent event={event} />
) : isVideo ? (
<VideoContent event={event} />
) : isVine ? (
<>
{vineTitle && (
<p className="text-[15px] mt-2 leading-relaxed break-words overflow-hidden">
{vineTitle}
</p>
)}
<VineMedia imeta={imeta} hashtags={hashtags} />
</>
) : isPoll ? (
<PollContent event={event} />
) : isGeocache ? (
<GeocacheContent event={event} />
) : isFoundLog ? (
<FoundLogContent event={event} />
) : isColor ? (
<ColorMomentContent event={event} />
) : isFollowPack ? (
<FollowPackContent event={event} />
) : isArticle ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<ArticleContent event={event} preview className="mt-2" />
</Suspense>
) : isMagicDeck ? (
<MagicDeckContent event={event} />
) : isStream ? (
<StreamContent event={event} />
) : isFileMetadata ? (
<FileMetadataContent event={event} compact />
) : isEmojiPack ? (
<EmojiPackContent event={event} />
) : isBadgeDefinition ? (
<BadgeContent event={event} />
) : isProfileBadges ? (
<ProfileBadgesContent event={event} />
) : isTheme ? (
<ThemeContent event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCalendarEvent ? (
<CalendarEventContent event={event} compact />
) : isMusicTrack ? (
<MusicTrackContent event={event} />
) : isMusicPlaylist ? (
<MusicPlaylistContent event={event} />
) : isPodcastEpisode ? (
<PodcastEpisodeContent event={event} />
) : isPodcastTrailer ? (
<PodcastTrailerContent event={event} />
) : isGitRepo ? (
<GitRepoCard event={event} />
) : isPatch ? (
<PatchCard event={event} />
) : isPullRequest ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<PullRequestCard event={event} />
</Suspense>
) : isCustomNip ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<CustomNipCard event={event} />
</Suspense>
) : isNsite ? (
<NsiteCard event={event} />
) : isZapstoreApp ? (
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAppContent event={event} compact />
</div>
</div>
) : isZapstoreRelease ? (
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreReleaseContent event={event} compact />
</div>
</div>
) : isZapstoreAsset ? (
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAssetContent event={event} compact />
</div>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} compact />
) : isLetter ? (
<EncryptedLetterContent event={event} compact />
) : isProfile ? (
<ProfileCardContent event={event} />
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} />
</Suspense>
) : (
<TruncatedNoteContent
event={event}
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
/>
)}
</ContentWarningGuard>
</>
);
// 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 ? (
<div className="min-w-0 min-h-[42px] flex flex-col justify-center space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
) : (
<div className="min-w-0 flex-1 min-h-[42px] flex flex-col justify-center">
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-bold text-[15px] hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
{metadata?.bot && (
<span className="text-xs text-primary shrink-0" title="Bot account">
🤖
</span>
)}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground min-w-0 pr-2">
{nip05 && nip05Pending && <Skeleton className="h-3 w-24" />}
{nip05 && nip05Pending && <span className="shrink-0">·</span>}
{nip05 && nip05Verified && (
<Nip05Badge nip05={nip05} pubkey={event.pubkey} />
)}
{nip05 && nip05Verified && <span className="shrink-0">·</span>}
<span className="shrink-0 hover:underline whitespace-nowrap">
{timeAgo(event.created_at)}
</span>
</div>
</div>
);
// Shared avatar element
const avatarElement = author.isLoading ? (
<Skeleton
className={cn(
threaded || threadedLast ? "size-10" : "size-11",
"rounded-full shrink-0",
)}
/>
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className={threaded || threadedLast ? "size-10" : "size-11"}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
);
// ── Shared action buttons (used in all layouts) ──
const actionButtons = (
<div className="flex items-center gap-5 mt-3 -ml-2">
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={(e) => {
e.stopPropagation();
setReplyOpen(true);
}}
>
<MessageCircle className="size-5" />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
title={isReposted ? "Undo repost" : "Repost"}
>
<RepostIcon className="size-5" />
{stats?.reposts || stats?.quotes ? (
<span className="text-sm tabular-nums">
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
</span>
) : null}
</button>
)}
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">
{formatNumber(stats.zapAmount)}
</span>
) : null}
</button>
</ZapDialog>
)}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title="Share"
onClick={async (e) => {
e.stopPropagation();
const url = `${window.location.origin}/${encodedId}`;
const result = await shareOrCopy(url);
if (result === "copied") toast({ title: "Link copied to clipboard" });
}}
>
<Share2 className="size-5" />
</button>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => {
e.stopPropagation();
setMoreMenuOpen(true);
}}
>
<MoreHorizontal className="size-5" />
</button>
</div>
);
// ── 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 (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
<VanishCardCompact event={event} timestamp={timeAgo(event.created_at)} />
{!compact && (
<>
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</>
)}
</div>
</div>
</article>
);
}
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<VanishCardCompact event={event} />
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
</article>
);
}
// ── Reaction layout (kind 7) ──
if (isReaction) {
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
</div>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
}
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 (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
<RepostIcon className="size-5 text-accent" />
</div>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Zap receipt layout (kind 9735) ──
if (isZap) {
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
const zapMessage = extractZapMessage(event);
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
}
actorRow={
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
extra={zapAmountSats > 0 ? (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
</span>
) : undefined}
/>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</ActivityCard>
);
}
// ── Poll vote layout (kind 1018) ──
if (isPollVote) {
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className={iconSize}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</div>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
);
}
// ── 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 (
<EventActionHeader
pubkey={event.pubkey}
icon={cfg.icon}
iconClassName={
event.kind === 30311
? isLive ? "text-primary" : "text-muted-foreground"
: cfg.iconClassName
}
action={typeof cfg.action === "function" ? cfg.action(event) : cfg.action}
noun={cfg.noun}
nounRoute={cfg.nounRoute}
/>
);
})()
: null;
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
{threadedKindHeader}
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
{authorInfo}
{contentBlock}
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</div>
</div>
</article>
);
}
// ── Normal layout ──
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
highlight && "animate-highlight-fade",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
{/* Action header — repost takes priority, otherwise derived from event kind */}
{repostedBy ? (
<EventActionHeader
pubkey={repostedBy}
icon={RepostIcon}
iconClassName="text-accent"
action="reposted"
/>
) : (
!hideKindHeader && KIND_HEADER_MAP[event.kind] &&
(() => {
const cfg = KIND_HEADER_MAP[event.kind];
const isLive =
event.kind === 30311 && getEffectiveStreamStatus(event) === "live";
return (
<EventActionHeader
pubkey={event.pubkey}
icon={cfg.icon}
iconClassName={
event.kind === 30311
? isLive
? "text-primary"
: "text-muted-foreground"
: cfg.iconClassName
}
action={
typeof cfg.action === "function"
? cfg.action(event)
: cfg.action
}
noun={cfg.noun}
nounRoute={cfg.nounRoute}
/>
);
})()
)}
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
</div>
{contentBlock}
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
</article>
);
});
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<string, ImetaEntry>;
webxdcApps?: ImetaEntry[];
}) {
const contentRef = useRef<HTMLDivElement>(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 (
<div className="mt-2 break-words overflow-hidden">
<div
ref={contentRef}
style={
!expanded && overflows
? { maxHeight: MAX_HEIGHT, overflow: "hidden" }
: undefined
}
className="relative"
>
<NoteContent event={event} className="text-[15px] leading-relaxed" />
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="mt-1 text-sm text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
>
{expanded ? "Show less" : "Read more"}
</button>
)}
{showMedia && (
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={event}
/>
)}
</div>
);
}
// ── 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<string, string> = {};
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<string, { dim?: string; blurhash?: string }>();
for (const photo of photos) {
map.set(photo.url, { blurhash: photo.blurhash });
}
return map;
}, [photos]);
if (photos.length === 0) return null;
return (
<div className="mt-2 space-y-2">
{title && <p className="font-semibold text-[15px]">{title}</p>}
<ImageGallery
images={photos.map((p) => p.url)}
maxVisible={4}
maxGridHeight="480px"
imetaMap={imetaMap}
/>
{description && (
<p className="text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 5).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
</div>
);
}
// ── 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<string, string> = {};
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 (
<div className="mt-2 space-y-2">
{title && <p className="font-semibold text-[15px]">{title}</p>}
<div
className={cn(
"relative rounded-xl overflow-hidden bg-muted",
isShort ? "max-w-[280px]" : "",
)}
>
<VideoPlayer src={url} poster={thumbnail} title={title ?? undefined} />
{formattedDuration && (
<div className="absolute bottom-2 right-2 bg-black/80 text-white text-[10px] px-1.5 py-0.5 rounded font-medium pointer-events-none">
{formattedDuration}
</div>
)}
{isShort && (
<div className="absolute top-2 left-2 pointer-events-none">
<span className="text-[10px] bg-black/60 text-white px-1.5 py-0.5 rounded">
Short
</span>
</div>
)}
</div>
{description && (
<p className="text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 5).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
</div>
);
}
/** 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<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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 && (
<div
ref={containerRef}
className="relative mt-3 rounded-2xl overflow-hidden cursor-pointer"
onClick={handlePlayToggle}
>
<video
ref={videoRef}
src={imeta.url}
poster={imeta.thumbnail}
className="w-full max-h-[70vh] object-cover"
loop
playsInline
preload="none"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="size-14 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
<Play className="size-7 text-white ml-1" fill="white" />
</div>
</div>
)}
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{hashtags.slice(0, 5).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
</>
);
}
/** 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 (
<div className="mt-2 space-y-2">
{/* Stream player / thumbnail */}
<div className="rounded-xl overflow-hidden border border-border">
{isLive ? (
// Inline live player — clicks on the player are intercepted so they don't navigate away
<div className="relative" onClick={(e) => e.stopPropagation()}>
<LiveStreamPlayer
src={streamingUrl}
poster={imageUrl}
title={title}
/>
{/* Status + viewer overlay on top of the player */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 pointer-events-none">
<Badge
variant="outline"
className={cn("text-[10px]", statusConfig.className)}
>
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
</div>
) : imageUrl ? (
<div className="relative w-full aspect-video overflow-hidden bg-muted">
<img
src={imageUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display =
"none";
}}
/>
<div className="absolute top-2 left-2">
<Badge
variant="outline"
className={cn("text-[10px]", statusConfig.className)}
>
{statusConfig.label}
</Badge>
</div>
{currentParticipants && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</div>
)}
</div>
) : (
// No image, no live stream — show a minimal placeholder with status
<div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40">
<Radio className="size-4 text-primary shrink-0" />
<Badge
variant="outline"
className={cn("text-[10px]", statusConfig.className)}
>
{status === "live" && (
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
)}
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
)}
</div>
{/* Title + summary — clickable to open stream details */}
<button
type="button"
className="flex items-start gap-2 text-left w-full group"
onClick={(e) => {
e.stopPropagation();
openPost();
}}
>
<Radio className="size-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm leading-snug line-clamp-2 group-hover:underline">
{title}
</h3>
{summary && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{summary}
</p>
)}
</div>
</button>
</div>
);
}
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<number, KindHeaderConfig> = {
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",
},
36767: {
icon: Sparkles,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "theme",
nounRoute: "/themes",
},
16767: {
icon: Sparkles,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated their", fallback: "updated their" }),
noun: "theme",
nounRoute: "/themes",
},
30030: {
icon: SmilePlus,
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
noun: "emoji pack",
nounRoute: "/emojis",
},
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",
},
31124: {
icon: Egg,
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "Blobbi",
nounRoute: "/blobbi",
},
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 (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
<div className="w-11 shrink-0 flex justify-end">
<Icon
className={cn(
"size-4 translate-y-px",
iconClassName ?? "text-primary",
)}
/>
</div>
<div className="flex items-center min-w-0">
{author.isLoading ? (
<Skeleton className="h-3 w-20 inline-block" />
) : (
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{name}
</EmojifiedText>
) : (
name
)}
</Link>
</ProfileHoverCard>
)}
<span className={cn("shrink-0", author.isLoading && "ml-1")}>
{action}
{noun && nounRoute && (
<>
{" "}
<Link
to={nounRoute}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{noun}
</Link>
</>
)}
{noun && !nounRoute && <> {noun}</>}
</span>
</div>
</div>
);
}