1187 lines
44 KiB
TypeScript
1187 lines
44 KiB
TypeScript
import type React from 'react';
|
|
import { type ReactNode, useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import {
|
|
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
|
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
|
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
|
Stars, Target, Users, UserCheck, Vote, Zap,
|
|
} from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
import { CardsIcon } from '@/components/icons/CardsIcon';
|
|
import { ChestIcon } from '@/components/icons/ChestIcon';
|
|
import { RepostIcon } from '@/components/icons/RepostIcon';
|
|
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
|
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
|
import { LinkPreview } from '@/components/LinkPreview';
|
|
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
|
import { ReactionEmoji } from '@/components/CustomEmoji';
|
|
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
|
|
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useBookInfo } from '@/hooks/useBookInfo';
|
|
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
|
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
|
import { useCountryFeed } from '@/contexts/CountryFeedContext';
|
|
import { cn } from '@/lib/utils';
|
|
import { useFlagPalette } from '@/lib/flagPalette';
|
|
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
|
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
|
|
import { cardPrimaryImage } from '@/lib/scryfall';
|
|
|
|
|
|
/** Default classes shared by all comment context rows. */
|
|
const ROW_CLASS = 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden';
|
|
|
|
/** Parsed root reference from a kind 1111 comment's uppercase tags. */
|
|
interface CommentRoot {
|
|
type: 'event' | 'addr' | 'external';
|
|
/** For type 'event': the root event ID. */
|
|
eventId?: string;
|
|
/** For type 'addr': the addressable event coordinates. */
|
|
addr?: { kind: number; pubkey: string; identifier: string };
|
|
/** For type 'external': the raw identifier (URL, ISBN, etc.). */
|
|
identifier?: string;
|
|
/** Root kind number (from K tag). */
|
|
rootKind?: string;
|
|
/** Relay URL hint from the E or A tag (position [2]). */
|
|
relayHint?: string;
|
|
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
|
|
authorHint?: string;
|
|
}
|
|
|
|
/** Parse the root reference from a kind 1111 comment's tags. */
|
|
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
|
|
const aTagFull = event.tags.find(([name]) => name === 'A');
|
|
// Use find (not findLast) to get the root E tag, not a parent e tag
|
|
const eTagFull = event.tags.find(([name]) => name === 'E');
|
|
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
|
|
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
|
|
// P tag holds the root event author's pubkey — used as author hint fallback
|
|
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
|
|
|
|
if (aTagFull) {
|
|
const aTag = aTagFull[1];
|
|
const relayHint = aTagFull[2] || undefined;
|
|
const parts = aTag.split(':');
|
|
const kind = parseInt(parts[0], 10);
|
|
const pubkey = parts[1] ?? '';
|
|
const identifier = parts.slice(2).join(':');
|
|
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
|
|
}
|
|
|
|
if (eTagFull) {
|
|
const eTag = eTagFull[1];
|
|
const relayHint = eTagFull[2] || undefined;
|
|
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
|
|
const authorHint = eTagFull[3] || pTag || undefined;
|
|
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
|
|
}
|
|
|
|
if (iTag) {
|
|
return { type: 'external', identifier: iTag, rootKind: kTag };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Singular comment-context labels for every supported kind.
|
|
* Must use singular form with article ("a post", "an article") since these
|
|
* appear as "Commenting on {label}". EXTRA_KINDS labels are plural/categorical
|
|
* ("Requests to Vanish", "User Statuses") and must NOT be used directly.
|
|
*/
|
|
const KIND_LABELS: Record<number, string> = {
|
|
0: 'a profile',
|
|
1: 'a post',
|
|
3: 'a follow list',
|
|
4: 'an encrypted message',
|
|
6: 'a repost',
|
|
7: 'a reaction',
|
|
8: 'a badge award',
|
|
16: 'a repost',
|
|
20: 'a photo',
|
|
21: 'a video',
|
|
22: 'a short video',
|
|
62: 'a request to vanish',
|
|
1063: 'a file',
|
|
1018: 'a vote',
|
|
1068: 'a poll',
|
|
1111: 'a comment',
|
|
1222: 'a voice message',
|
|
8211: 'a letter',
|
|
1617: 'a patch',
|
|
1618: 'a pull request',
|
|
2473: 'a bird detection',
|
|
12473: 'a Birdex',
|
|
3367: 'a color moment',
|
|
7516: 'a found log',
|
|
15128: 'an nsite',
|
|
10008: 'profile badges',
|
|
30008: 'profile badges',
|
|
30009: 'a badge',
|
|
30023: 'an article',
|
|
30030: 'an emoji pack',
|
|
30054: 'a podcast episode',
|
|
30055: 'a podcast trailer',
|
|
3063: 'a Zapstore asset',
|
|
30063: 'a Zapstore release',
|
|
30311: 'a stream',
|
|
30315: 'a status',
|
|
30617: 'a repository',
|
|
30817: 'a custom NIP',
|
|
31922: 'a calendar event',
|
|
31923: 'a calendar event',
|
|
31990: 'an app',
|
|
32267: 'a Zapstore app',
|
|
34139: 'a playlist',
|
|
34236: 'a divine',
|
|
34550: 'a community',
|
|
9041: 'a goal',
|
|
35128: 'an nsite',
|
|
36639: 'an action',
|
|
36787: 'a track',
|
|
37381: 'a Magic deck',
|
|
37516: 'a treasure',
|
|
30000: 'a follow set',
|
|
30621: 'a constellation',
|
|
39089: 'a follow pack',
|
|
9735: 'a zap',
|
|
9802: 'a highlight',
|
|
};
|
|
|
|
/** Kind-specific icons — matches sidebar and NoteCard icons. */
|
|
const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: string }>>> = {
|
|
0: Users,
|
|
1: MessageSquare,
|
|
4: Mail,
|
|
6: RepostIcon,
|
|
8: Award,
|
|
16: RepostIcon,
|
|
20: Camera,
|
|
21: Film,
|
|
22: Film,
|
|
1063: FileText,
|
|
1018: Vote,
|
|
1068: BarChart3,
|
|
1222: Mic,
|
|
1617: FileText,
|
|
8211: Mail,
|
|
1618: GitPullRequest,
|
|
15128: Rocket,
|
|
35128: Rocket,
|
|
36639: Megaphone,
|
|
10008: Award,
|
|
30008: Award,
|
|
30009: Award,
|
|
30023: BookOpen,
|
|
30030: SmilePlus,
|
|
30054: Podcast,
|
|
30055: Podcast,
|
|
3063: Package,
|
|
30063: Package,
|
|
30311: Radio,
|
|
30617: GitBranch,
|
|
31990: Package,
|
|
32267: Package,
|
|
34236: Clapperboard,
|
|
36787: Music,
|
|
34139: Music,
|
|
37381: CardsIcon,
|
|
37516: ChestIcon,
|
|
7516: ChestIcon,
|
|
3: UserCheck,
|
|
30000: Users,
|
|
39089: PartyPopper,
|
|
3367: Palette,
|
|
9041: Target,
|
|
9735: Zap,
|
|
9802: Highlighter,
|
|
2473: Bird,
|
|
12473: Bird,
|
|
30621: Stars,
|
|
};
|
|
|
|
/**
|
|
* Get a singular comment-context label for a kind number.
|
|
* Only uses KIND_LABELS (which has proper singular forms with articles).
|
|
* Never falls through to EXTRA_KINDS labels since those are plural/categorical.
|
|
* Unknown kinds render as "an unsupported event" — never as "a post", which
|
|
* would misrepresent arbitrary event kinds as text notes.
|
|
*/
|
|
function getKindLabel(kind: number): string {
|
|
return KIND_LABELS[kind] ?? 'an unsupported event';
|
|
}
|
|
|
|
/** Parse a rootKind string into a label, handling both numeric and external content kinds. */
|
|
function getRootKindLabel(rootKind: string | undefined): string {
|
|
if (!rootKind) return 'a post';
|
|
|
|
const kindNum = parseInt(rootKind, 10);
|
|
if (isNaN(kindNum)) {
|
|
if (rootKind === 'web' || rootKind === 'https' || rootKind === 'http') return 'a link';
|
|
if (rootKind === '#') return 'a hashtag';
|
|
return rootKind;
|
|
}
|
|
|
|
return getKindLabel(kindNum);
|
|
}
|
|
|
|
/** Suffix that describes the kind, appended after a title (e.g. "Wet Dry World theme"). */
|
|
const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
|
30009: 'badge',
|
|
30030: 'emoji pack',
|
|
30000: 'follow set',
|
|
39089: 'follow pack',
|
|
37381: 'deck',
|
|
37516: 'treasure',
|
|
30621: 'constellation',
|
|
34550: 'community',
|
|
30054: 'episode',
|
|
30055: 'trailer',
|
|
34139: 'playlist',
|
|
};
|
|
|
|
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
|
|
const KIND_POSTFIXES: Partial<Record<number, string>> = {
|
|
32267: 'on Zapstore',
|
|
30063: 'Zapstore release',
|
|
3063: 'Zapstore asset',
|
|
};
|
|
|
|
/** Get a display name for an event based on its kind and tags. */
|
|
function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } {
|
|
const icon = KIND_ICONS[event.kind];
|
|
|
|
// Nsite deployments: "{siteName} nsite" with rocket icon
|
|
if (event.kind === 15128 || event.kind === 35128) {
|
|
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
const siteName = title || dTag;
|
|
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
|
|
}
|
|
|
|
// NIP-89 apps: name lives in JSON content, not in tags
|
|
if (event.kind === 31990) {
|
|
try {
|
|
const meta = JSON.parse(event.content);
|
|
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
|
|
if (appName) return { text: `${appName} app`, icon };
|
|
} catch { /* fall through */ }
|
|
return { text: 'an app', icon };
|
|
}
|
|
|
|
// Extract a title-like string from tags
|
|
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
|
const name = event.tags.find(([name]) => name === 'name')?.[1];
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
const alt = event.tags.find(([name]) => name === 'alt')?.[1]?.trim();
|
|
const displayTitle = title || name || dTag;
|
|
|
|
// Kinds with a custom postfix (e.g. "Ditto on Zapstore")
|
|
const postfix = KIND_POSTFIXES[event.kind];
|
|
if (postfix && displayTitle) {
|
|
return { text: `${displayTitle} ${postfix}`, icon };
|
|
}
|
|
|
|
// Kinds with a suffix (e.g. "Beagle Owner badge", "Wet Dry World theme")
|
|
const suffix = KIND_SUFFIXES[event.kind];
|
|
if (suffix && displayTitle) {
|
|
return { text: `${displayTitle} ${suffix}`, icon };
|
|
}
|
|
|
|
// Known kinds: use the conventional title/name/d tag if available.
|
|
if (KIND_LABELS[event.kind] && displayTitle) {
|
|
return { text: displayTitle, icon };
|
|
}
|
|
|
|
// Unknown kinds: only trust the NIP-31 `alt` tag. title/name/d have
|
|
// kind-specific semantics we can't interpret; `d` in particular is often
|
|
// an opaque compound identifier.
|
|
if (alt) return { text: alt, icon };
|
|
|
|
// Fall back to kind label ("an unsupported event" for unknown kinds).
|
|
return { text: getKindLabel(event.kind), icon };
|
|
}
|
|
|
|
/** Build a navigation link for the root event. */
|
|
function getRootLink(event: NostrEvent): string {
|
|
if (event.kind >= 30000 && event.kind < 40000) {
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
if (dTag) {
|
|
return `/${nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag })}`;
|
|
}
|
|
}
|
|
return `/${nip19.neventEncode({ id: event.id, author: event.pubkey })}`;
|
|
}
|
|
|
|
// ─── Shared wrapper ────────────────────────────────────────────
|
|
|
|
interface CommentContextRowProps {
|
|
prefix: string;
|
|
className?: string;
|
|
loading?: boolean;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
/** Shared row wrapper for all comment context variants. */
|
|
function CommentContextRow({ prefix, className, loading, children }: CommentContextRowProps) {
|
|
return (
|
|
<div className={className || ROW_CLASS}>
|
|
<span className="shrink-0">{prefix}</span>
|
|
{loading ? <Skeleton className="h-3.5 w-24 inline-block" /> : children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Shared hover card for Nostr events ────────────────────────
|
|
|
|
interface EventHoverLinkProps {
|
|
display: { text: string; icon?: React.ComponentType<{ className?: string }> };
|
|
link: string;
|
|
hoverContent: ReactNode;
|
|
}
|
|
|
|
/** Link with icon that shows a hover card preview. */
|
|
function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
|
|
const DisplayIcon = display.icon;
|
|
return (
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
to={link}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{DisplayIcon && <DisplayIcon className="size-3.5 shrink-0" />}
|
|
{display.text}
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={4}
|
|
className="w-80 p-0 rounded-2xl shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{hoverContent}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
// ─── Main export ───────────────────────────────────────────────
|
|
|
|
interface CommentContextProps {
|
|
event: NostrEvent;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Displays "Replying to @user" or "Commenting on [name]" context for kind 1111 comments.
|
|
* When the parent item (lowercase k tag) is another kind 1111 comment, shows "Replying to @user"
|
|
* using the lowercase p tag (parent author). Otherwise shows "Commenting on [root]".
|
|
*/
|
|
export function CommentContext({ event, className }: CommentContextProps) {
|
|
// If the direct parent is another comment (k="1111"), show "Replying to @user"
|
|
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
|
|
const parentAuthorPubkey = event.tags.findLast(([name]) => name === 'p')?.[1];
|
|
|
|
if (parentKind === '1111' && parentAuthorPubkey) {
|
|
return <ReplyToCommentContext pubkey={parentAuthorPubkey} eventId={event.tags.findLast(([name]) => name === 'e')?.[1]} className={className} />;
|
|
}
|
|
|
|
const root = parseCommentRoot(event);
|
|
|
|
if (!root) return null;
|
|
|
|
switch (root.type) {
|
|
case 'addr':
|
|
return <AddrCommentContext root={root} className={className} />;
|
|
case 'event':
|
|
return <EventCommentContext root={root} className={className} />;
|
|
case 'external':
|
|
return <ExternalCommentContext root={root} className={className} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Sub-components ────────────────────────────────────────────
|
|
|
|
/** Comment context when replying directly to another kind 1111 comment — shows "Replying to @user". */
|
|
function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; eventId?: string; className?: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
|
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
|
const parentLink = useMemo(() => {
|
|
if (!eventId) return undefined;
|
|
try { return `/${nip19.neventEncode({ id: eventId, author: pubkey })}`; } catch { return undefined; }
|
|
}, [eventId, pubkey]);
|
|
|
|
return (
|
|
<CommentContextRow prefix="Replying to" className={className} loading={author.isLoading}>
|
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
|
<Link
|
|
to={parentLink ?? `/${npubEncoded}`}
|
|
className="text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for addressable event roots (A tag). */
|
|
function AddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
|
// Kind 0 (profile) roots get special treatment — show "@DisplayName" with a profile link
|
|
if (root.addr?.kind === 0) {
|
|
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
|
|
}
|
|
|
|
// Kind 10008 or 30008 (profile badges) roots — show "@User's profile badges"
|
|
if (root.addr?.kind === 10008 || root.addr?.kind === 30008) {
|
|
return <ProfileBadgesCommentContext root={root} className={className} />;
|
|
}
|
|
|
|
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
|
|
if (root.addr?.kind === 3) {
|
|
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
|
|
}
|
|
|
|
return <GenericAddrCommentContext root={root} className={className} />;
|
|
}
|
|
|
|
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
|
|
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
|
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
|
const listLink = useMemo(
|
|
() => `/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`,
|
|
[pubkey],
|
|
);
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
|
<Link
|
|
to={`/${npubEncoded}`}
|
|
className="text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}'s
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
<Link
|
|
to={listLink}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<UserCheck className="size-3.5 shrink-0" />
|
|
follow list
|
|
</Link>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
|
|
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
|
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
|
<Link
|
|
to={`/${npubEncoded}`}
|
|
className="text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for kind 10008/30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
|
|
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
|
const pubkey = root.addr?.pubkey ?? '';
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
|
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
|
|
|
// Build naddr link for the profile badges event
|
|
const link = useMemo(() => {
|
|
if (!root.addr) return undefined;
|
|
try { return `/${nip19.naddrEncode({ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier })}`; } catch { return undefined; }
|
|
}, [root.addr]);
|
|
|
|
// Hover content for the addressable event
|
|
const hoverContent = root.addr ? (
|
|
<EmbeddedNaddr
|
|
addr={{ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier }}
|
|
className="border-0 rounded-none"
|
|
/>
|
|
) : undefined;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
|
{link && hoverContent ? (
|
|
<EventHoverLink
|
|
display={{ text: 'profile badges', icon: Award }}
|
|
link={link}
|
|
hoverContent={hoverContent}
|
|
/>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 truncate">
|
|
<Award className="size-3.5 shrink-0" />
|
|
profile badges
|
|
</span>
|
|
)}
|
|
<span className="shrink-0">by</span>
|
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
|
<Link
|
|
to={`/${npubEncoded}`}
|
|
className="text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for non-profile addressable event roots (A tag). */
|
|
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
|
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
|
|
|
|
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
|
|
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
|
|
|
|
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
|
const link = event ? getRootLink(event) : undefined;
|
|
|
|
// Build hover card content for addressable events
|
|
const hoverContent = root.addr ? (
|
|
<EmbeddedNaddr
|
|
addr={{ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier }}
|
|
className="border-0 rounded-none"
|
|
/>
|
|
) : undefined;
|
|
|
|
return (
|
|
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
|
{link && hoverContent ? (
|
|
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
|
|
) : link ? (
|
|
<Link
|
|
to={link}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{display.icon && <display.icon className="size-3.5 shrink-0" />}
|
|
{display.text}
|
|
</Link>
|
|
) : (
|
|
<span className="truncate">{display.text}</span>
|
|
)}
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for regular event roots (E tag). */
|
|
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
|
const { data: event, isLoading } = useEvent(
|
|
root.eventId,
|
|
root.relayHint ? [root.relayHint] : undefined,
|
|
root.authorHint,
|
|
);
|
|
|
|
// Kind 7 reactions get special treatment
|
|
if (event?.kind === 7) {
|
|
return <ReactionCommentContext event={event} className={className} />;
|
|
}
|
|
|
|
// Kind 1018 poll votes get special treatment
|
|
if (event?.kind === 1018) {
|
|
return <PollVoteCommentContext event={event} className={className} />;
|
|
}
|
|
|
|
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
|
const link = event ? getRootLink(event) : undefined;
|
|
|
|
const hoverContent = root.eventId ? (
|
|
<EmbeddedNote
|
|
eventId={root.eventId}
|
|
relays={root.relayHint ? [root.relayHint] : undefined}
|
|
authorHint={root.authorHint}
|
|
className="border-0 rounded-none"
|
|
disableHoverCards
|
|
/>
|
|
) : undefined;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
|
{link && hoverContent ? (
|
|
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
|
|
) : (
|
|
<span className="truncate">{display.text}</span>
|
|
)}
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */
|
|
function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey);
|
|
const reactionLink = getRootLink(event);
|
|
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className}>
|
|
<Link
|
|
to={reactionLink}
|
|
className="text-primary hover:underline shrink-0 cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ReactionEmoji content={event.content} tags={event.tags} className="inline-block h-[1.2em] w-[1.2em] align-text-bottom object-contain" />
|
|
</Link>
|
|
<span className="shrink-0">by</span>
|
|
{author.isLoading ? (
|
|
<Skeleton className="h-3.5 w-16 inline-block" />
|
|
) : (
|
|
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
|
<Link
|
|
to={profileLink}
|
|
className="text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
)}
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
|
|
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey);
|
|
const voteLink = getRootLink(event);
|
|
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
|
|
|
|
const voteLabel = usePollVoteLabel(event);
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className}>
|
|
{author.isLoading ? (
|
|
<Skeleton className="h-3.5 w-16 inline-block" />
|
|
) : (
|
|
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
|
<Link
|
|
to={profileLink}
|
|
className="text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
)}
|
|
<Link
|
|
to={voteLink}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Vote className="size-3.5 shrink-0" />
|
|
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
|
|
</Link>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for external content roots (I tag). */
|
|
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
|
const identifier = root.identifier ?? '';
|
|
|
|
// ISBN identifiers get special treatment — show book title instead of raw ISBN
|
|
if (identifier.startsWith('isbn:')) {
|
|
return <IsbnCommentContext identifier={identifier} className={className} />;
|
|
}
|
|
|
|
// URL identifiers get special treatment — show page title with favicon.
|
|
// Gatherer URLs are routed to a Scryfall-backed renderer that shows the
|
|
// actual card name instead of the raw URL.
|
|
if (identifier.startsWith('http://') || identifier.startsWith('https://')) {
|
|
const gathererCard = extractGathererCard(identifier);
|
|
if (gathererCard) {
|
|
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} />;
|
|
}
|
|
return <UrlCommentContext url={identifier} className={className} />;
|
|
}
|
|
|
|
// ISO 3166 country/subdivision identifiers get special treatment
|
|
if (identifier.startsWith('iso3166:')) {
|
|
return <CountryCommentContext identifier={identifier} className={className} />;
|
|
}
|
|
|
|
// Generic fallback for other external identifiers
|
|
const link = `/i/${encodeURIComponent(identifier)}`;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className}>
|
|
<Link
|
|
to={link}
|
|
className="text-primary hover:underline truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{identifier}
|
|
</Link>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Comment context for URL identifiers — fetches and displays the page title with favicon. */
|
|
function UrlCommentContext({ url, className }: { url: string; className?: string }) {
|
|
const { data: preview, isLoading } = useLinkPreview(url);
|
|
const link = `/i/${encodeURIComponent(url)}`;
|
|
|
|
let fallbackHost: string;
|
|
try {
|
|
fallbackHost = new URL(url).hostname.replace(/^www\./, '');
|
|
} catch {
|
|
fallbackHost = url;
|
|
}
|
|
|
|
const title = preview?.title;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
|
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
to={link}
|
|
className="text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{title || fallbackHost}
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={4}
|
|
className="w-80 p-0 rounded-2xl shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<LinkPreview url={url} className="border-0 rounded-none" navigateToComments />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/** Internal: clickable country flag emoji that opens the country feed. */
|
|
function CountryPillBadge({ identifier, className }: { identifier: string; className?: string }) {
|
|
const code = identifier.slice('iso3166:'.length);
|
|
const info = getCountryInfo(code);
|
|
const link = `/i/${encodeURIComponent(identifier)}`;
|
|
|
|
const flag = info?.flag ?? '🌍';
|
|
|
|
return (
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
to={link}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={`Posted from ${info?.name ?? code}`}
|
|
className={cn(
|
|
'group/flag inline-flex items-center justify-center shrink-0',
|
|
'text-xl leading-none',
|
|
'transition-transform duration-200',
|
|
'motion-safe:hover:scale-110 motion-safe:active:scale-95',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full',
|
|
className,
|
|
)}
|
|
>
|
|
<span role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
|
{flag}
|
|
</span>
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="end"
|
|
sideOffset={6}
|
|
className="w-64 p-0 rounded-2xl shadow-lg overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
|
{flag}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<MapPin className="size-3 shrink-0" />
|
|
<span>{info?.subdivisionName ? 'Region' : 'Country'}</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate mt-0.5">
|
|
{info?.subdivisionName ?? info?.name ?? code}
|
|
</p>
|
|
{info?.subdivisionName && info.name && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{info.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve whether a kind-1111 event should display country-rooted UI in the
|
|
* current context. Shared by `CountryCommentPill` (renders a pill in the
|
|
* card header) and `CountryFlagBackdrop` (renders a faded flag behind the
|
|
* card). Returns `null` to suppress both; otherwise returns the resolved
|
|
* country identifier so callers don't re-parse tags.
|
|
*/
|
|
function useCountryRootContext(event: NostrEvent): { iTag: string; code: string } | null {
|
|
const countryFeed = useCountryFeed();
|
|
|
|
if (event.kind !== 1111) return null;
|
|
|
|
// If the direct parent is another kind-1111 comment, the body shows
|
|
// "Replying to @user" — country chrome would be visual noise.
|
|
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
|
|
if (parentKind === '1111') return null;
|
|
|
|
// Root I tag (uppercase = root identifier per NIP-22)
|
|
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
|
|
if (!iTag || !iTag.startsWith('iso3166:')) return null;
|
|
|
|
const code = iTag.slice('iso3166:'.length);
|
|
|
|
// Suppress when already inside the matching country feed page — there the
|
|
// chrome would just point back to the page the user is already on.
|
|
if (countryFeed && countryFeed.countryCode === code.toUpperCase()) {
|
|
return null;
|
|
}
|
|
|
|
return { iTag, code };
|
|
}
|
|
|
|
/**
|
|
* Whether the given event is rendering with country chrome (pill + flag
|
|
* backdrop) in the current context. Useful for sibling components that want
|
|
* to coordinate styling — e.g. NoteCard switching its text to white when a
|
|
* flag is showing through behind the author row.
|
|
*/
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function useIsCountryRooted(event: NostrEvent): boolean {
|
|
return useCountryRootContext(event) !== null;
|
|
}
|
|
|
|
/**
|
|
* Standalone country flag pill for kind-1111 events whose root is an ISO 3166
|
|
* identifier. Intended to be rendered in the upper-right of `NoteCard`'s
|
|
* header row so the post reads as a neighborhood entry instead of a comment.
|
|
*
|
|
* Visibility rules: see `useCountryRootContext`.
|
|
*
|
|
* Because the pill takes over the country-root presentation, the body-level
|
|
* `CommentContext` is silent for country roots — see `CountryCommentContext`.
|
|
*/
|
|
export function CountryCommentPill({ event, className }: { event: NostrEvent; className?: string }) {
|
|
const ctx = useCountryRootContext(event);
|
|
if (!ctx) return null;
|
|
return <CountryPillBadge identifier={ctx.iTag} className={className} />;
|
|
}
|
|
|
|
/**
|
|
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
|
|
* country's Wikipedia lead image (the flag, for country articles) faded
|
|
* behind the post, echoing the country detail page's hero
|
|
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
|
|
* to a card. Pairs with `CountryCommentPill`.
|
|
*
|
|
* Designed to be rendered as the first child of a `relative overflow-hidden`
|
|
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
|
|
* siblings must declare `relative` (any positioned value works) so they
|
|
* paint above the backdrop. Pointer events are disabled so the post body
|
|
* stays fully interactive.
|
|
*
|
|
* The Wikipedia summary fetch is cached for 24 h across all cards
|
|
* referencing the same country code, so a feed of N Venezuelan posts only
|
|
* pays the network cost once.
|
|
*
|
|
* Visibility rules: see `useCountryRootContext` (identical to the pill).
|
|
*/
|
|
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
|
const ctx = useCountryRootContext(event);
|
|
const info = ctx ? getCountryInfo(ctx.code) : null;
|
|
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
|
|
const { data: wiki } = useWikipediaSummary(wikiTitle);
|
|
// Sample dominant colors from the flag emoji at render time. Used as the
|
|
// fallback gradient while Wikipedia is still resolving and after image
|
|
// load failures, so the backdrop never reverts to a giant blurred emoji.
|
|
const palette = useFlagPalette(info?.flag);
|
|
// Track image load failures so we cleanly fall back to the flag-color
|
|
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
|
|
// generally CORS-friendly, but hotlink-protection or transient 4xx
|
|
// responses can still happen.
|
|
const [imageFailed, setImageFailed] = useState(false);
|
|
|
|
if (!ctx) return null;
|
|
|
|
// For country articles Wikipedia returns the flag as the page's lead image
|
|
// — the same source used by `CountryContentHeader`. Prefer the original
|
|
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
|
|
// and looks fuzzy when stretched across a full-width feed card.
|
|
const flagImage = !imageFailed
|
|
? (wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
|
|
: null;
|
|
|
|
// Pre-built gradient using the palette (sampled from the flag emoji at
|
|
// mount). Used as the fallback when Wikipedia hasn't returned an image or
|
|
// its image failed to load. Single-color palettes get duplicated so
|
|
// linear-gradient still has two stops.
|
|
const paletteGradient =
|
|
palette && palette.length > 0
|
|
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
|
|
: null;
|
|
|
|
return (
|
|
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
|
|
{flagImage ? (
|
|
// Full-width flag banner across the top of the card. A mask-image
|
|
// gradient fades the image to nothing at its bottom edge, so the
|
|
// flag dissolves into the card with no hard seam.
|
|
<img
|
|
src={flagImage}
|
|
alt=""
|
|
decoding="async"
|
|
onError={() => setImageFailed(true)}
|
|
className="w-full h-full object-cover opacity-30 saturate-75 brightness-125 dark:opacity-60 dark:saturate-100 dark:brightness-100 select-none"
|
|
style={{
|
|
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
}}
|
|
/>
|
|
) : paletteGradient ? (
|
|
// Wikipedia not yet resolved (or its image failed) — paint the
|
|
// flag-color gradient as a placeholder/fallback. Same theme-aware opacity and
|
|
// mask shape as the image so the visual swap is seamless when the
|
|
// image arrives.
|
|
<div
|
|
className="absolute inset-0 opacity-30 saturate-75 brightness-125 dark:opacity-60 dark:saturate-100 dark:brightness-100"
|
|
style={{
|
|
backgroundImage: paletteGradient,
|
|
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
{/* Light mode washes/brightens the flag for dark text; dark mode keeps
|
|
the original dimmed treatment for white text. Both washes mirror
|
|
the mask shape so they fade with no hard edge. */}
|
|
<div
|
|
className="absolute inset-0 dark:hidden"
|
|
style={{
|
|
backgroundImage:
|
|
'linear-gradient(to bottom, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.84) 50%, rgba(255,255,255,0) 100%)',
|
|
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-0 hidden dark:block"
|
|
style={{
|
|
backgroundImage:
|
|
'linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.75) 50%, rgba(0,0,0,0) 100%)',
|
|
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Body-level comment context for ISO 3166 roots — intentionally renders
|
|
* nothing. The country pill is hoisted into the card header via
|
|
* `CountryCommentPill`, so we suppress the in-body version to avoid
|
|
* duplication.
|
|
*/
|
|
function CountryCommentContext(_props: { identifier: string; className?: string }) {
|
|
return null;
|
|
}
|
|
|
|
/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */
|
|
function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) {
|
|
const isbn = identifier.slice('isbn:'.length);
|
|
const { data: bookInfo, isLoading } = useBookInfo(isbn);
|
|
const link = `/i/${encodeURIComponent(identifier)}`;
|
|
const displayText = bookInfo?.title ?? identifier;
|
|
const coverUrl = bookInfo?.cover?.medium || bookInfo?.cover?.large;
|
|
const authors = bookInfo?.authors?.map((a) => a.name).join(', ');
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
to={link}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<BookOpen className="size-3.5 shrink-0" />
|
|
{displayText}
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={4}
|
|
className="w-72 p-0 rounded-2xl shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
{coverUrl ? (
|
|
<img
|
|
src={coverUrl}
|
|
alt={bookInfo?.title || 'Book cover'}
|
|
className="w-9 h-12 rounded object-cover shrink-0"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
|
<BookOpen className="size-4 text-muted-foreground/40" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<BookOpen className="size-3 shrink-0" />
|
|
<span>Book</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate mt-0.5">
|
|
{bookInfo?.title || `ISBN ${isbn}`}
|
|
</p>
|
|
{authors && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
by {authors}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Comment context for gatherer.wizards.com URLs — resolves the URL to a
|
|
* Magic: The Gathering card via Scryfall and shows the card's real name
|
|
* (e.g. "Xenagos, God of Revels") instead of the raw URL.
|
|
*/
|
|
function GathererCardCommentContext({
|
|
card,
|
|
url,
|
|
className,
|
|
}: {
|
|
card: GathererCard;
|
|
url: string;
|
|
className?: string;
|
|
}) {
|
|
const lookup = useMemo(() => (
|
|
card.kind === 'multiverse'
|
|
? { kind: 'multiverse' as const, multiverseId: card.multiverseId }
|
|
: { kind: 'set' as const, set: card.set, number: card.number, lang: card.lang }
|
|
), [card]);
|
|
const { data: scryCard, isLoading } = useScryfallCard(lookup);
|
|
const link = `/i/${encodeURIComponent(url)}`;
|
|
|
|
const displayText = scryCard?.name ?? 'Magic card';
|
|
const coverUrl = scryCard ? cardPrimaryImage(scryCard, 'small') : undefined;
|
|
|
|
return (
|
|
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
to={link}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<CardsIcon className="size-3.5 shrink-0" />
|
|
{displayText}
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={4}
|
|
className="w-72 p-0 rounded-2xl shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
{coverUrl ? (
|
|
<img
|
|
src={coverUrl}
|
|
alt={scryCard?.name ?? 'Magic card'}
|
|
className="w-9 h-12 rounded object-cover shrink-0"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
|
<CardsIcon className="size-4 text-muted-foreground/40" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<CardsIcon className="size-3 shrink-0" />
|
|
<span>Magic Card</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate mt-0.5">
|
|
{scryCard?.name ?? 'Unknown card'}
|
|
</p>
|
|
{scryCard?.set_name && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{scryCard.set_name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</CommentContextRow>
|
|
);
|
|
}
|