import { create } from 'zustand'; import type { Friend, FriendRequest, User } from '@backspace/shared'; import { api } from '../api/client'; import { useInstanceStore } from './instanceStore'; // ─── Tagged types (origin tracking for federation) ─────────────────────────── export type TaggedFriend = Friend & { _instanceOrigin: string }; export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string }; // ─── Helpers ───────────────────────────────────────────────────────────────── function getApiForOrigin(origin: string) { if (!origin) return api; const instance = useInstanceStore.getState().instances.find(i => i.origin === origin); return instance?.api ?? api; } // ─── Store ─────────────────────────────────────────────────────────────────── interface SocialState { friends: TaggedFriend[]; requests: TaggedFriendRequest[]; isLoading: boolean; error: string | null; loadFriends: () => Promise; loadRequests: () => Promise; sendFriendRequest: (username: string) => Promise; updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise; cancelFriendRequest: (id: string) => Promise; removeFriend: (id: 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; removeFriendLocally: (userId: string, origin: string) => void; reset: () => void; } export const useSocialStore = create((set, get) => ({ friends: [], requests: [], isLoading: false, error: null, loadFriends: async () => { set({ isLoading: true, error: null }); try { const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); const results = await Promise.allSettled([ api.social.friends().then(friends => ({ friends, origin: '' })), ...connectedInstances.map(inst => inst.api.social.friends().then(friends => ({ friends, origin: inst.origin })) ), ]); const allFriends: TaggedFriend[] = []; const seen = new Set(); for (const result of results) { if (result.status !== 'fulfilled') continue; const { friends, origin } = result.value; for (const friend of friends) { const key = `${friend.id}:${origin}`; if (seen.has(key)) continue; seen.add(key); allFriends.push({ ...friend, _instanceOrigin: origin }); } } set({ friends: allFriends, isLoading: false }); } catch (err) { set({ error: (err as Error).message, isLoading: false }); } }, loadRequests: async () => { set({ isLoading: true, error: null }); try { const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); const results = await Promise.allSettled([ api.social.requests().then(requests => ({ requests, origin: '' })), ...connectedInstances.map(inst => inst.api.social.requests().then(requests => ({ requests, origin: inst.origin })) ), ]); const allRequests: TaggedFriendRequest[] = []; const seen = new Set(); for (const result of results) { if (result.status !== 'fulfilled') continue; const { requests, origin } = result.value; for (const request of requests) { const key = `${request.id}:${origin}`; if (seen.has(key)) continue; seen.add(key); allRequests.push({ ...request, _instanceOrigin: origin }); } } set({ requests: allRequests, isLoading: false }); } catch (err) { set({ error: (err as Error).message, isLoading: false }); } }, sendFriendRequest: async (username: string) => { set({ isLoading: true, error: null }); try { const atIndex = username.lastIndexOf('@'); if (atIndex === -1) { // No @ → local user on home instance await api.social.sendRequest(username); } else { const baseName = username.slice(0, atIndex); const domain = username.slice(atIndex + 1); // Check if domain matches home instance if (domain === window.location.host) { // Strip domain, send to home API await api.social.sendRequest(baseName); } else { // Find a connected instance matching this domain const instances = useInstanceStore.getState().instances; const match = instances.find(inst => { try { return new URL(inst.origin).host === domain; } catch { return false; } }); if (!match) { throw new Error(`Not connected to ${domain}. Add it as an instance in Settings first.`); } if (match.status !== 'connected') { throw new Error(`Instance ${domain} is not currently connected. Check your connection in Settings.`); } // On the remote instance, the user is just "alice", not "alice@orbit" await match.api.social.sendRequest(baseName); } } await get().loadRequests(); } catch (err) { set({ error: (err as Error).message, isLoading: false }); throw err; } }, updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => { set({ isLoading: true, error: null }); try { // Find the request to determine which instance owns it const request = get().requests.find(r => r.id === id); const origin = request?._instanceOrigin ?? ''; const client = getApiForOrigin(origin); await client.social.updateRequest(id, status); await get().loadRequests(); if (status === 'accepted') { await get().loadFriends(); } } catch (err) { set({ error: (err as Error).message, isLoading: false }); throw err; } }, cancelFriendRequest: async (id: string) => { set({ isLoading: true, error: null }); try { const request = get().requests.find(r => r.id === id); const origin = request?._instanceOrigin ?? ''; const client = getApiForOrigin(origin); await client.social.cancelRequest(id); set((state) => ({ requests: state.requests.filter(r => r.id !== id), isLoading: false, })); } catch (err) { set({ error: (err as Error).message, isLoading: false }); throw err; } }, removeFriend: async (id: string) => { set({ isLoading: true, error: null }); try { // Find the friend to determine which instance owns it const friend = get().friends.find(f => f.id === id); const origin = friend?._instanceOrigin ?? ''; const client = getApiForOrigin(origin); await client.social.removeFriend(id); set((state) => ({ friends: state.friends.filter(f => !(f.id === id && f._instanceOrigin === origin)), isLoading: false, })); } catch (err) { set({ error: (err as Error).message, isLoading: false }); throw err; } }, searchUsers: async (query: string) => { try { const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); const results = await Promise.allSettled([ api.social.search(query), ...connectedInstances.map(inst => inst.api.social.search(query)), ]); const allUsers: User[] = []; const seen = new Set(); for (const result of results) { if (result.status !== 'fulfilled') continue; for (const user of result.value) { if (seen.has(user.id)) continue; seen.add(user.id); allUsers.push(user); } } return allUsers; } catch (err) { console.error('Failed to search users:', err); return []; } }, // Called from WS handler when another user sends you a friend request addIncomingRequest: (request: FriendRequest, origin: string) => { set((state) => { const key = `${request.id}:${origin}`; if (state.requests.find(r => `${r.id}:${r._instanceOrigin}` === key)) return state; return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] }; }); }, // Called from WS handler when someone accepts your friend request addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => { set((state) => { const key = `${friend.id}:${origin}`; const alreadyExists = state.friends.some(f => `${f.id}:${f._instanceOrigin}` === key); return { friends: alreadyExists ? state.friends : [...state.friends, { ...friend, _instanceOrigin: origin }], requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)), }; }); }, // Called from WS handler when the other user removes us as a friend removeFriendLocally: (userId: string, origin: string) => { set((state) => ({ friends: state.friends.filter(f => !(f.id === userId && f._instanceOrigin === origin)), })); }, // Called from WS handler on presence_update to keep friend status live updateFriendPresence: (userId: string, status: string) => { set((state) => ({ friends: state.friends.map(f => f.id === userId ? { ...f, status: status as Friend['status'] } : f ), })); }, reset: () => set({ friends: [], requests: [], isLoading: false, error: null }), }));