import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import type { User } from '@backspace/shared'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser } from '../../stores/socialStore'; import { useAuthStore } from '../../stores/authStore'; import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore'; import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useSpaceStore } from '../../stores/spaceStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useUIStore } from '../../stores/uiStore'; import { useFederationStore } from '../../stores/federationStore'; import { Avatar } from '../ui/Avatar'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import { getAvatarGradient } from '../../utils/gradients'; import { api } from '../../api/client'; import { Mascot } from '../ui/Mascot'; import { useActivityStore } from '../../stores/activityStore'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { Username } from '../ui/Username'; import { ConfirmDialog } from '../ui/ConfirmDialog'; const statusLabel: Record = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' }; function ActivityFriendItem({ friend, isOffline, activities, isRichActivity, accentClass, mobile, onMobileClick, onDmClick, }: { friend: TaggedFriend; isOffline: boolean; activities: import('@backspace/shared').Activity[]; isRichActivity: boolean; accentClass: string; mobile?: boolean; onMobileClick: (userId: string) => void; onDmClick: (id: string, homeUserId?: string, homeInstance?: string | null) => void; }) { const canonical = useCanonicalUserView(friend as unknown as User); const { baseName } = parseFederatedUsername(canonical.username); const friendDisplayName = canonical.displayName ?? baseName; const rowClass = isRichActivity ? `flex items-center gap-3 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` : 'flex items-center gap-3 px-4 py-2.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer transition-colors active:bg-interactive-hover'; return (
{ if (mobile) { onMobileClick(friend.id); } else { onDmClick(friend.id, friend.homeUserId ?? undefined, friend.homeInstance); } }} className={rowClass} >
{!isOffline && isFederationGlobeApplicable(canonical) && (
@{parseFederatedUsername(canonical.username).domain}
)} {!isOffline && ( )}
); } type Tab = 'online' | 'all' | 'pending' | 'add' | 'activity'; interface FriendsPageProps { mobile?: boolean; } export function FriendsPage({ mobile }: FriendsPageProps) { const [activeTab, setActiveTab] = useState('online'); const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null); const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); // If the user clicked "Retry your friend request" in the Connections panel // and we just navigated here, the federation store carries the original // target. Switching to the Add tab makes the AddFriendTab mount, which then // consumes the prefill into its query input. We only check on mount — the // store value is one-shot (cleared by AddFriendTab on consume). useEffect(() => { if (useFederationStore.getState().pendingFriendAddPrefill) { setActiveTab('add'); } }, []); const { friends, requests, isLoading, loadFriends, loadRequests, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore(); useEffect(() => { loadFriends(); loadRequests(); }, [loadFriends, loadRequests]); const userActivities = useActivityStore((s) => s.userActivities); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const onlineFriends = friends.filter(f => f.status !== 'offline'); const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId); const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId); const handleOpenDm = async (friendId: string, homeUserId?: string, homeInstance?: string | null) => { try { // Check if a DM already exists with this user (on any instance) const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId, homeUserId: homeUserId ?? undefined }); if (existing) { useUIStore.getState().setShowDms(true); navigate(`/channels/@me/${existing.dm.id}`); return; } const dmChannel = await api.dm.create({ userId: homeInstance ? undefined : friendId, homeUserId: homeUserId ?? undefined, homeInstance: homeInstance ?? undefined, }); addDmChannel(dmChannel); navigate(`/channels/@me/${dmChannel.id}`); } catch (err) { console.error('Failed to open DM:', err); } }; const renderTabContent = () => { if (isLoading && friends.length === 0 && requests.length === 0) { return (
); } switch (activeTab) { case 'online': return (

Online — {onlineFriends.length}

{onlineFriends.length === 0 ? (

No one's online right now.

) : ( <> {onlineFriends.map(friend => ( setPendingUnfriend({ id: friend.id, name: friend.displayName ?? parseFederatedUsername(friend.username).baseName })} onDm={() => handleOpenDm(friend.id, friend.homeUserId ?? undefined, friend.homeInstance)} /> ))} )}
); case 'all': return (

All Friends — {friends.length}

{friends.length === 0 ? (

No friends yet — add someone!

) : ( <> {friends.map(friend => ( setPendingUnfriend({ id: friend.id, name: friend.displayName ?? parseFederatedUsername(friend.username).baseName })} onDm={() => handleOpenDm(friend.id, friend.homeUserId ?? undefined, friend.homeInstance)} /> ))} )}
); case 'pending': return (

Pending — {pendingIncoming.length + pendingOutgoing.length}

{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (

No pending requests — Nori is napping.

) : ( <> {pendingIncoming.map(req => ( updateFriendRequest(req.id, 'accepted')} onDecline={() => updateFriendRequest(req.id, 'declined')} /> ))} {pendingOutgoing.map(req => ( cancelFriendRequest(req.id)} /> ))} )}
); case 'add': return ( ); case 'activity': { const activeFriends: TaggedFriend[] = []; const idleFriends: TaggedFriend[] = []; const offlineActivityFriends: TaggedFriend[] = []; for (const f of friends) { if (f.status === 'offline') { offlineActivityFriends.push(f); continue; } const acts = userActivities.get(f.homeUserId ?? f.id) ?? []; const primary = getPrimaryActivity(acts); if (primary && primary.type !== 'custom') { activeFriends.push(f); } else { idleFriends.push(f); } } const renderActivityFriend = (friend: TaggedFriend, isOffline = false) => { const activities = userActivities.get(friend.homeUserId ?? friend.id) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; return ( pushMobileScreen('user-profile', { userId })} onDmClick={handleOpenDm} /> ); }; return (
{activeFriends.length === 0 && idleFriends.length === 0 && offlineActivityFriends.length === 0 ? (
It's quiet for now... When friends start an activity, we'll show it here!
) : ( <> {activeFriends.length > 0 && (

Active — {activeFriends.length}

{activeFriends.map(f => renderActivityFriend(f))}
)} {idleFriends.length > 0 && (

Online — {idleFriends.length}

{idleFriends.map(f => renderActivityFriend(f))}
)} {offlineActivityFriends.length > 0 && (

Offline — {offlineActivityFriends.length}

{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
)} )}
); } } }; const popMobileScreen = useUIStore((s) => s.popMobileScreen); return (
{/* Header */} {mobile ? (
Friends
) : (
Friends
setActiveTab('online')}>Online setActiveTab('all')}>All setActiveTab('pending')}> Pending {(pendingIncoming.length > 0) && ( {pendingIncoming.length} )}
)} {/* Mobile tab bar */} {mobile && (
{(['online', 'all', 'pending', 'add', 'activity'] as Tab[]).map((tab) => ( ))}
)} {renderTabContent()} setPendingUnfriend(null)} onConfirm={async () => { if (pendingUnfriend) { await removeFriend(pendingUnfriend.id); setPendingUnfriend(null); } }} title="Remove Friend" description={`Are you sure you want to remove ${pendingUnfriend?.name ?? 'this user'} as a friend? You can always send them a new friend request later.`} confirmLabel="Remove" variant="danger" />
); } // ─── Add Friend Tab ───────────────────────────────────────────────────────── function AddFriendTab({ onOpenDm, }: { onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void; }) { const searchUsers = useSocialStore((s) => s.searchUsers); const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const friends = useSocialStore((s) => s.friends); const requests = useSocialStore((s) => s.requests); const currentUser = useAuthStore((s) => s.user); const instances = useInstanceStore((s) => s.instances); const addToast = useUIStore((s) => s.addToast); const discoverUsers = useDiscoverStore((s) => s.users); const discoverLoading = useDiscoverStore((s) => s.isLoading); const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const updateRelationship = useDiscoverStore((s) => s.updateRelationship); const [query, setQuery] = useState(() => { // Consume the federation store's pending friend-add prefill at mount so // a Retry click in the Connections panel lands here with the original // target already in the input. The consume call clears the store value // so subsequent mounts (e.g. tab switching) start empty. return useFederationStore.getState().consumePendingFriendAddPrefill() ?? ''; }); const [rawSearchResults, setRawSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); // Fetch discover on mount useEffect(() => { fetchDiscoverUsers(); }, [fetchDiscoverUsers]); // Debounced search with race condition guard useEffect(() => { if (!query.trim()) { setRawSearchResults([]); setSearchLoading(false); return; } let isActive = true; setSearchLoading(true); const timer = setTimeout(async () => { const results = await searchUsers(query.trim()); if (isActive) { setRawSearchResults(results); setSearchLoading(false); } }, 300); return () => { isActive = false; clearTimeout(timer); }; }, [query, searchUsers]); // Self-exclusion set const selfIds = useMemo(() => { const ids = new Set(); if (currentUser?.id) ids.add(`${currentUser.id}:`); for (const inst of instances) { if (inst.user?.id) ids.add(`${inst.user.id}:${inst.origin}`); } return ids; }, [currentUser?.id, instances]); const isSearchMode = query.trim().length > 0; // Enrich search results with friend/request status at render time const enrichedSearchResults: TaggedDiscoverUser[] = useMemo(() => { if (!isSearchMode) return []; return rawSearchResults .filter(u => !selfIds.has(`${u.id}:${u._instanceOrigin}`)) .map(user => { const isFriend = friends.some(f => f.id === user.id && f._instanceOrigin === user._instanceOrigin); if (isFriend) { return { ...user, relationship: 'friends' as const, mutualFriendCount: 0, mutualSpaceCount: 0 }; } const outbound = requests.find(r => r.status === 'pending' && r.user?.id === r.toId && r.user?.id === user.id && r._instanceOrigin === user._instanceOrigin); if (outbound) { return { ...user, relationship: 'outbound_pending' as const, requestId: outbound.id, mutualFriendCount: 0, mutualSpaceCount: 0 }; } const inbound = requests.find(r => r.status === 'pending' && r.user?.id === r.fromId && r.user?.id === user.id && r._instanceOrigin === user._instanceOrigin); if (inbound) { return { ...user, relationship: 'inbound_pending' as const, requestId: inbound.id, mutualFriendCount: 0, mutualSpaceCount: 0 }; } return { ...user, relationship: 'none' as const, mutualFriendCount: 0, mutualSpaceCount: 0 }; }); }, [rawSearchResults, friends, requests, selfIds, isSearchMode]); const trimmedQuery = query.trim(); const directAt = trimmedQuery.lastIndexOf('@'); // Allow bare handle (no @) or @ at non-edge position; hide @, @bob, bob@. const showDirectAdd = trimmedQuery.length > 0 && (directAt === -1 || (directAt > 0 && directAt < trimmedQuery.length - 1)); // Bare handle gets the home host appended for display only — submission // still uses the raw trimmed query. const directAddDisplay = directAt === -1 ? `${trimmedQuery}@${window.location.host}` : trimmedQuery; // Direct Add handler const handleDirectAdd = async () => { setDirectAddLoading(true); try { await sendFriendRequest(query.trim()); addToast('Friend request sent!', 'success'); setQuery(''); } 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'). It also doubles as fallback // text if the code is unrecognized by mapServerErrorToMessage. const code = err instanceof Error ? err.message : undefined; addToast(mapServerErrorToMessage(code, code, query.trim()), 'warning'); } finally { setDirectAddLoading(false); } }; // No-op relationship change for search mode cards (useMemo re-derives from store) const noopRelationshipChange = useCallback(() => {}, []); // Determine which list to display const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers; const displayLoading = isSearchMode ? searchLoading : discoverLoading; const emptyLabel = isSearchMode ? 'No users match your search.' : 'No discoverable users yet — invite people to join!'; return (
{/* Header */}

Find People

Search by username or use user@instance to add someone directly.

{/* Unified search input */}
setQuery(e.target.value)} className="input-search w-full px-4 py-3 rounded-lg" /> {query && ( )}
{/* Direct Add action row */} {showDirectAdd && (
Send friend request to {directAddDisplay}
)}
{/* Results grid */}
{!isSearchMode && (
Discover People
)} {displayLoading && displayUsers.length === 0 ? (
) : displayUsers.length === 0 ? (

{emptyLabel}

) : (
{isSearchMode ? enrichedSearchResults.map((user) => ( )) : discoverUsers.map((user) => ( )) }
)}
); } // ─── User Discover Card ───────────────────────────────────────────────────── function UserDiscoverCard({ user, onOpenDm, onRelationshipChange, }: { user: TaggedDiscoverUser; onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void; onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void; }) { const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); const openModal = useUIStore((s) => s.openModal); const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(''); const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username; const displayName = user.displayName ?? baseName; const gradient = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor); const originLabel = user._instanceOrigin ? (() => { try { return new URL(user._instanceOrigin).host; } catch { return user._instanceOrigin; } })() : null; const avatarUrl = user.avatar ? (user.avatar.startsWith('http') || user.avatar.startsWith('/') ? user.avatar : `/api/uploads/${user.avatar}`) : null; const bannerUrl = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : `/api/uploads/${user.banner}`) : null; const handleSendRequest = async () => { setActionLoading(true); setError(''); const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName; try { const requestId = await sendFriendRequest(username); onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); } catch (err) { // See handleDirectAdd above — err.message is the server error code. const code = err instanceof Error ? err.message : undefined; setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username)); } finally { setActionLoading(false); } }; const handleAccept = async () => { if (!user.requestId) return; setActionLoading(true); setError(''); try { await updateFriendRequest(user.requestId, 'accepted'); onRelationshipChange(user.id, user._instanceOrigin, 'friends'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to accept request'); } finally { setActionLoading(false); } }; const handleDecline = async () => { if (!user.requestId) return; setActionLoading(true); setError(''); try { await updateFriendRequest(user.requestId, 'declined'); onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to decline request'); } finally { setActionLoading(false); } }; const handleCancelRequest = async () => { if (!user.requestId) return; setActionLoading(true); setError(''); try { const origin = user._instanceOrigin; const client = origin ? (useInstanceStore.getState().instances.find(i => i.origin === origin)?.api ?? api) : api; await client.social.cancelRequest(user.requestId); onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to cancel request'); } finally { setActionLoading(false); } }; const handleOpenProfile = () => { openModal('userProfile', { userId: user.id, user, origin: user._instanceOrigin }); }; const handleMessage = () => { onOpenDm(user.id, user.homeUserId ?? undefined, user.homeInstance); }; return (
{/* Banner area */}
{bannerUrl ? ( ) : (
)}
{originLabel && (
{originLabel}
)}
{/* Overlapping avatar */}
{/* Content */}
{user.bio && (

{user.bio}

)} {!user.bio &&
} {/* Mutuals */} {(user.mutualFriendCount > 0 || user.mutualSpaceCount > 0) && (
{user.mutualFriendCount > 0 && ( {user.mutualFriendCount} mutual {user.mutualFriendCount === 1 ? 'friend' : 'friends'} )} {user.mutualFriendCount > 0 && user.mutualSpaceCount > 0 && ( · )} {user.mutualSpaceCount > 0 && ( {user.mutualSpaceCount} mutual {user.mutualSpaceCount === 1 ? 'space' : 'spaces'} )}
)} {user.mutualFriendCount === 0 && user.mutualSpaceCount === 0 &&
} {/* Error */} {error && (
{error}
)} {/* Action button */} {user.relationship === 'none' && ( )} {user.relationship === 'outbound_pending' && ( )} {user.relationship === 'inbound_pending' && (
)} {user.relationship === 'friends' && ( )}
); } // ─── Shared Components ────────────────────────────────────────────────────── function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) { return ( ); } function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) { const canonical = useCanonicalUserView(friend as unknown as User); const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : ''; const { baseName: friendBaseName } = parseFederatedUsername(canonical.username); const friendDisplayName = canonical.displayName ?? friendBaseName; return (
{friendDisplayName} @{friend.username}
{statusLabel[friend.status] ?? friend.status} {instanceLabel && ( via {instanceLabel} )}
); } function RequestItem({ request, type, onAccept, onDecline, onCancel }: { request: TaggedFriendRequest; type: 'incoming' | 'outgoing'; onAccept?: () => void; onDecline?: () => void; onCancel?: () => void; }) { const rawUser = request.user; const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER); const user = rawUser ? canonicalUser : null; if (!user) return null; const instanceLabel = request._instanceOrigin ? (() => { try { return new URL(request._instanceOrigin).host; } catch { return request._instanceOrigin; } })() : ''; const { baseName: reqBaseName } = parseFederatedUsername(user.username); const reqDisplayName = user.displayName ?? reqBaseName; return (
{reqDisplayName} @{user.username}
{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} {instanceLabel && ( via {instanceLabel} )}
{type === 'incoming' ? ( <> ) : ( )}
); }