Files
backspace/packages/web/src/stores/socialStore.ts
T
Jannis Braun 213d05a810 feat: fix cross-instance friends list for federated users
Friends fan-out (loadFriends/loadRequests) now waits for all remote
connections to establish before querying, fixing the empty friends list
when logged into a remote instance as a federated user.

- Add _autoConnectDone wait guard to loadFriends, loadRequests, and
  loadFederatedMutuals (same pattern as discoverStore)
- Add concurrency guards to prevent thundering herd from multiple
  ready events firing simultaneous fan-outs
- Fix deduplication to use canonical identity (homeUserId ?? id)
  instead of id:origin, preventing duplicate entries for the same
  user across instances
- Auto-connect to home instance when logged in as a federated user,
  with registry entry so it appears in Connections UI
- Allow re-adding error/disconnected instances in probeInstance
2026-04-08 18:41:13 +02:00

418 lines
15 KiB
TypeScript

import { create } from 'zustand';
import type { Friend, FriendRequest, User } from '@backspace/shared';
import { api } from '../api/client';
import { useInstanceStore } from './instanceStore';
import { normalizeUserAssets } from '../utils/assetUrls';
// ─── Federation errors ────────────────────────────────────────────────────
/** Thrown when the target domain has never been connected. */
export class InstanceNotConnectedError extends Error {
constructor(public domain: string) {
super(`Not connected to ${domain}`);
this.name = 'InstanceNotConnectedError';
}
}
/** Thrown when the instance entry exists but the session is disconnected/errored. */
export class InstanceDisconnectedError extends Error {
constructor(public domain: string) {
super(`Instance ${domain} is not currently connected`);
this.name = 'InstanceDisconnectedError';
}
}
// ─── Tagged types (origin tracking for federation) ───────────────────────────
export type TaggedFriend = Friend & { _instanceOrigin: string };
export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
export type TaggedUser = User & { _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;
}
// ─── Concurrency guards (module-level, not in store state) ──────────────────
let _friendsLoadInFlight = false;
let _requestsLoadInFlight = false;
// ─── Auto-connect wait (same pattern as discoverStore) ──────────────────────
async function waitForAutoConnect(): Promise<void> {
if (useInstanceStore.getState()._autoConnectDone) return;
return new Promise<void>((resolve) => {
const unsub = useInstanceStore.subscribe((state) => {
if (state._autoConnectDone) {
unsub();
resolve();
}
});
// Double-check (race condition guard)
if (useInstanceStore.getState()._autoConnectDone) {
unsub();
resolve();
}
});
}
// ─── Store ───────────────────────────────────────────────────────────────────
interface SocialState {
friends: TaggedFriend[];
requests: TaggedFriendRequest[];
isLoading: boolean;
error: string | null;
loadFriends: () => Promise<void>;
loadRequests: () => Promise<void>;
sendFriendRequest: (username: string) => Promise<string | undefined>;
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
cancelFriendRequest: (id: string) => Promise<void>;
removeFriend: (id: string) => Promise<void>;
searchUsers: (query: string) => Promise<TaggedUser[]>;
addIncomingRequest: (request: FriendRequest, origin: string) => void;
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
updateFriendPresence: (userId: string, status: string) => void;
updateFriendProfile: (user: User) => void;
removeFriendLocally: (userId: string, origin: string) => void;
removeRequestById: (requestId: string, origin: string) => void;
removeRequestsForUser: (userId: string) => void;
reset: () => void;
}
export const useSocialStore = create<SocialState>((set, get) => ({
friends: [],
requests: [],
isLoading: false,
error: null,
loadFriends: async () => {
if (_friendsLoadInFlight) return;
_friendsLoadInFlight = true;
set({ isLoading: true, error: null });
try {
// Wait for all remote connections to establish before fanning out
await waitForAutoConnect();
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[] = [];
// Deduplicate by canonical identity — a user who exists on multiple
// instances (native + replicated stub) should appear once.
// Native profiles (homeUserId is null) replace stubs when found.
const seen = new Map<string, number>(); // canonicalId → index in allFriends
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { friends, origin } = result.value;
for (const friend of friends) {
const canonicalId = friend.homeUserId ?? friend.id;
const isNative = !friend.homeUserId;
const existingIdx = seen.get(canonicalId);
if (existingIdx !== undefined) {
// Replace replicated stub with native profile when found
if (isNative) {
allFriends[existingIdx] = { ...friend, _instanceOrigin: origin };
}
continue;
}
seen.set(canonicalId, allFriends.length);
if (origin) normalizeUserAssets(friend, origin);
allFriends.push({ ...friend, _instanceOrigin: origin });
}
}
set({ friends: allFriends, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
} finally {
_friendsLoadInFlight = false;
}
},
loadRequests: async () => {
if (_requestsLoadInFlight) return;
_requestsLoadInFlight = true;
set({ isLoading: true, error: null });
try {
// Wait for all remote connections to establish before fanning out
await waitForAutoConnect();
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[] = [];
// Deduplicate by the canonical identity of the other party —
// there can only be one pending request between any two users.
const seen = new Set<string>();
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { requests, origin } = result.value;
for (const request of requests) {
// Use the other party's canonical identity for dedup
const otherCanonicalId = request.user?.homeUserId ?? request.user?.id;
if (otherCanonicalId) {
if (seen.has(otherCanonicalId)) continue;
seen.add(otherCanonicalId);
}
if (origin && request.user) normalizeUserAssets(request.user, origin);
allRequests.push({ ...request, _instanceOrigin: origin });
}
}
set({ requests: allRequests, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
} finally {
_requestsLoadInFlight = false;
}
},
sendFriendRequest: async (username: string) => {
set({ isLoading: true, error: null });
try {
const atIndex = username.lastIndexOf('@');
let res: { success: boolean; requestId?: string };
if (atIndex === -1) {
// No @ → local user on home instance
res = 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
res = 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 InstanceNotConnectedError(domain);
}
if (match.status !== 'connected') {
throw new InstanceDisconnectedError(domain);
}
// On the remote instance, the user is just "alice", not "alice@orbit"
res = await match.api.social.sendRequest(baseName);
}
}
await get().loadRequests();
return res.requestId;
} 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');
// Pair each promise with its origin for asset normalization
const searches: { promise: Promise<User[]>; origin: string }[] = [
{ promise: api.social.search(query), origin: '' },
...connectedInstances.map(inst => ({
promise: inst.api.social.search(query),
origin: inst.origin,
})),
];
const results = await Promise.allSettled(searches.map(s => s.promise));
const allUsers: TaggedUser[] = [];
// Map canonical ID → index in allUsers for dedup with replacement
const seen = new Map<string, number>();
results.forEach((result, i) => {
if (result.status !== 'fulfilled') return;
const origin = searches[i]!.origin;
for (const user of result.value) {
// Deduplicate by canonical identity: replicated profiles share
// the same homeUserId as the native profile's id, so collapse them.
// Prefer native profiles (homeUserId is null) over replicated ones.
const canonicalId = user.homeUserId ?? user.id;
const isNative = !user.homeUserId;
const existingIdx = seen.get(canonicalId);
if (existingIdx !== undefined) {
// Replace replicated with native when found
if (isNative) {
if (origin) normalizeUserAssets(user, origin);
allUsers[existingIdx] = { ...user, _instanceOrigin: origin };
}
continue;
}
seen.set(canonicalId, allUsers.length);
if (origin) normalizeUserAssets(user, origin);
allUsers.push({ ...user, _instanceOrigin: origin });
}
});
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 when a friend request is cancelled or declined
removeRequestById: (requestId: string, origin: string) => {
set((state) => ({
requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)),
}));
},
// Called when a user is deleted — remove all pending requests involving them
removeRequestsForUser: (userId: string) => {
set((state) => ({
requests: state.requests.filter(r => r.fromId !== userId && r.toId !== userId),
}));
},
// 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
),
}));
},
// Called from WS handler on user_updated to keep friend profile data live
updateFriendProfile: (user: User) => {
set((state) => ({
friends: state.friends.map(f =>
f.id === user.id
? { ...f, displayName: user.displayName, avatar: user.avatar,
banner: user.banner, accentColor: user.accentColor,
avatarColor: user.avatarColor, bio: user.bio,
customStatus: user.customStatus, status: user.status }
: f
),
}));
},
reset: () => set({ friends: [], requests: [], isLoading: false, error: null }),
}));