diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 0d834f69..fae13ec5 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; +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'; @@ -26,14 +27,6 @@ interface FriendsPageProps { 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 addToast = useUIStore((s) => s.addToast); - const [connectModal, setConnectModal] = useState<{ - domain: string; - isReconnect: boolean; - username: string; - } | null>(null); const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); @@ -43,7 +36,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) { isLoading, loadFriends, loadRequests, - sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend @@ -61,41 +53,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) { 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) { - if (err instanceof InstanceNotConnectedError) { - setConnectModal({ domain: err.domain, isReconnect: false, username: addUsername.trim() }); - } else if (err instanceof InstanceDisconnectedError) { - setConnectModal({ domain: err.domain, isReconnect: true, username: addUsername.trim() }); - } else { - setAddStatus({ type: 'error', message: (err as Error).message }); - } - } - }; - - const handleAddFriendConnected = 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'; - setAddStatus({ type: 'success', message: `${verb} ${domain} — friend request sent!` }); - setAddUsername(''); - } catch (err) { - addToast((err as Error).message, 'warning'); - } - }; - const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => { try { // Check if a DM already exists with this user (on any instance) @@ -201,11 +158,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) { case 'add': return ( ); @@ -396,16 +348,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) { )} {renderTabContent()} - - {connectModal && ( - setConnectModal(null)} - /> - )} ); } @@ -413,98 +355,165 @@ export function FriendsPage({ mobile }: FriendsPageProps) { // ─── 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 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 discoverQuery = useDiscoverStore((s) => s.searchQuery); - const setDiscoverQuery = useDiscoverStore((s) => s.setSearchQuery); - const fetchUsers = useDiscoverStore((s) => s.fetchUsers); + const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const updateRelationship = useDiscoverStore((s) => s.updateRelationship); - const debounceRef = useRef | null>(null); + 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 discovery on mount + // Fetch discover on mount useEffect(() => { - fetchUsers(); - }, [fetchUsers]); + fetchDiscoverUsers(); + }, [fetchDiscoverUsers]); - // Cleanup debounce timer + // Debounced search with race condition guard 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); + 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); - }, [setDiscoverQuery, fetchUsers]); + 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 (
-

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 */} -
-
- - + {/* Header */} +
+ + - Discover People +

Find People

+

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

- {/* Discover search */} + {/* Unified search input */}
handleDiscoverSearch(e.target.value)} - className="input-search w-full" + placeholder="Search or add by username..." + value={query} + onChange={(e) => setQuery(e.target.value)} + className="input-search w-full px-4 py-3 rounded-lg" /> - {discoverQuery && ( + {query && (
- {/* Grid */} - {discoverLoading && discoverUsers.length === 0 ? ( + {/* Direct Add action row */} + {showDirectAdd && ( +
+ + + +
+ Send friend request to {query.trim()} +
+ +
+ )} +
+ + {/* Results grid */} +
+ {!isSearchMode && ( +
+ + + + Discover People +
+ )} + + {displayLoading && displayUsers.length === 0 ? (
- ) : discoverUsers.length === 0 ? ( + ) : displayUsers.length === 0 ? (
-

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

+

{emptyLabel}

) : (
- {discoverUsers.map((user) => ( - - ))} + {isSearchMode + ? enrichedSearchResults.map((user) => ( + + )) + : discoverUsers.map((user) => ( + + )) + }
)}
+ + {connectModal && ( + setConnectModal(null)} + /> + )}
); }