import React, { useEffect, useState, useCallback } 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 { ProfileActivity } from '../ui/ProfileActivity'; import { useActivityStore } from '../../stores/activityStore'; import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useAuthStore } from '../../stores/authStore'; import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity'; import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals'; type Tab = 'about' | 'friends' | 'spaces'; type FriendshipStatus = | { state: 'self' } | { state: 'friends'; friend: TaggedFriend } | { state: 'outbound_pending'; request: TaggedFriendRequest } | { state: 'inbound_pending'; request: TaggedFriendRequest } | { state: 'none' }; function getFriendshipStatus( viewedUser: User, currentUser: User | null, friends: TaggedFriend[], requests: TaggedFriendRequest[], ): FriendshipStatus { if (!currentUser) return { state: 'none' }; if (isSelf(viewedUser, currentUser)) return { state: 'self' }; const friend = friends.find(f => canonicalUserMatch(f, viewedUser)); if (friend) return { state: 'friends', friend }; const request = requests.find(r => r.user && canonicalUserMatch(r.user, viewedUser) ); if (request?.user) { // request.user is the OTHER party. If their ID === toId, then I am fromId (outbound) const isOutbound = request.user.id === request.toId; return isOutbound ? { state: 'outbound_pending', request } : { state: 'inbound_pending', request }; } return { state: 'none' }; } export function UserProfileModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const addToast = useUIStore((s) => s.addToast); const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const friends = useSocialStore((s) => s.friends); const requests = useSocialStore((s) => s.requests); const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const removeFriend = useSocialStore((s) => s.removeFriend); const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest); const currentUser = useAuthStore((s) => s.user); const [user, setUser] = useState(null); const [userOrigin, setUserOrigin] = useState(''); const [activeTab, setActiveTab] = useState('about'); const [mutualFriends, setMutualFriends] = useState([]); const [mutualSpaces, setMutualSpaces] = useState([]); const [loadingMutuals, setLoadingMutuals] = useState(false); const [friendActionLoading, setFriendActionLoading] = useState(false); const isOpen = activeModal === 'userProfile'; const userId = modalData?.userId as string | undefined; const passedUser = modalData?.user as User | undefined; const passedOrigin = (modalData?.origin as string | undefined) ?? ''; // Determine friendship status (federation-safe canonical matching) const friendship: FriendshipStatus = user ? getFriendshipStatus(user, currentUser, friends, requests) : { state: 'none' }; const loadUser = useCallback(async (id: string, origin: string) => { try { const targetApi = getApiForOrigin(origin); const u = await targetApi.users.get(id); setUser(u); useSpaceStore.getState().upsertUserView(u, origin); } catch { // User not found } }, []); const loadMutuals = useCallback(async (id: string, targetUser?: User) => { setLoadingMutuals(true); try { const data = await loadFederatedMutuals(id, targetUser?.homeUserId); setMutualFriends(data.mutualFriends); setMutualSpaces(data.mutualSpaces); } catch { setMutualFriends([]); setMutualSpaces([]); } finally { setLoadingMutuals(false); } }, []); useEffect(() => { if (isOpen && userId) { setActiveTab('about'); const origin = passedOrigin || (passedUser ? resolveUserOrigin(passedUser) : ''); setUserOrigin(origin); // Use the passed user directly (avoids 404 for federated users on local API) if (passedUser) { setUser(passedUser); } else { loadUser(userId, origin); } loadMutuals(userId, passedUser); } }, [isOpen, userId, passedUser, passedOrigin, loadUser, loadMutuals]); // Reset on close useEffect(() => { if (!isOpen) { setUser(null); setUserOrigin(''); setMutualFriends([]); setMutualSpaces([]); } }, [isOpen]); // Escape to close useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [isOpen, closeModal]); if (!isOpen || !user) return null; const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; // Banner — use correct API client for remote users const profileApi = getApiForOrigin(userOrigin); // Keyed by home id, matching every other activity consumer (ActivityPanel, // MemberSidebar), so federated users resolve to the same record. The `?? []` // stays OUTSIDE the selector: building it inside would hand zustand a fresh // array reference every render and spin. const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id)); const activities = activityList ?? []; const bannerSrc = user.banner ? (user.banner.startsWith('http') ? user.banner : profileApi.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); })(); const handleSendMessage = async () => { try { const existing = useSpaceStore.getState().findExistingDmForUser(user); if (existing) { useUIStore.getState().setShowDms(true); closeModal(); 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); closeModal(); navigate(`/channels/@me/${channel.id}`); } catch (err) { console.error('Failed to create DM channel:', err); } }; const handleAddFriend = async () => { setFriendActionLoading(true); try { await sendFriendRequest(user.username); } catch (err) { // The shared API client throws `new Error(body.error)` for non-2xx // responses (api/client.ts:298), so err.message carries the server's // error code (e.g. 'peer_pending_approval'). const code = err instanceof Error ? err.message : undefined; addToast(mapServerErrorToMessage(code, code, user.username), 'warning'); } finally { setFriendActionLoading(false); } }; const handleRemoveFriend = async () => { if (friendship.state !== 'friends') return; setFriendActionLoading(true); try { await removeFriend(friendship.friend.id); } catch (err) { addToast((err as Error).message, 'warning'); } finally { setFriendActionLoading(false); } }; const handleCancelRequest = async () => { if (friendship.state !== 'outbound_pending') return; setFriendActionLoading(true); try { await cancelFriendRequest(friendship.request.id); } catch (err) { addToast((err as Error).message, 'warning'); } finally { setFriendActionLoading(false); } }; const handleAcceptRequest = async () => { if (friendship.state !== 'inbound_pending') return; setFriendActionLoading(true); try { await updateFriendRequest(friendship.request.id, 'accepted'); } catch (err) { addToast((err as Error).message, 'warning'); } finally { setFriendActionLoading(false); } }; const handleDeclineRequest = async () => { if (friendship.state !== 'inbound_pending') return; setFriendActionLoading(true); try { await updateFriendRequest(friendship.request.id, 'declined'); } catch (err) { addToast((err as Error).message, 'warning'); } finally { setFriendActionLoading(false); } }; const handleViewFriend = (friend: TaggedMutualFriend) => { const friendOrigin = friend._instanceOrigin || resolveUserOrigin(friend); setUserOrigin(friendOrigin); setUser(friend); loadMutuals(friend.id, friend); setActiveTab('about'); // Update modal data so re-opening preserves context useUIStore.getState().openModal('userProfile', { userId: friend.id, user: friend, origin: friendOrigin }); }; const handleGoToSpace = (spaceId: string) => { closeModal(); navigate(`/channels/${spaceId}`); }; const tabs: { key: Tab; label: string; count?: number }[] = [ { key: 'about', label: 'About' }, { key: 'friends', label: 'Mutual Friends', count: mutualFriends.length }, { key: 'spaces', label: 'Mutual Spaces', count: mutualSpaces.length }, ]; return (
{/* Banner */}
{/* Close button */}
{/* Header (avatar + name) */}
{user.customStatus && (
{user.customStatus}
)}
{/* Tab bar */}
{tabs.map((tab) => ( ))}
{/* Tab content */}
{activeTab === 'about' && (
{/* Bio */} {user.bio && (
About Me
( {children} ), }} > {user.bio}
)} {/* Current activity — the "Listening to Spotify" block */} {/* Member Since */}
Member Since
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric', })}
)} {activeTab === 'friends' && (
{loadingMutuals ? (
) : mutualFriends.length === 0 ? (
No mutual friends
) : (
{mutualFriends.map((friend) => { const fname = friend.displayName ?? parseFederatedUsername(friend.username).baseName; return ( ); })}
)}
)} {activeTab === 'spaces' && (
{loadingMutuals ? (
) : mutualSpaces.length === 0 ? (
No mutual spaces
) : (
{mutualSpaces.map((space) => { const spaceApi = getApiForOrigin(space._instanceOrigin); return ( ); })}
)}
)}
{/* Action buttons */}
{friendship.state === 'none' && ( )} {friendship.state === 'outbound_pending' && ( )} {friendship.state === 'inbound_pending' && ( <> )} {friendship.state === 'friends' && ( )}
); }