From 4e170f0b692f06e2806dbbc654b1f0788e12cba8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:54:36 +0100 Subject: [PATCH 1/4] feat(social): add TaggedUser type and origin-tag searchUsers results --- packages/web/src/stores/socialStore.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 96f3f2e4..cca77eaf 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -26,6 +26,7 @@ export class InstanceDisconnectedError extends Error { export type TaggedFriend = Friend & { _instanceOrigin: string }; export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string }; +export type TaggedUser = User & { _instanceOrigin: string }; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -48,7 +49,7 @@ interface SocialState { updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise; cancelFriendRequest: (id: string) => Promise; removeFriend: (id: string) => Promise; - searchUsers: (query: string) => Promise; + searchUsers: (query: string) => Promise; addIncomingRequest: (request: FriendRequest, origin: string) => void; addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void; updateFriendPresence: (userId: string, status: string) => void; @@ -253,7 +254,7 @@ export const useSocialStore = create((set, get) => ({ const results = await Promise.allSettled(searches.map(s => s.promise)); - const allUsers: User[] = []; + const allUsers: TaggedUser[] = []; const seen = new Set(); results.forEach((result, i) => { @@ -264,7 +265,7 @@ export const useSocialStore = create((set, get) => ({ if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); if (origin) normalizeUserAssets(user, origin); - allUsers.push(user); + allUsers.push({ ...user, _instanceOrigin: origin }); } }); From 970af77169830f9c09c4e022de75df2cd9d34f00 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:56:25 +0100 Subject: [PATCH 2/4] refactor(friends): extract onRelationshipChange prop from UserDiscoverCard --- packages/web/src/components/chat/FriendsPage.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 7b2f45c1..0d834f69 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -432,6 +432,7 @@ function AddFriendTab({ const discoverQuery = useDiscoverStore((s) => s.searchQuery); const setDiscoverQuery = useDiscoverStore((s) => s.setSearchQuery); const fetchUsers = useDiscoverStore((s) => s.fetchUsers); + const updateRelationship = useDiscoverStore((s) => s.updateRelationship); const debounceRef = useRef | null>(null); @@ -536,6 +537,7 @@ function AddFriendTab({ key={`${user.id}:${user._instanceOrigin}`} user={user} onOpenDm={onOpenDm} + onRelationshipChange={updateRelationship} /> ))} @@ -550,13 +552,14 @@ function AddFriendTab({ function UserDiscoverCard({ user, onOpenDm, + onRelationshipChange, }: { user: TaggedDiscoverUser; onOpenDm: (userId: string, origin: string, homeUserId?: string) => 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 updateRelationship = useDiscoverStore((s) => s.updateRelationship); const openModal = useUIStore((s) => s.openModal); const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(''); @@ -587,7 +590,7 @@ function UserDiscoverCard({ const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName; try { const requestId = await sendFriendRequest(username); - updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId); + onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); } catch (err) { if (err instanceof InstanceNotConnectedError) { setConnectModal({ domain: err.domain, isReconnect: false, username }); @@ -610,7 +613,7 @@ function UserDiscoverCard({ setActionLoading(true); try { const requestId = await sendFriendRequest(username); - updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId); + 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) { @@ -626,7 +629,7 @@ function UserDiscoverCard({ setError(''); try { await updateFriendRequest(user.requestId, 'accepted'); - updateRelationship(user.id, user._instanceOrigin, 'friends'); + onRelationshipChange(user.id, user._instanceOrigin, 'friends'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to accept request'); } finally { @@ -640,7 +643,7 @@ function UserDiscoverCard({ setError(''); try { await updateFriendRequest(user.requestId, 'declined'); - updateRelationship(user.id, user._instanceOrigin, 'none'); + onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to decline request'); } finally { @@ -658,7 +661,7 @@ function UserDiscoverCard({ ? (useInstanceStore.getState().instances.find(i => i.origin === origin)?.api ?? api) : api; await client.social.cancelRequest(user.requestId); - updateRelationship(user.id, user._instanceOrigin, 'none'); + onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to cancel request'); } finally { From a9266a3ad872a71f9abedf626720a2fd47f44f89 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:01:44 +0100 Subject: [PATCH 3/4] feat(friends): rewrite AddFriendTab with unified search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two separate inputs (direct add + discover search) with a single unified search field. When the user types, results come from socialStore.searchUsers() across all connected instances. When empty, the discover grid is shown as before. A user@instance pattern surfaces a Direct Add action row for sending friend requests to federated users. Parent FriendsPage no longer owns add-friend state — it all lives inside AddFriendTab now, keeping the component self-contained. --- .../web/src/components/chat/FriendsPage.tsx | 343 ++++++++++-------- 1 file changed, 199 insertions(+), 144 deletions(-) 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)} + /> + )}
); } From 277b69a7326fa254f03892fe7325d185a76a1bf4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:06:01 +0100 Subject: [PATCH 4/4] test(friends): update tests for unified friend search UI Replace old "Add Friend" form tests (placeholder/button/inline messages) with new tests for the Find People panel: search input, Direct Add row, toast feedback, and searchUsers debounce. Also fix ancillary mocks (discoverStore, authStore, activityStore, ConnectInstanceModal, findExistingDmForUser) required by the rewritten component. --- .../src/components/chat/FriendsPage.test.tsx | 154 ++++++++++++++---- 1 file changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 9a7c1e9a..422129d6 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom'; import { FriendsPage } from './FriendsPage'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { useSpaceStore } from '../../stores/spaceStore'; +import { useUIStore } from '../../stores/uiStore'; import type { Friend, FriendRequest } from '@backspace/shared'; // Mock the mascot animation hook @@ -36,17 +37,80 @@ vi.mock('../../api/client', () => ({ cancelRequest: vi.fn().mockResolvedValue({ success: true }), removeFriend: vi.fn().mockResolvedValue({ success: true }), search: vi.fn().mockResolvedValue([]), + discover: vi.fn().mockResolvedValue({ users: [], total: 0 }), }, }, })); // Mock the instanceStore (imported by socialStore) vi.mock('../../stores/instanceStore', () => ({ - useInstanceStore: { - getState: () => ({ instances: [] }), - setState: vi.fn(), - subscribe: vi.fn(), - }, + useInstanceStore: Object.assign( + (selector: (s: any) => any) => selector({ + instances: [], + _autoConnectDone: true, + }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +vi.mock('../../stores/discoverStore', () => ({ + useDiscoverStore: Object.assign( + (selector: (s: any) => any) => selector({ + users: [], + isLoading: false, + searchQuery: '', + setSearchQuery: vi.fn(), + fetchUsers: vi.fn(), + updateRelationship: vi.fn(), + }), + { + getState: () => ({ + users: [], + isLoading: false, + searchQuery: '', + fetchUsers: vi.fn(), + updateRelationship: vi.fn(), + }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +vi.mock('../../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: any) => any) => selector({ + user: { id: 'current-user' }, + }), + { + getState: () => ({ user: { id: 'current-user' } }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Mock activityStore +vi.mock('../../stores/activityStore', () => ({ + useActivityStore: Object.assign( + (selector: (s: any) => any) => selector({ + userActivities: new Map(), + }), + { + getState: () => ({ userActivities: new Map(), reset: vi.fn() }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Mock ConnectInstanceModal +vi.mock('../modals/ConnectInstanceModal', () => ({ + ConnectInstanceModal: () => null, })); const mockNavigate = vi.fn(); @@ -122,72 +186,103 @@ beforeEach(() => { error: null, loadFriends: vi.fn(), loadRequests: vi.fn(), + searchUsers: vi.fn().mockResolvedValue([]), }); useSpaceStore.setState({ dmChannels: [], + findExistingDmForUser: () => null, }); }); describe('FriendsPage', () => { describe('Add Friend tab', () => { - it('renders the Add Friend form when tab is clicked', async () => { + it('renders the search input when Add Friend tab is clicked', async () => { const user = userEvent.setup(); renderFriendsPage(); const addFriendTab = screen.getByText('Add Friend'); await user.click(addFriendTab); - expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument(); - expect(screen.getByText('Send Friend Request')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Search or add by username/)).toBeInTheDocument(); + expect(screen.getByText('Find People')).toBeInTheDocument(); }); - it('calls sendFriendRequest with the username when form is submitted', async () => { + it('shows Direct Add row and sends request for user@domain input', async () => { const user = userEvent.setup(); - const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined); + const mockSendFriendRequest = vi.fn().mockResolvedValue('req-123'); useSocialStore.setState({ sendFriendRequest: mockSendFriendRequest, }); renderFriendsPage(); - - // Switch to Add Friend tab await user.click(screen.getByText('Add Friend')); - // Type username - const input = screen.getByPlaceholderText('You can add a friend with their username'); - await user.type(input, 'newbuddy'); + const input = screen.getByPlaceholderText(/Search or add by username/); + await user.type(input, 'newbuddy@remote.example.com'); - // Click send - await user.click(screen.getByText('Send Friend Request')); + // Direct Add row should appear + expect(screen.getByText(/Send friend request to/)).toBeInTheDocument(); + + // Click Send Request + await user.click(screen.getByText('Send Request')); await waitFor(() => { - expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy'); - }); - - // Should show success message - await waitFor(() => { - expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument(); + expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy@remote.example.com'); }); }); - it('shows error when sendFriendRequest fails', async () => { + it('shows toast when Direct Add request fails', async () => { const user = userEvent.setup(); const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found')); + const mockAddToast = vi.fn(); useSocialStore.setState({ sendFriendRequest: mockSendFriendRequest, }); + useUIStore.setState({ + addToast: mockAddToast, + }); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); - const input = screen.getByPlaceholderText('You can add a friend with their username'); - await user.type(input, 'ghost'); - await user.click(screen.getByText('Send Friend Request')); + const input = screen.getByPlaceholderText(/Search or add by username/); + await user.type(input, 'ghost@remote.example.com'); + await user.click(screen.getByText('Send Request')); await waitFor(() => { - expect(screen.getByText('User not found')).toBeInTheDocument(); + expect(mockAddToast).toHaveBeenCalledWith('User not found', 'warning'); }); }); + + it('does not show Direct Add row for plain usernames', async () => { + const user = userEvent.setup(); + renderFriendsPage(); + await user.click(screen.getByText('Add Friend')); + + const input = screen.getByPlaceholderText(/Search or add by username/); + await user.type(input, 'marc'); + + expect(screen.queryByText(/Send friend request to/)).not.toBeInTheDocument(); + }); + + it('calls searchUsers when typing a non-@ query', async () => { + const user = userEvent.setup(); + const mockSearchUsers = vi.fn().mockResolvedValue([]); + useSocialStore.setState({ + searchUsers: mockSearchUsers, + }); + + renderFriendsPage(); + await user.click(screen.getByText('Add Friend')); + + const input = screen.getByPlaceholderText(/Search or add by username/); + await user.type(input, 'marc'); + + // Wait for debounce + await waitFor(() => { + expect(mockSearchUsers).toHaveBeenCalledWith('marc'); + }, { timeout: 500 }); + }); }); describe('DM button on friend item', () => { @@ -202,6 +297,7 @@ describe('FriendsPage', () => { }); useSpaceStore.setState({ addDmChannel: mockAddDmChannel, + findExistingDmForUser: () => null, }); // Mock the dm.create API @@ -226,7 +322,7 @@ describe('FriendsPage', () => { }); await waitFor(() => { - expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' })); + expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' }), ''); }); await waitFor(() => {