64cf10f381
The cache-peek approach always returned npub because the cache was empty at render time. Replace it with a proper hook that calls useNip05Verify, relying on TanStack Query's deduplication to avoid redundant requests. Also fix the dot separator in NoteCard to hide when NIP-05 is unverified, and refactor ProfileSearchDropdown to pass the verified URL through onClick rather than recomputing it in an event handler.
136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
|
import { EmojifiedText } from '@/components/CustomEmoji';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { formatNip05Display, getNip05Domain } from '@/lib/nip05';
|
|
import { useNip05Verify } from '@/hooks/useNip05Verify';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { FollowButton } from '@/components/FollowButton';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ProfileHoverCardProps {
|
|
pubkey: string;
|
|
children: React.ReactNode;
|
|
/** If true, the trigger element won't be wrapped in anything extra */
|
|
asChild?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Inner content component — mounts only when the hover card is open.
|
|
* Triggers a background refetch of the author's profile on mount.
|
|
*/
|
|
function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
|
|
const queryClient = useQueryClient();
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(pubkey);
|
|
const profileUrl = useProfileUrl(pubkey, metadata);
|
|
const nip05 = metadata?.nip05;
|
|
const nip05Domain = getNip05Domain(nip05);
|
|
const { data: nip05Verified } = useNip05Verify(nip05, pubkey);
|
|
const nip05Display = nip05Verified && nip05 ? formatNip05Display(nip05) : undefined;
|
|
|
|
useEffect(() => {
|
|
queryClient.refetchQueries({ queryKey: ['author', pubkey] });
|
|
}, [pubkey, queryClient]);
|
|
|
|
return (
|
|
<>
|
|
{/* Mini banner */}
|
|
<div className="h-16 bg-secondary relative">
|
|
{metadata?.banner && (
|
|
<img
|
|
src={metadata.banner}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
{/* Follow button over the banner */}
|
|
<div className="absolute top-2 right-2">
|
|
<FollowButton pubkey={pubkey} size="sm" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile info */}
|
|
<div className="px-4 pb-4">
|
|
{/* Avatar overlapping the banner */}
|
|
<div className="-mt-8 mb-2">
|
|
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
|
|
<Avatar className="size-16 border-3 border-background">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-lg">
|
|
{displayName[0]?.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Name + NIP-05 */}
|
|
<Link
|
|
to={profileUrl}
|
|
className="font-bold text-[15px] hover:underline block truncate"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{author.data?.event ? (
|
|
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
|
) : displayName}
|
|
</Link>
|
|
|
|
{nip05Display && (
|
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
|
|
<span className="truncate">@{nip05Display}</span>
|
|
{nip05Domain && (
|
|
<ExternalFavicon url={`https://${nip05Domain}`} size={14} className="shrink-0" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{metadata?.bot && (
|
|
<span className="text-xs text-primary mt-1 inline-block" title="Bot account">Bot</span>
|
|
)}
|
|
|
|
{/* Bio */}
|
|
{metadata?.about && (
|
|
<p className={cn(
|
|
'text-sm text-muted-foreground mt-2 whitespace-pre-wrap break-words',
|
|
'line-clamp-3',
|
|
)}>
|
|
{author.data?.event ? (
|
|
<EmojifiedText tags={author.data.event.tags}>{metadata.about}</EmojifiedText>
|
|
) : metadata.about}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wraps any element with a hover card that shows a profile preview.
|
|
* Shows avatar, display name, NIP-05, and bio on hover.
|
|
*/
|
|
export function ProfileHoverCard({ pubkey, children, asChild }: ProfileHoverCardProps) {
|
|
return (
|
|
<HoverCard openDelay={300} closeDelay={150}>
|
|
<HoverCardTrigger asChild={asChild}>
|
|
{children}
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={8}
|
|
className="w-72 p-0 rounded-2xl overflow-hidden border border-border shadow-xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ProfileHoverCardBody pubkey={pubkey} />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|