import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; 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'; type Tab = 'online' | 'all' | 'pending' | 'add'; interface FriendsPageProps { mobile?: boolean; } export function FriendsPage({ mobile }: FriendsPageProps) { const [activeTab, setActiveTab] = useState('online'); const [addUsername, setAddUsername] = useState(''); const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore(); useEffect(() => { loadFriends(); loadRequests(); }, [loadFriends, loadRequests]); 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 handleAddFriend = async (e: React.FormEvent) => { e.preventDefault(); if (!addUsername.trim()) return; try { await sendFriendRequest(addUsername.trim()); setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` }); setAddUsername(''); } catch (err) { setAddStatus({ type: 'error', message: (err as Error).message }); } }; const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => { 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; } let client = api; if (instanceOrigin) { const instance = useInstanceStore.getState().instances.find(i => i.origin === instanceOrigin); if (instance?.api) client = instance.api; } const dmChannel = await client.dm.create({ userId: friendId }); addDmChannel(dmChannel, instanceOrigin); 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 ? (
(e.target as any).style.display='none'} />

No one's around to play with Wumpus.

) : ( onlineFriends.map(friend => ( removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} /> )) )}
); case 'all': return (

All Friends — {friends.length}

{friends.length === 0 ? (

Wumpus is waiting on friends. You can add them!

) : ( friends.map(friend => ( removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} /> )) )}
); case 'pending': return (

Pending — {pendingIncoming.length + pendingOutgoing.length}

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

There are no pending friend requests. Here's Wumpus for now!

) : ( <> {pendingIncoming.map(req => ( updateFriendRequest(req.id, 'accepted')} onDecline={() => updateFriendRequest(req.id, 'declined')} /> ))} {pendingOutgoing.map(req => ( cancelFriendRequest(req.id)} /> ))} )}
); case 'add': return ( ); } }; 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'] as Tab[]).map((tab) => ( ))}
)} {renderTabContent()}
); } // ─── Add Friend Tab ───────────────────────────────────────────────────────── function AddFriendTab({ addUsername, setAddUsername, addStatus, isLoading, onSubmit, onOpenDm, }: { addUsername: string; setAddUsername: (v: string) => void; addStatus: { type: 'success' | 'error'; message: string } | null; isLoading: boolean; onSubmit: (e: React.FormEvent) => void; onOpenDm: (userId: string, origin: string, homeUserId?: string) => void; }) { const discoverUsers = useDiscoverStore((s) => s.users); const discoverLoading = useDiscoverStore((s) => s.isLoading); const discoverQuery = useDiscoverStore((s) => s.searchQuery); const setDiscoverQuery = useDiscoverStore((s) => s.setSearchQuery); const fetchUsers = useDiscoverStore((s) => s.fetchUsers); const debounceRef = useRef | null>(null); // Fetch discovery on mount useEffect(() => { fetchUsers(); }, [fetchUsers]); // Cleanup debounce timer useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); const handleDiscoverSearch = useCallback((value: string) => { setDiscoverQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { fetchUsers(value || undefined); }, 300); }, [setDiscoverQuery, fetchUsers]); return (

Add Friend

You can add friends with their Backspace username.

setAddUsername(e.target.value)} className="input-search w-full px-4 py-3 rounded-lg" />
{addStatus && (
{addStatus.message}
)}
{/* Discover People section */}
Discover People
{/* Discover search */}
handleDiscoverSearch(e.target.value)} className="input-search w-full" /> {discoverQuery && ( )}
{/* Grid */} {discoverLoading && discoverUsers.length === 0 ? (
) : discoverUsers.length === 0 ? (

{discoverQuery ? 'No users match your search.' : 'No discoverable users yet — invite people to join!'}

) : (
{discoverUsers.map((user) => ( ))}
)}
); } // ─── User Discover Card ───────────────────────────────────────────────────── function UserDiscoverCard({ user, onOpenDm, }: { user: TaggedDiscoverUser; onOpenDm: (userId: string, origin: string, homeUserId?: string) => void; }) { const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); const updateRelationship = useDiscoverStore((s) => s.updateRelationship); const openModal = useUIStore((s) => s.openModal); const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(''); const displayName = user.displayName ?? user.username; const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username; 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(''); try { // For federated users, use user@host format const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName; const requestId = await sendFriendRequest(username); updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId); } 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'); updateRelationship(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'); updateRelationship(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); updateRelationship(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._instanceOrigin, user.homeUserId ?? undefined); }; 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 instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : ''; return (
{friend.displayName ?? friend.username} @{friend.username}
{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; } })() : ''; return (
{user.displayName ?? user.username} @{user.username}
{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} {instanceLabel && ( via {instanceLabel} )}
{type === 'incoming' ? ( <> ) : ( )}
); }