Files
eranos/src/components/EmbeddedNaddr.tsx
T
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00

527 lines
18 KiB
TypeScript

import { type ReactNode, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { Award, Image, MessageSquareOff } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
import type { NostrEvent } from '@nostrify/nostrify';
interface EmbeddedNaddrProps {
/** The decoded naddr coordinates. */
addr: AddrCoords;
className?: string;
/** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */
disableHoverCards?: boolean;
}
/** Maximum characters of content to show in the embedded preview. */
const MAX_CONTENT_LENGTH = 280;
/** Extract metadata from an addressable event's tags and content. */
function extractMetadata(event: NostrEvent): {
title?: string;
description?: string;
image?: string;
} {
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
let title = getTag('title') || getTag('name');
let description = getTag('summary') || getTag('description');
let image = getTag('image') || getTag('thumb') || getTag('banner');
// Try parsing JSON content for additional metadata
if (event.content) {
try {
const parsed = JSON.parse(event.content);
if (typeof parsed === 'object' && parsed !== null) {
if (!title && parsed.title) title = parsed.title;
if (!description && parsed.description) description = parsed.description;
if (!image && parsed.images?.[0]) image = parsed.images[0];
if (!image && parsed.image) image = parsed.image;
}
} catch {
// Content is not JSON — use it as description fallback
if (!description && event.content.length > 0) {
description = event.content;
}
}
}
return { title, description, image };
}
/** Inline embedded card for an addressable Nostr event (naddr). */
export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNaddrProps) {
const { data: event, isLoading, isError } = useAddrEvent(addr);
if (isLoading) {
return <EmbeddedNaddrSkeleton className={className} />;
}
if (isError || !event) {
return <EmbeddedNaddrTombstone addr={addr} className={className} />;
}
// Badge definitions get a compact showcase instead of a link-preview card
if (event.kind === 30009) {
return <EmbeddedBadgeCard event={event} className={className} />;
}
// Profile badges (kind 10008/30008) get a compact badge row preview
if (isProfileBadgesKind(event.kind)) {
return <EmbeddedProfileBadgesCard event={event} className={className} />;
}
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
/** Compact badge showcase for kind 30009 embeds — smaller version of the feed BadgeContent. */
function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?: string }) {
const navigate = useNavigate();
const badge = useMemo(() => parseBadgeDefinition(event), [event]);
const naddrId = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}, [event]);
if (!badge) return <EmbeddedNaddrCard event={event} className={className} />;
const heroImage = badge.image
?? badge.thumbs.find((t) => t.dimensions === '512x512')?.url
?? badge.thumbs[0]?.url;
return (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${naddrId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${naddrId}`);
}
}}
>
{/* Compact badge showcase */}
<div className="relative isolate flex flex-col items-center py-6 overflow-hidden">
{/* Rotating light rays — scaled down from the feed version */}
<div
className="absolute -z-10 pointer-events-none"
aria-hidden="true"
style={{
width: 240,
height: 240,
top: '50%',
left: '50%',
transform: 'translate(-50%, -55%)',
}}
>
<div
className="w-full h-full animate-badge-spotlight"
style={{
background: `repeating-conic-gradient(
hsl(var(--primary) / 0.08) 0deg 6deg,
transparent 6deg 18deg
)`,
maskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
}}
/>
</div>
{/* Badge image */}
<div className="relative z-[1]">
{heroImage ? (
<img
src={heroImage}
alt={badge.name}
className="size-20 rounded-xl object-cover drop-shadow-lg"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-20 rounded-xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-8 text-primary/30" />
</div>
)}
</div>
{/* Badge info */}
<div className="relative z-[1] mt-3 text-center px-4 max-w-xs">
<p className="text-sm font-semibold leading-snug">{badge.name}</p>
{badge.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{badge.description}</p>
)}
</div>
</div>
</div>
);
}
/** Maximum badge thumbnails to show in the embedded preview. */
const EMBED_BADGE_LIMIT = 6;
/** Compact badge list preview for kind 10008/30008 profile badges embeds. */
export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEvent; className?: string }) {
const { nostr } = useNostr();
const navigate = useNavigate();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const badgeRefs = useMemo(() => parseProfileBadges(event), [event]);
// Fetch badge definitions for the preview
const { data: badgeDefs } = useQuery({
queryKey: ['badge-definitions-embed', badgeRefs.map((r) => r.aTag).join(',')],
queryFn: async () => {
if (badgeRefs.length === 0) return [];
const filters = badgeRefs.slice(0, EMBED_BADGE_LIMIT).map((ref) => ({
kinds: [30009 as const],
authors: [ref.pubkey],
'#d': [ref.identifier],
limit: 1,
}));
return nostr.query(filters);
},
enabled: badgeRefs.length > 0,
staleTime: 5 * 60_000,
});
const badgeMap = useMemo(() => {
const map = new Map<string, BadgeData>();
if (!badgeDefs) return map;
for (const ev of badgeDefs) {
const parsed = parseBadgeDefinition(ev);
if (!parsed) continue;
map.set(`30009:${ev.pubkey}:${parsed.identifier}`, parsed);
}
return map;
}, [badgeDefs]);
const naddrId = useMemo(
() => nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: '' }),
[event.kind, event.pubkey],
);
const showRefs = badgeRefs.slice(0, EMBED_BADGE_LIMIT);
const remaining = Math.max(0, badgeRefs.length - EMBED_BADGE_LIMIT);
return (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${naddrId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${naddrId}`);
}
}}
>
<div className="px-3 py-2 space-y-2">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<Link
to={`/${nip19.npubEncode(event.pubkey)}`}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<Link
to={`/${nip19.npubEncode(event.pubkey)}`}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
</span>
</div>
{/* Badge thumbnails row */}
{showRefs.length > 0 && (
<div className="flex items-center gap-1.5">
{showRefs.map((ref) => {
const badge = badgeMap.get(ref.aTag);
if (!badge) return (
<div key={ref.aTag} className="size-8 rounded-md border border-border bg-background flex items-center justify-center shrink-0">
<Award className="size-4 text-muted-foreground" />
</div>
);
return (
<BadgeThumbnail key={ref.aTag} badge={badge} size={32} />
);
})}
{remaining > 0 && (
<span className="text-[10px] text-muted-foreground font-medium ml-0.5">
+{remaining}
</span>
)}
</div>
)}
{/* Kind label */}
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<Award className="size-3 shrink-0" />
{badgeRefs.length} badge{badgeRefs.length !== 1 ? 's' : ''}
</p>
</div>
</div>
);
}
function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
const navigate = useNavigate();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const naddrId = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}, [event]);
const { title, description, image } = useMemo(() => extractMetadata(event), [event]);
const truncatedDesc = useMemo(() => {
if (!description) return undefined;
if (description.length <= MAX_CONTENT_LENGTH) return description;
return description.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…';
}, [description]);
// Kind label for context (e.g. "nsite" with icon)
const kindMeta = useMemo(() => {
const label = getKindLabel(event.kind);
if (!label) return undefined;
return { label, Icon: getKindIcon(event.kind) };
}, [event.kind]);
return (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${naddrId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${naddrId}`);
}
}}
>
{/* Text content */}
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeProfileHoverCard>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</MaybeProfileHoverCard>
</>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
</span>
</div>
{/* Title */}
{title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{title}
</p>
)}
{/* Description */}
{truncatedDesc && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{truncatedDesc}
</p>
)}
{/* Kind label and attachment indicators */}
<div className="flex items-center gap-2 flex-wrap">
{kindMeta && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
{kindMeta.label}
</span>
)}
{image && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
Image
</span>
)}
</div>
</div>
</div>
);
}
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
if (disabled) {
return <>{children}</>;
}
return (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
);
}
/** Tombstone shown when an addressable event could not be loaded. */
function EmbeddedNaddrTombstone({ addr, className }: { addr: AddrCoords; className?: string }) {
const navigate = useNavigate();
const naddrId = useMemo(
() => nip19.naddrEncode({
kind: addr.kind,
pubkey: addr.pubkey,
identifier: addr.identifier,
}),
[addr],
);
return (
<div
className={cn(
'rounded-2xl border border-dashed border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${naddrId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${naddrId}`);
}
}}
>
<div className="px-3.5 py-4 flex items-center gap-2 text-muted-foreground">
<MessageSquareOff className="size-4 shrink-0" />
<span className="text-sm">This post could not be loaded</span>
</div>
</div>
);
}
function EmbeddedNaddrSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<div className="px-3.5 py-2.5 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-10" />
</div>
<Skeleton className="h-4 w-3/4" />
<div className="space-y-1.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</div>
</div>
</div>
);
}