Files
eranos/src/components/EmbeddedPeopleListCard.tsx
T
Alex Gleason d84f2b790f Link people-list avatars to profiles and prefer naddr for kind 3
Stacked avatars in PeopleAvatarStack are now clickable, navigating to
the user's npub profile so readers can jump straight to a member from a
follow list, follow set, or follow pack — not only from the surrounding
post. Each avatar is wrapped in a Link with a stopPropagation handler so
the click doesn't bubble up to the card-level navigation, and the focus
ring is now visible on keyboard focus.

Kind 3 follow-list events are legacy replaceable kinds (NIP-01) but
fell outside the 10000–19999 range that NoteCard, EmbeddedPeopleListCard,
PostDetailPage, and NoteMoreMenu all special-cased — so clicking a
follow list in a feed went to a per-event nevent that pinned to a stale
revision instead of the stable naddr. The four call sites are now
unified behind a new lib/encodeEvent.ts helper that treats kinds 0, 3,
and 41 as replaceable, alongside 10000–19999 and 30000–39999. The same
helper exposes encodeEventNevent for callers that intentionally want to
reference a historical version (e.g. the profile-recovery dialog).
2026-05-09 17:06:10 -07:00

129 lines
4.6 KiB
TypeScript

import { useMemo } from 'react';
import { Users, PartyPopper, UserCheck } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { encodeEventAddress } from '@/lib/encodeEvent';
import { genUserName } from '@/lib/genUserName';
import { parsePeopleList, getDisplayPubkeys } from '@/lib/packUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Max avatars shown in the embedded preview stack. */
const EMBED_AVATAR_LIMIT = 6;
interface EmbeddedPeopleListCardProps {
event: NostrEvent;
className?: string;
disableHoverCards?: boolean;
}
/**
* Compact embedded card for people-list events — kind 3 (follow list),
* 30000 (follow set), and 39089 (follow pack).
*
* The generic `EmbeddedNoteCard` / `EmbeddedNaddrCard` fallbacks render an
* empty shell for these kinds because the meaningful data (the list of
* pubkeys) lives in `p` tags, not in `content` or title tags. This card
* shows the title, an avatar stack, and a member count — matching the
* visual language of the full feed card `PeopleListContent`.
*/
export function EmbeddedPeopleListCard({ event, className, disableHoverCards }: EmbeddedPeopleListCardProps) {
// For kind 3 follow lists we synthesize a title from the author's display name.
const needsAuthorMeta = event.kind === 3;
const author = useAuthor(needsAuthorMeta ? event.pubkey : '');
const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined;
const { title, description, image, pubkeys, variant } = useMemo(
() => parsePeopleList(event, {
authorMetadata,
authorDisplayName: authorMetadata?.name || authorMetadata?.display_name,
}),
[event, authorMetadata],
);
const nip19Id = useMemo(() => encodeEventAddress(event), [event]);
const previewPubkeys = useMemo(
() => getDisplayPubkeys(event, pubkeys).slice(0, EMBED_AVATAR_LIMIT),
[event, pubkeys],
);
const { data: membersMap } = useAuthors(previewPubkeys);
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper;
const memberLabel = pubkeys.length === 1 ? '1 member' : `${pubkeys.length} members`;
return (
<EmbeddedCardShell
pubkey={event.pubkey}
createdAt={event.created_at}
navigateTo={nip19Id}
className={className}
disableHoverCards={disableHoverCards}
>
{/* Title with variant icon */}
<div className="flex items-center gap-1.5 min-w-0">
<TitleIcon className="size-3.5 text-primary shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-1">
{title}
</p>
</div>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description}
</p>
)}
{/* Cover image — only for packs/sets that declare one */}
{safeImage && (
<div className="rounded-lg overflow-hidden">
<img
src={safeImage}
alt={title}
className="w-full max-h-[140px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Avatar stack + member count */}
{pubkeys.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex -space-x-1.5">
{previewPubkeys.map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || member?.metadata?.display_name || genUserName(pk);
const shape = getAvatarShape(member?.metadata);
return (
<Avatar
key={pk}
shape={shape}
className="size-5 ring-1 ring-background"
>
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-[11px] text-muted-foreground">
{memberLabel}
</span>
</div>
)}
</EmbeddedCardShell>
);
}