import { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLink } from '@fortawesome/free-solid-svg-icons'; import type { ProfileViewDetailed } from '../api/types'; import { atprotoClient } from '../api/client'; import { cacheGet, cacheSet } from '../prefetch/cache'; import { useAuth } from '../auth/useAuth'; import { usePostLabels } from '../settings/usePostLabels'; import { useMuteBlock } from './muteBlockContext'; import ProfileActions from './ProfileActions'; import PdsFavicon from './PdsFavicon'; import './HandleHoverCard.css'; const inflightProfiles = new Map>(); const didDocCache = new Map(); const inflightDidDocs = new Map>(); interface DidDocument { alsoKnownAs?: string[]; verificationMethod?: unknown[]; service?: unknown[]; } async function fetchDidDocument(did: string): Promise<{ alsoKnownAs: string[] } | null> { if (didDocCache.has(did)) return didDocCache.get(did)!; if (inflightDidDocs.has(did)) return inflightDidDocs.get(did)!; let url: string; if (did.startsWith('did:plc:')) { url = `https://plc.directory/${encodeURIComponent(did)}`; } else if (did.startsWith('did:web:')) { const domain = did.split(':')[2]; url = `https://${domain}/.well-known/did.json`; } else { return null; } const promise = fetch(url) .then((r) => { if (!r.ok) return null; return r.json() as Promise; }) .then((doc) => { const result: { alsoKnownAs: string[] } = { alsoKnownAs: doc?.alsoKnownAs ?.map((aka: string) => { if (aka.startsWith('at://')) { const parts = aka.replace('at://', '').split('/'); return parts[0]; } return aka; }) .filter((h: string) => h && h.includes('.')) ?? [], }; didDocCache.set(did, result); inflightDidDocs.delete(did); return result; }) .catch(() => { inflightDidDocs.delete(did); return null; }); inflightDidDocs.set(did, promise); return promise; } async function fetchHoverProfile(handle: string): Promise { const cacheKey = `hover-profile:${handle}`; const cached = cacheGet(cacheKey); if (cached) return cached; if (inflightProfiles.has(handle)) return inflightProfiles.get(handle)!; const promise = atprotoClient.getActorProfile(handle) .then((profile) => { cacheSet(cacheKey, profile, 60_000); inflightProfiles.delete(handle); return profile as ProfileViewDetailed; }) .catch(() => { inflightProfiles.delete(handle); return null; }); inflightProfiles.set(handle, promise); return promise; } interface HoverProfileData { profile: ProfileViewDetailed | null; alsoKnownAs: string[]; pinnedPostText: string | null; pinnedPostAuthor: string | null; } interface HandleHoverCardProps { handle: string; did?: string; className?: string; style?: React.CSSProperties; children?: React.ReactNode; onAuxClick?: (e: React.MouseEvent) => void; } export default function HandleHoverCard({ handle, did, className, style, children, onAuxClick }: HandleHoverCardProps) { const [showCard, setShowCard] = useState(false); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [cardPlacement, setCardPlacement] = useState<'top' | 'bottom'>('top'); const [cardStyle, setCardStyle] = useState({}); const hoverTimerRef = useRef | null>(null); const leaveTimerRef = useRef | null>(null); const triggerRef = useRef(null); const cardRef = useRef(null); const fetchedRef = useRef(false); const { session } = useAuth(); const { t: label } = usePostLabels(); const { isBlockedBy, addBlockedBy } = useMuteBlock(); const clearTimeouts = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (leaveTimerRef.current) { clearTimeout(leaveTimerRef.current); leaveTimerRef.current = null; } }, []); const fetchData = useCallback(async () => { if (fetchedRef.current) return; fetchedRef.current = true; setLoading(true); try { const profile = await fetchHoverProfile(handle); let alsoKnownAs: string[] = []; let pinnedPostText: string | null = null; let pinnedPostAuthor: string | null = null; if (profile) { const didToUse = did ?? profile.did; const didDoc = await fetchDidDocument(didToUse); if (didDoc) { alsoKnownAs = didDoc.alsoKnownAs.filter((h) => h !== handle); } if (profile.pinnedPost?.uri) { try { const thread = await atprotoClient.getPostThread(profile.pinnedPost.uri, 0, 0); if (thread.thread && '$type' in thread.thread && 'post' in thread.thread) { const threadPost = thread.thread as { post: { record: { text: string }; author: { handle: string } } }; pinnedPostText = threadPost.post.record.text; pinnedPostAuthor = threadPost.post.author.handle; } } catch { /* empty */ } } } setData({ profile, alsoKnownAs, pinnedPostText, pinnedPostAuthor }); } catch { setData(null); } finally { setLoading(false); } }, [handle, did]); const handleMouseEnter = useCallback(() => { clearTimeouts(); hoverTimerRef.current = setTimeout(() => { setShowCard(true); fetchData(); }, 400); }, [clearTimeouts, fetchData]); const handleMouseLeave = useCallback(() => { clearTimeouts(); leaveTimerRef.current = setTimeout(() => { setShowCard(false); }, 200); }, [clearTimeouts]); useEffect(() => { if (!showCard || !triggerRef.current) return; const compute = () => { const triggerEl = triggerRef.current; if (!triggerEl) return; const triggerRect = triggerEl.getBoundingClientRect(); const cardEl = cardRef.current; const cardHeight = cardEl ? cardEl.offsetHeight : 320; const cardWidth = cardEl ? cardEl.offsetWidth : 320; const GAP = 6; const PAD = 8; // Determine placement: top or bottom const spaceAbove = triggerRect.top; const spaceBelow = window.innerHeight - triggerRect.bottom; const placement: 'top' | 'bottom' = spaceAbove < cardHeight + GAP + PAD && spaceBelow > spaceAbove ? 'bottom' : 'top'; setCardPlacement(placement); let top: number; if (placement === 'top') { top = triggerRect.top - cardHeight - GAP; if (top < PAD) top = PAD; } else { top = triggerRect.bottom + GAP; } let left = triggerRect.left; // Clamp horizontally if (left + cardWidth > window.innerWidth - PAD) { left = window.innerWidth - PAD - cardWidth; } if (left < PAD) { left = PAD; } setCardStyle({ position: 'fixed', top, left, zIndex: 99999 }); }; compute(); const rafId = requestAnimationFrame(compute); return () => cancelAnimationFrame(rafId); }, [showCard, data]); useEffect(() => { return () => { clearTimeouts(); }; }, [clearTimeouts]); const profile = data?.profile; if (profile?.viewer?.blockedBy) addBlockedBy(profile.did); return ( {children ?? `@${handle}`} {showCard && createPortal(
{ clearTimeouts(); }} onMouseLeave={handleMouseLeave} > {loading ? (
Loading…
) : !profile ? (
@{handle}
) : ( <>
{profile.avatar ? ( ) : (
{(profile.displayName ?? handle).charAt(0).toUpperCase()}
)}
{profile.displayName ?? handle} {profile.pronouns && ( {profile.pronouns} )} {profile.viewer?.followedBy && Follows you} {isBlockedBy(profile.did) && Blocks you} {profile.viewer?.blocking && Blocked} {profile.viewer?.muted && Muted} {profile.suspended && Suspended}
@{profile.handle}
{profile.description && (
{profile.description.length > 200 ? profile.description.slice(0, 200) + '…' : profile.description}
)} {profile.website && ( {extractDisplayUrl(profile.website)} )}
{formatCount(profile.followsCount ?? 0)} following {formatCount(profile.followersCount ?? 0)} followers {formatCount(profile.postsCount ?? 0)} {label('post', true)}
{session && session.did !== profile.did && (
)} {data.pinnedPostText && (
📌 Pinned
{data.pinnedPostText.length > 150 ? data.pinnedPostText.slice(0, 150) + '…' : data.pinnedPostText}
)} {data.alsoKnownAs.length > 0 && (
Also known as: {data.alsoKnownAs.map((aka) => ( @{aka} ))}
)} )}
, document.body, )}
); } function formatCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } function extractDisplayUrl(url: string): string { try { const u = new URL(url); return u.hostname + (u.pathname !== '/' ? u.pathname : ''); } catch { return url; } }