(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 160 lines 5.3 kB view raw
1import React, { useState, useEffect, useRef } from "react"; 2import Avatar from "../ui/Avatar"; 3import RichText from "./RichText"; 4import { getProfile } from "../../api/client"; 5import type { UserProfile } from "../../types"; 6import { Loader2 } from "lucide-react"; 7import { useTranslation } from "react-i18next"; 8 9interface ProfileHoverCardProps { 10 did?: string; 11 handle?: string; 12 children: React.ReactNode; 13 className?: string; 14} 15 16export default function ProfileHoverCard({ 17 did, 18 handle, 19 children, 20 className, 21}: ProfileHoverCardProps) { 22 const { t } = useTranslation(); 23 const [isOpen, setIsOpen] = useState(false); 24 const [profile, setProfile] = useState<UserProfile | null>(null); 25 const [loading, setLoading] = useState(false); 26 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 27 const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 28 const cardRef = useRef<HTMLDivElement>(null); 29 30 const handleMouseEnter = () => { 31 timeoutRef.current = setTimeout(async () => { 32 setIsOpen(true); 33 if (!profile && (did || handle)) { 34 setLoading(true); 35 try { 36 const identifier = did || handle || ""; 37 38 const [marginData, bskyData] = await Promise.all([ 39 getProfile(identifier).catch(() => null), 40 fetch( 41 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identifier)}`, 42 ) 43 .then((res) => (res.ok ? res.json() : null)) 44 .catch(() => null), 45 ]); 46 47 const merged: UserProfile = { 48 did: marginData?.did || bskyData?.did || identifier, 49 handle: marginData?.handle || bskyData?.handle || "", 50 displayName: marginData?.displayName || bskyData?.displayName, 51 avatar: marginData?.avatar || bskyData?.avatar, 52 description: marginData?.description || bskyData?.description, 53 }; 54 55 setProfile(merged); 56 } catch (e) { 57 console.error("Failed to load profile", e); 58 } finally { 59 setLoading(false); 60 } 61 } 62 }, 400); 63 }; 64 65 const handleMouseLeave = () => { 66 if (timeoutRef.current) { 67 clearTimeout(timeoutRef.current); 68 timeoutRef.current = null; 69 } 70 closeTimeoutRef.current = setTimeout(() => { 71 setIsOpen(false); 72 }, 300); 73 }; 74 75 const handleCardMouseEnter = () => { 76 if (closeTimeoutRef.current) { 77 clearTimeout(closeTimeoutRef.current); 78 closeTimeoutRef.current = null; 79 } 80 }; 81 82 const handleCardMouseLeave = () => { 83 setIsOpen(false); 84 }; 85 86 useEffect(() => { 87 return () => { 88 if (timeoutRef.current) { 89 clearTimeout(timeoutRef.current); 90 } 91 if (closeTimeoutRef.current) { 92 clearTimeout(closeTimeoutRef.current); 93 } 94 }; 95 }, []); 96 97 return ( 98 <div 99 className={`relative inline-block ${className || ""}`} 100 onMouseEnter={handleMouseEnter} 101 onMouseLeave={handleMouseLeave} 102 ref={cardRef} 103 > 104 {children} 105 106 {isOpen && ( 107 <div 108 className="absolute z-50 left-0 top-full mt-2 w-72 bg-white dark:bg-surface-800 rounded-xl shadow-xl border border-surface-200 dark:border-surface-700 p-4 animate-in fade-in slide-in-from-top-1 duration-150" 109 onMouseEnter={handleCardMouseEnter} 110 onMouseLeave={handleCardMouseLeave} 111 > 112 {loading ? ( 113 <div className="flex items-center justify-center py-4"> 114 <Loader2 size={20} className="animate-spin text-primary-600" /> 115 </div> 116 ) : profile ? ( 117 <div className="space-y-3"> 118 <a 119 href={`/profile/${profile.did}`} 120 className="flex items-start gap-3 group" 121 > 122 <Avatar 123 did={profile.did} 124 avatar={profile.avatar} 125 size="lg" 126 className="shrink-0" 127 /> 128 <div className="flex-1 min-w-0"> 129 <p className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 130 {profile.displayName || profile.handle} 131 </p> 132 <p className="text-sm text-surface-500 dark:text-surface-400 truncate"> 133 @{profile.handle} 134 </p> 135 </div> 136 </a> 137 138 {profile.description && ( 139 <p className="text-sm text-surface-600 dark:text-surface-300 whitespace-pre-line line-clamp-3"> 140 <RichText text={profile.description} /> 141 </p> 142 )} 143 144 <a 145 href={`/profile/${profile.did}`} 146 className="block w-full text-center py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" 147 > 148 {t("profileHoverCard.viewProfile")} 149 </a> 150 </div> 151 ) : ( 152 <p className="text-sm text-surface-500 text-center py-2"> 153 {t("profileHoverCard.notFound")} 154 </p> 155 )} 156 </div> 157 )} 158 </div> 159 ); 160}