import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; import { useAuthStore } from '../../stores/authStore'; import { ConnectInstanceModal } from '../modals/ConnectInstanceModal'; import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useUIStore } from '../../stores/uiStore'; 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 } from '../../utils/identity'; import { Username } from '../ui/Username'; const statusLabel: Record = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' }; type Tab = 'online' | 'all' | 'pending' | 'add' | 'activity'; interface FriendsPageProps { mobile?: boolean; } export function FriendsPage({ mobile }: FriendsPageProps) { const [activeTab, setActiveTab] = useState('online'); const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); 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 => ( removeFriend(friend.id)} 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 => ( removeFriend(friend.id)} 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 { baseName, domain } = parseFederatedUsername(friend.username); const friendDisplayName = friend.displayName ?? baseName; const activities = userActivities.get(friend.homeUserId ?? friend.id) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; 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) { pushMobileScreen('user-profile', { userId: friend.id }); } else { handleOpenDm(friend.id, friend.homeUserId ?? undefined, friend.homeInstance); } }} className={rowClass} >
{domain && !isOffline && (
@{domain}
)} {!isOffline && ( )}
); }; 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()}
); } // ─── 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(''); const [rawSearchResults, setRawSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); const [connectModal, setConnectModal] = useState<{ domain: string; isReconnect: boolean; username: string; } | null>(null); // 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]); // Direct Add detection (synchronous, not debounced) const atIndex = query.lastIndexOf('@'); const showDirectAdd = atIndex > 0 && atIndex < query.length - 1; // Direct Add handler const handleDirectAdd = async () => { setDirectAddLoading(true); try { await sendFriendRequest(query.trim()); addToast('Friend request sent!', 'success'); setQuery(''); } catch (err) { if (err instanceof InstanceNotConnectedError) { setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() }); } else if (err instanceof InstanceDisconnectedError) { setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() }); } else { addToast((err as Error).message, 'warning'); } } finally { setDirectAddLoading(false); } }; // Connect modal handler const handleConnected = async (result: 'new' | 'reconnect') => { const username = connectModal?.username; const domain = connectModal?.domain; setConnectModal(null); if (!username) return; try { await sendFriendRequest(username); const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to'; addToast(`${verb} ${domain} — friend request sent!`, 'success'); setQuery(''); } catch (err) { addToast((err as Error).message, 'warning'); } }; // 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 {query.trim()}
)}
{/* Results grid */}
{!isSearchMode && (
Discover People
)} {displayLoading && displayUsers.length === 0 ? (
) : displayUsers.length === 0 ? (

{emptyLabel}

) : (
{isSearchMode ? enrichedSearchResults.map((user) => ( )) : discoverUsers.map((user) => ( )) }
)}
{connectModal && ( setConnectModal(null)} /> )}
); } // ─── 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 addToast = useUIStore((s) => s.addToast); const [connectModal, setConnectModal] = useState<{ domain: string; isReconnect: boolean; username: string; } | null>(null); 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) { if (err instanceof InstanceNotConnectedError) { setConnectModal({ domain: err.domain, isReconnect: false, username }); } else if (err instanceof InstanceDisconnectedError) { setConnectModal({ domain: err.domain, isReconnect: true, username }); } else { setError(err instanceof Error ? err.message : 'Failed to send request'); } } finally { setActionLoading(false); } }; const handleDiscoverConnected = async (result: 'new' | 'reconnect') => { const username = connectModal?.username; const domain = connectModal?.domain; setConnectModal(null); if (!username) return; setActionLoading(true); try { const requestId = await sendFriendRequest(username); onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to'; addToast(`${verb} ${domain} — friend request sent!`, 'success'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to send request'); } 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' && ( )}
{connectModal && ( setConnectModal(null)} /> )}
); } // ─── 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 instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : ''; const { baseName: friendBaseName } = parseFederatedUsername(friend.username); const friendDisplayName = friend.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 user = request.user; 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' ? ( <> ) : ( )}
); }