From 72d1fab930d779344bc36f91f7d7665a28f6bafb Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:28:28 +0100 Subject: [PATCH] feat: federate friends/social system across connected instances Rewrites socialStore to aggregate friends and requests from all connected instances using Promise.allSettled. Parses user@domain in friend requests to route to the correct instance. Removes !isHome guards on social WS events so remote friend requests arrive in real-time. Shows "via hostname" labels on remote friends/requests in the UI. --- .../src/components/chat/FriendsPage.test.tsx | 19 +- .../web/src/components/chat/FriendsPage.tsx | 41 ++-- packages/web/src/hooks/useWebSocket.ts | 34 +++- packages/web/src/stores/socialStore.ts | 187 +++++++++++++++--- 4 files changed, 234 insertions(+), 47 deletions(-) diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index f561f151..9203956d 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { FriendsPage } from './FriendsPage'; -import { useSocialStore } from '../../stores/socialStore'; +import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { useServerStore } from '../../stores/serverStore'; import type { Friend, FriendRequest } from '@backspace/shared'; @@ -25,6 +25,15 @@ vi.mock('../../api/client', () => ({ }, })); +// Mock the instanceStore (imported by socialStore) +vi.mock('../../stores/instanceStore', () => ({ + useInstanceStore: { + getState: () => ({ instances: [] }), + setState: vi.fn(), + subscribe: vi.fn(), + }, +})); + const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); @@ -34,7 +43,7 @@ vi.mock('react-router-dom', async () => { }; }); -const makeFriend = (overrides: Partial = {}): Friend => ({ +const makeFriend = (overrides: Partial = {}): TaggedFriend => ({ id: 'friend-1', username: 'testfriend', displayName: 'Test Friend', @@ -43,15 +52,19 @@ const makeFriend = (overrides: Partial = {}): Friend => ({ customStatus: null, createdAt: Date.now(), addedAt: Date.now(), + homeUserId: null, + homeInstance: null, + _instanceOrigin: '', ...overrides, }); -const makeRequest = (overrides: Partial = {}): FriendRequest => ({ +const makeRequest = (overrides: Partial = {}): TaggedFriendRequest => ({ id: 'req-1', fromId: 'other-user', toId: 'current-user', status: 'pending', createdAt: Date.now(), + _instanceOrigin: '', user: { id: 'other-user', username: 'otheruser', diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 5c022071..01e5495f 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useSocialStore } from '../../stores/socialStore'; +import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { useServerStore } from '../../stores/serverStore'; +import { useInstanceStore } from '../../stores/instanceStore'; import { Avatar } from '../ui/Avatar'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import { api } from '../../api/client'; -import type { Friend, FriendRequest } from '@backspace/shared'; type Tab = 'online' | 'all' | 'pending' | 'add'; @@ -51,9 +51,14 @@ export function FriendsPage() { } }; - const handleOpenDm = async (friendId: string) => { + const handleOpenDm = async (friendId: string, instanceOrigin: string) => { try { - const dmChannel = await api.dm.create({ userId: friendId }); + 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); navigate(`/channels/@me/${dmChannel.id}`); } catch (err) { @@ -84,7 +89,7 @@ export function FriendsPage() { ) : ( onlineFriends.map(friend => ( - removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} /> + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} /> )) )} @@ -101,7 +106,7 @@ export function FriendsPage() { ) : ( friends.map(friend => ( - removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} /> + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} /> )) )} @@ -120,7 +125,7 @@ export function FriendsPage() { <> {pendingIncoming.map(req => ( updateFriendRequest(req.id, 'accepted')} @@ -129,7 +134,7 @@ export function FriendsPage() { ))} {pendingOutgoing.map(req => ( cancelFriendRequest(req.id)} @@ -227,7 +232,8 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a ); } -function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) { +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 (
@@ -237,7 +243,12 @@ function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () = {friend.displayName ?? friend.username} @{friend.username}
- {friend.status} +
+ {friend.status} + {instanceLabel && ( + via {instanceLabel} + )} +
@@ -265,7 +276,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () = } function RequestItem({ request, type, onAccept, onDecline, onCancel }: { - request: FriendRequest; + request: TaggedFriendRequest; type: 'incoming' | 'outgoing'; onAccept?: () => void; onDecline?: () => void; @@ -273,6 +284,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { }) { const user = request.user; if (!user) return null; + const instanceLabel = request._instanceOrigin ? (() => { try { return new URL(request._instanceOrigin).host; } catch { return request._instanceOrigin; } })() : ''; return (
@@ -283,7 +295,12 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { {user.displayName ?? user.username} @{user.username}
- {type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} +
+ {type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} + {instanceLabel && ( + via {instanceLabel} + )} +
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 5ac21e38..22d18ddb 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -316,26 +316,23 @@ function handleEvent(origin: string, event: ServerEvent): void { onReactionRemoved(event.messageId, event.userId, event.emoji); break; - // ─── Social events (home-only) ────────────────────────────────────────── + // ─── Social events (all origins — federation) ────────────────────────── case 'friend_request_received': { - if (!isHome) break; const { addIncomingRequest } = useSocialStore.getState(); - addIncomingRequest(event.request); + addIncomingRequest(event.request, origin); break; } case 'friend_request_accepted': { - if (!isHome) break; const { addFriendFromAccepted } = useSocialStore.getState(); - addFriendFromAccepted(event.friend, event.requestId); + addFriendFromAccepted(event.friend, event.requestId, origin); break; } case 'friend_removed': { - if (!isHome) break; const { removeFriendLocally } = useSocialStore.getState(); - removeFriendLocally(event.userId); + removeFriendLocally(event.userId, origin); break; } @@ -481,6 +478,29 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + // ─── Join request events (home-only) ──────────────────────────────── + + case 'join_request_received': { + if (!isHome) break; + console.log('[WebSocket] Join request received from', event.request.user?.username ?? event.request.userId); + break; + } + + case 'join_request_accepted': { + if (!isHome) break; + // Add the server to our server list + const { addServerFromReady } = useServerStore.getState(); + addServerFromReady(origin, event.server); + console.log('[WebSocket] Join request accepted for server', event.server.name); + break; + } + + case 'join_request_declined': { + if (!isHome) break; + console.log('[WebSocket] Join request declined for server', event.request.serverId); + break; + } + case 'pong': break; diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 96b08a22..6344f4eb 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -1,10 +1,26 @@ 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: Friend[]; - requests: FriendRequest[]; + friends: TaggedFriend[]; + requests: TaggedFriendRequest[]; isLoading: boolean; error: string | null; loadFriends: () => Promise; @@ -14,10 +30,10 @@ interface SocialState { cancelFriendRequest: (id: string) => Promise; removeFriend: (id: string) => Promise; searchUsers: (query: string) => Promise; - addIncomingRequest: (request: FriendRequest) => void; - addFriendFromAccepted: (friend: Friend, requestId: string) => void; + addIncomingRequest: (request: FriendRequest, origin: string) => void; + addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void; updateFriendPresence: (userId: string, status: string) => void; - removeFriendLocally: (userId: string) => void; + removeFriendLocally: (userId: string, origin: string) => void; reset: () => void; } @@ -30,8 +46,31 @@ export const useSocialStore = create((set, get) => ({ loadFriends: async () => { set({ isLoading: true, error: null }); try { - const friends = await api.social.friends(); - set({ friends, isLoading: false }); + 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 }); } @@ -40,8 +79,31 @@ export const useSocialStore = create((set, get) => ({ loadRequests: async () => { set({ isLoading: true, error: null }); try { - const requests = await api.social.requests(); - set({ requests, isLoading: false }); + 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 }); } @@ -50,7 +112,43 @@ export const useSocialStore = create((set, get) => ({ sendFriendRequest: async (username: string) => { set({ isLoading: true, error: null }); try { - await api.social.sendRequest(username); + 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 }); @@ -61,7 +159,12 @@ export const useSocialStore = create((set, get) => ({ updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => { set({ isLoading: true, error: null }); try { - await api.social.updateRequest(id, status); + // 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(); @@ -75,7 +178,11 @@ export const useSocialStore = create((set, get) => ({ cancelFriendRequest: async (id: string) => { set({ isLoading: true, error: null }); try { - await api.social.cancelRequest(id); + 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, @@ -89,9 +196,14 @@ export const useSocialStore = create((set, get) => ({ removeFriend: async (id: string) => { set({ isLoading: true, error: null }); try { - await api.social.removeFriend(id); + // 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), + friends: state.friends.filter(f => !(f.id === id && f._instanceOrigin === origin)), isLoading: false, })); } catch (err) { @@ -102,7 +214,27 @@ export const useSocialStore = create((set, get) => ({ searchUsers: async (query: string) => { try { - return await api.social.search(query); + 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 []; @@ -110,25 +242,30 @@ export const useSocialStore = create((set, get) => ({ }, // Called from WS handler when another user sends you a friend request - addIncomingRequest: (request: FriendRequest) => { + addIncomingRequest: (request: FriendRequest, origin: string) => { set((state) => { - if (state.requests.find(r => r.id === request.id)) return state; - return { requests: [...state.requests, request] }; + 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) => { - set((state) => ({ - friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend], - requests: state.requests.filter(r => r.id !== requestId), - })); + 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) => { + removeFriendLocally: (userId: string, origin: string) => { set((state) => ({ - friends: state.friends.filter(f => f.id !== userId), + friends: state.friends.filter(f => !(f.id === userId && f._instanceOrigin === origin)), })); },