import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import { useUIStore } from '../../stores/uiStore'; import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { parseFederatedUsername } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { loadFederatedMutuals } from '../../utils/mutuals'; interface UserProfilePopoutProps { user: User; onClose: () => void; position?: { top: number; left: number }; } export function UserProfilePopout({ user: propUser, onClose, position }: UserProfilePopoutProps) { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const openModal = useUIStore((s) => s.openModal); // Resolve to the best-known view of this user from the userViews cache. // The prop frequently arrives as a federated stub (when the carrying DM // came from a sibling instance that won the populateFromReady dedup race); // routing through the cache surfaces the home view when one is loaded. // Identity fields (id, homeUserId, homeInstance) are preserved across // canonicalization, so write-payload code paths below remain correct. const user = useCanonicalUserView(propUser); const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; const origin = resolveUserOrigin(user); const userApi = getApiForOrigin(origin); const [mutualCounts, setMutualCounts] = useState<{ friends: number; spaces: number } | null>(null); useEffect(() => { loadFederatedMutuals(user.id, user.homeUserId) .then((data) => setMutualCounts({ friends: data.mutualFriends.length, spaces: data.mutualSpaces.length })) .catch(() => {}); }, [user.id, user.homeUserId]); const top = position ? Math.min(Math.max(8, position.top), window.innerHeight - 460) : undefined; const left = position ? Math.min(Math.max(8, position.left), window.innerWidth - 356) : undefined; const handleSendMessage = async () => { try { const existing = useSpaceStore.getState().findExistingDmForUser(user); if (existing) { useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${existing.dm.id}`); return; } const channel = await api.dm.create({ userId: user.homeInstance ? undefined : user.id, homeUserId: user.homeUserId ?? undefined, homeInstance: user.homeInstance ?? undefined, }); addDmChannel(channel); useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${channel.id}`); } catch (err) { console.error('Failed to create DM channel:', err); } }; const handleViewFullProfile = () => { onClose(); openModal('userProfile', { userId: user.id, user, origin }); }; // Banner display const bannerSrc = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner)) : null; const bannerFallback = user.accentColor ? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40)) : (() => { const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor); return mutedGradient(g.from, g.to); })(); return (
{/* Banner */}
{/* Body */}
{/* Avatar */} {/* Name & info */}
{user.customStatus && (
{user.customStatus}
)}
{/* Bio */} {user.bio && ( <>
About Me
( {children} ), }} > {user.bio}
)}
{/* Member since + Mutuals */}
Member Since {new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
{mutualCounts && (mutualCounts.friends > 0 || mutualCounts.spaces > 0) && (
{mutualCounts.friends > 0 && ( {mutualCounts.friends} mutual friend{mutualCounts.friends !== 1 ? 's' : ''} )} {mutualCounts.friends > 0 && mutualCounts.spaces > 0 && ( · )} {mutualCounts.spaces > 0 && ( {mutualCounts.spaces} mutual space{mutualCounts.spaces !== 1 ? 's' : ''} )}
)}
{/* Actions */}
); }