Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
13 kB · 395 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396import { 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<string, Promise<ProfileViewDetailed | null>>();
const didDocCache = new Map<string, { alsoKnownAs: string[] }>();const inflightDidDocs = new Map<string, Promise<{ alsoKnownAs: string[] } | null>>();
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<DidDocument>; }) .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<ProfileViewDetailed | null> { const cacheKey = `hover-profile:${handle}`; const cached = cacheGet<ProfileViewDetailed>(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<HoverProfileData | null>(null); const [loading, setLoading] = useState(false); const [cardPlacement, setCardPlacement] = useState<'top' | 'bottom'>('top'); const [cardStyle, setCardStyle] = useState<React.CSSProperties>({}); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const triggerRef = useRef<HTMLSpanElement>(null); const cardRef = useRef<HTMLDivElement>(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 ( <span ref={triggerRef} className={`handle-hover-trigger${className ? ` ${className}` : ''}`} style={style} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onAuxClick={onAuxClick} > {children ?? `@${handle}`}
{showCard && createPortal( <div ref={cardRef} className={`handle-hover-card handle-hover-card--${cardPlacement}`} style={cardStyle} onMouseEnter={() => { clearTimeouts(); }} onMouseLeave={handleMouseLeave} > {loading ? ( <div className="handle-hover-loading">Loading…</div> ) : !profile ? ( <div className="handle-hover-card__header"> <span className="handle-hover-card__name">@{handle}</span> </div> ) : ( <> <div className="handle-hover-card__header"> {profile.avatar ? ( <img className="handle-hover-card__avatar" src={profile.avatar} alt="" /> ) : ( <div className="handle-hover-card__avatar-placeholder"> {(profile.displayName ?? handle).charAt(0).toUpperCase()} </div> )} <div className="handle-hover-card__info"> <div className="handle-hover-card__name-row"> <span className="handle-hover-card__name"> {profile.displayName ?? handle} </span> <PdsFavicon did={profile.did} /> {profile.pronouns && ( <span className="handle-hover-card__pronouns">{profile.pronouns}</span> )} {profile.viewer?.followedBy && <span className="follows-you-badge">Follows you</span>} {isBlockedBy(profile.did) && <span className="blocks-you-badge">Blocks you</span>} {profile.viewer?.blocking && <span className="blocked-badge">Blocked</span>} {profile.viewer?.muted && <span className="muted-badge">Muted</span>} {profile.suspended && <span className="suspended-badge">Suspended</span>} </div> <span className="handle-hover-card__handle">@{profile.handle}</span> </div> </div>
{profile.description && ( <div className="handle-hover-card__bio"> {profile.description.length > 200 ? profile.description.slice(0, 200) + '…' : profile.description} </div> )}
{profile.website && ( <a className="handle-hover-card__website" href={profile.website} target="_blank" rel="noopener noreferrer" > <FontAwesomeIcon icon={faLink} /> {extractDisplayUrl(profile.website)} </a> )}
<div className="handle-hover-card__stats"> <span><strong>{formatCount(profile.followsCount ?? 0)}</strong> following</span> <span><strong>{formatCount(profile.followersCount ?? 0)}</strong> followers</span> <span><strong>{formatCount(profile.postsCount ?? 0)}</strong> {label('post', true)}</span> </div>
{session && session.did !== profile.did && ( <div className="handle-hover-card__actions"> <ProfileActions subjectDid={profile.did} followingUri={profile.viewer?.following} blockingUri={profile.viewer?.blocking} muted={profile.viewer?.muted} size="sm" /> </div> )}
{data.pinnedPostText && ( <div className="handle-hover-card__pinned"> <div className="handle-hover-card__pinned-label">📌 Pinned</div> <div className="handle-hover-card__pinned-text"> {data.pinnedPostText.length > 150 ? data.pinnedPostText.slice(0, 150) + '…' : data.pinnedPostText} </div> </div> )}
{data.alsoKnownAs.length > 0 && ( <div className="handle-hover-card__aka"> <span className="handle-hover-card__aka-label">Also known as:</span> {data.alsoKnownAs.map((aka) => ( <span key={aka} className="handle-hover-card__aka-handle"> @{aka} </span> ))} </div> )} </> )} </div>, document.body, )} </span> );}
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; }}