fed1bb9ce0
Campaign-launch events (kind 33863) previously fell through to the
generic text-note path in NoteCard, rendering as just the author row
plus the campaign's raw markdown story. No banner, no title, no
progress, no donor count, no goal, no deadline, no link to the
campaign page.
Wire campaigns through the same polished CampaignCard component the
campaign directory already uses:
- New CampaignNoteCardContent component — thin wrapper that parses the
event and renders a CampaignCard. Malformed events silently drop.
- NoteCard: add isCampaign flag, exclude from isTextNote, add the
dispatch branch, and import HandHeart for the action header.
- KIND_HEADER_MAP: 33863 entry gives 'Alice launched a campaign'
header above the rich card (uses publishedAtAction so edits read
'updated a' instead of 'launched a'). nounRoute points at
/campaigns/all so the bold 'campaign' word is clickable.
- CommentContext: register 33863 in KIND_LABELS ('a campaign'),
KIND_SUFFIXES ('campaign'), and KIND_ICONS (HandHeart) so comments
on campaigns render with proper context labels instead of falling
back to 'an unsupported event'.
The whole feed card now links to the campaign's naddr-based detail
route via CampaignCard's existing <Link> wrapper.
Embedded quote-preview rendering for kind 33863 and notification-stack
integration are deferred — out of scope for the activity-feed card.
1972 lines
68 KiB
TypeScript
1972 lines
68 KiB
TypeScript
import type { NostrEvent } from "@nostrify/nostrify";
|
|
import {
|
|
Award,
|
|
Camera,
|
|
FileCode,
|
|
FileText,
|
|
GitBranch,
|
|
GitPullRequest,
|
|
HandHeart,
|
|
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 { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
|
|
import { CommunityContent } from "@/components/CommunityContent";
|
|
import { CalendarEventContent } from "@/components/CalendarEventContent";
|
|
import {
|
|
ColorMomentContent,
|
|
ColorMomentEyeButton,
|
|
} from "@/components/ColorMomentContent";
|
|
import { CommentContext, CountryCommentPill } 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 { 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 { 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<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;
|
|
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 (
|
|
<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 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;
|
|
/** 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<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;
|
|
}
|
|
|
|
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 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 isCampaign = event.kind === 33863;
|
|
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 &&
|
|
!isCampaign &&
|
|
!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 = (
|
|
<CommunityContentWarning event={event}>
|
|
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
|
|
{isComment && <CommentContext event={event} prefix={commentContextPrefix} />}
|
|
{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 ? (
|
|
<PeopleListContent 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} />
|
|
) : isCommunity ? (
|
|
<CommunityContent event={event} />
|
|
) : isZapGoal ? (
|
|
<GoalCard event={event} />
|
|
|
|
) : isAction ? (
|
|
<ActionContent event={event} />
|
|
|
|
) : isCampaign ? (
|
|
<CampaignNoteCardContent 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} />
|
|
) : (
|
|
<TruncatedNoteContent
|
|
event={event}
|
|
/>
|
|
)}
|
|
</ContentWarningGuard>
|
|
</CommunityContentWarning>
|
|
);
|
|
|
|
// 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">
|
|
<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 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 flex-wrap items-center gap-1 sm:gap-2 mt-3">
|
|
<button
|
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
|
title="Reply"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setReplyOpen(true);
|
|
}}
|
|
>
|
|
<MessageCircle className="size-[18px]" />
|
|
{stats?.replies ? (
|
|
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
|
|
) : null}
|
|
</button>
|
|
|
|
<RepostMenu event={actionTarget}>
|
|
{(isReposted: boolean) => (
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium 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-[18px]" />
|
|
{stats?.reposts || stats?.quotes ? (
|
|
<span className="tabular-nums">
|
|
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
)}
|
|
</RepostMenu>
|
|
|
|
<ReactionButton
|
|
eventId={actionTarget.id}
|
|
eventPubkey={actionTarget.pubkey}
|
|
eventKind={actionTarget.kind}
|
|
reactionCount={stats?.reactions}
|
|
variant="chip"
|
|
/>
|
|
|
|
{canZapAuthor && (
|
|
<ZapDialog target={actionTarget}>
|
|
<button
|
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
|
title="Zap"
|
|
>
|
|
<Zap className="size-[18px]" />
|
|
{stats?.zapAmount ? (
|
|
<span className="tabular-nums">
|
|
{formatNumber(stats.zapAmount)}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</ZapDialog>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<button
|
|
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
|
title="Share"
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
impactLight();
|
|
const url = `${window.location.origin}/${encodedId}`;
|
|
const result = await shareOrCopy(url);
|
|
if (result === "copied") toast({ title: "Link copied to clipboard" });
|
|
}}
|
|
>
|
|
<Share2 className="size-[18px]" />
|
|
</button>
|
|
|
|
<button
|
|
className="inline-flex items-center justify-center h-9 w-9 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-[18px]" />
|
|
</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={actionTarget} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
|
<ReplyComposeModal event={actionTarget} 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={actionTarget}
|
|
open={moreMenuOpen}
|
|
onOpenChange={setMoreMenuOpen}
|
|
/>
|
|
<ReplyComposeModal
|
|
event={actionTarget}
|
|
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} 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} 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 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 (
|
|
<NoteCard
|
|
event={zapCommentEvent}
|
|
actionEvent={event}
|
|
className={className}
|
|
compact={compact}
|
|
threaded={threaded}
|
|
threadedLineClassName={threadedLineClassName}
|
|
threadedLast={threadedLast}
|
|
highlight={highlight}
|
|
hideKindHeader
|
|
commentContextPrefix={donationPrefix}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── 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 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(
|
|
"relative 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="relative">
|
|
{threadedKindHeader && (
|
|
<div>
|
|
{threadedKindHeader}
|
|
</div>
|
|
)}
|
|
<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")}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
{authorInfo}
|
|
</div>
|
|
<CountryCommentPill event={event} className="shrink-0 [text-shadow:none]" />
|
|
</div>
|
|
{contentBlock}
|
|
{actionButtons}
|
|
<NoteMoreMenu
|
|
event={actionTarget}
|
|
open={moreMenuOpen}
|
|
onOpenChange={setMoreMenuOpen}
|
|
/>
|
|
<ReplyComposeModal
|
|
event={actionTarget}
|
|
open={replyOpen}
|
|
onOpenChange={setReplyOpen}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
// ── Normal layout ──
|
|
return (
|
|
<article
|
|
className={cn(
|
|
"relative 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}
|
|
>
|
|
<div className="relative">
|
|
<div>
|
|
{/* 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 with the country pill anchored
|
|
right. The pill is a flex sibling of the author row so it
|
|
keeps its own surface treatment regardless of context. */}
|
|
<div className="flex items-center gap-3">
|
|
{avatarElement}
|
|
{authorInfo}
|
|
{isColor && <ColorMomentEyeButton event={event} />}
|
|
<CountryCommentPill
|
|
event={event}
|
|
className="shrink-0 [text-shadow:none]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{contentBlock}
|
|
|
|
{/* Action buttons — hidden in compact/embed mode */}
|
|
{!compact && (
|
|
<>
|
|
{actionButtons}
|
|
<NoteMoreMenu
|
|
event={actionTarget}
|
|
open={moreMenuOpen}
|
|
onOpenChange={setMoreMenuOpen}
|
|
/>
|
|
<ReplyComposeModal
|
|
event={actionTarget}
|
|
open={replyOpen}
|
|
onOpenChange={setReplyOpen}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</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 render inline within NoteContent at their original content position. */
|
|
function TruncatedNoteContent({
|
|
event,
|
|
}: {
|
|
event: NostrEvent;
|
|
}) {
|
|
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]);
|
|
|
|
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>
|
|
)}
|
|
</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",
|
|
},
|
|
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 an", updated: "updated an", fallback: "shared an" }),
|
|
noun: "organization",
|
|
nounRoute: "/communities",
|
|
},
|
|
33863: {
|
|
icon: HandHeart,
|
|
action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
|
|
noun: "campaign",
|
|
nounRoute: "/campaigns/all",
|
|
},
|
|
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: "created a", updated: "updated a", fallback: "created a" }),
|
|
noun: "pledge",
|
|
nounRoute: "/pledges",
|
|
},
|
|
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>
|
|
);
|
|
}
|