d84f2b790f
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).
129 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|