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
This commit is contained in:
Jannis Braun
2026-04-08 18:41:13 +02:00
parent d58464fe95
commit 213d05a810
3 changed files with 133 additions and 14 deletions
+52 -7
View File
@@ -200,8 +200,10 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
throw new Error("You're already logged into this instance"); throw new Error("You're already logged into this instance");
} }
// Reject duplicates // Reject duplicates — but only if already connected/connecting.
if (get().instances.some(i => i.origin === origin)) { // Allow re-adding instances that are in error/disconnected state.
const existing = get().instances.find(i => i.origin === origin);
if (existing && (existing.status === 'connected' || existing.status === 'connecting')) {
throw new Error('This instance is already connected'); throw new Error('This instance is already connected');
} }
@@ -783,8 +785,51 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
} }
} }
// If logged in as a federated user, include the home instance as a
// connection target. It won't be in replicatedInstances (you don't
// "federate to" your own home), but the client needs it for friends,
// DMs, and profile data.
const instancesToConnect = [...currentUser.replicatedInstances];
if (currentUser.homeInstance) {
const homeOrigin = `https://${currentUser.homeInstance}`;
if (!isSelfOrigin(homeOrigin)) {
// Compute bare username (strip @domain suffix if present)
const bareUsername = currentUser.username.includes('@')
? currentUser.username.split('@')[0]!
: currentUser.username;
const alreadyIncluded = instancesToConnect.some(ri =>
(ri.origin || `https://${ri.domain}`) === homeOrigin
);
if (!alreadyIncluded) {
instancesToConnect.push({
origin: homeOrigin,
username: bareUsername,
domain: currentUser.homeInstance,
});
}
// Ensure the home instance has a registry entry so it appears
// in the Connections UI (the registry is the source of truth for
// the Connections panel, not the instances array).
if (!registry.has(homeOrigin)) {
registry.set(homeOrigin, {
origin: homeOrigin,
label: currentUser.homeInstance,
username: bareUsername,
remoteUserId: currentUser.homeUserId ?? '',
status: 'auth_expired',
addedAt: Date.now(),
lastConnectedAt: null,
disconnectedAt: null,
errorMessage: 'Authenticate to connect to your home instance',
});
}
}
}
// Early return if there's nothing to connect // Early return if there's nothing to connect
if (currentUser.replicatedInstances.length === 0 && registry.size === 0) { if (instancesToConnect.length === 0 && registry.size === 0) {
set({ _autoConnectDone: true }); set({ _autoConnectDone: true });
return; return;
} }
@@ -793,11 +838,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// - withToken: have a cached token and should auto-connect // - withToken: have a cached token and should auto-connect
// - withoutToken: no cached token → add as error placeholder // - withoutToken: no cached token → add as error placeholder
// - userDisconnected: user explicitly disconnected → add as disconnected placeholder (no auto-connect) // - userDisconnected: user explicitly disconnected → add as disconnected placeholder (no auto-connect)
const withToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = []; const withToken: Array<{ origin: string; ri: (typeof instancesToConnect)[0]; entry: CachedInstanceToken }> = [];
const withoutToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0] }> = []; const withoutToken: Array<{ origin: string; ri: (typeof instancesToConnect)[0] }> = [];
const userDisconnected: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = []; const userDisconnected: Array<{ origin: string; ri: (typeof instancesToConnect)[0]; entry: CachedInstanceToken }> = [];
for (const ri of currentUser.replicatedInstances) { for (const ri of instancesToConnect) {
const origin = ri.origin || `https://${ri.domain}`; const origin = ri.origin || `https://${ri.domain}`;
// Never connect to ourselves — home WS is managed separately // Never connect to ourselves — home WS is managed separately
if (isSelfOrigin(origin)) continue; if (isSelfOrigin(origin)) continue;
+63 -7
View File
@@ -36,6 +36,30 @@ function getApiForOrigin(origin: string) {
return instance?.api ?? api; 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 ─────────────────────────────────────────────────────────────────── // ─── Store ───────────────────────────────────────────────────────────────────
interface SocialState { interface SocialState {
@@ -67,8 +91,13 @@ export const useSocialStore = create<SocialState>((set, get) => ({
error: null, error: null,
loadFriends: async () => { loadFriends: async () => {
if (_friendsLoadInFlight) return;
_friendsLoadInFlight = true;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
// Wait for all remote connections to establish before fanning out
await waitForAutoConnect();
const instances = useInstanceStore.getState().instances; const instances = useInstanceStore.getState().instances;
const connectedInstances = instances.filter(i => i.status === 'connected'); const connectedInstances = instances.filter(i => i.status === 'connected');
@@ -80,15 +109,28 @@ export const useSocialStore = create<SocialState>((set, get) => ({
]); ]);
const allFriends: TaggedFriend[] = []; const allFriends: TaggedFriend[] = [];
const seen = new Set<string>(); // 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) { for (const result of results) {
if (result.status !== 'fulfilled') continue; if (result.status !== 'fulfilled') continue;
const { friends, origin } = result.value; const { friends, origin } = result.value;
for (const friend of friends) { for (const friend of friends) {
const key = `${friend.id}:${origin}`; const canonicalId = friend.homeUserId ?? friend.id;
if (seen.has(key)) continue; const isNative = !friend.homeUserId;
seen.add(key); 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); if (origin) normalizeUserAssets(friend, origin);
allFriends.push({ ...friend, _instanceOrigin: origin }); allFriends.push({ ...friend, _instanceOrigin: origin });
} }
@@ -97,12 +139,19 @@ export const useSocialStore = create<SocialState>((set, get) => ({
set({ friends: allFriends, isLoading: false }); set({ friends: allFriends, isLoading: false });
} catch (err) { } catch (err) {
set({ error: (err as Error).message, isLoading: false }); set({ error: (err as Error).message, isLoading: false });
} finally {
_friendsLoadInFlight = false;
} }
}, },
loadRequests: async () => { loadRequests: async () => {
if (_requestsLoadInFlight) return;
_requestsLoadInFlight = true;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
// Wait for all remote connections to establish before fanning out
await waitForAutoConnect();
const instances = useInstanceStore.getState().instances; const instances = useInstanceStore.getState().instances;
const connectedInstances = instances.filter(i => i.status === 'connected'); const connectedInstances = instances.filter(i => i.status === 'connected');
@@ -114,15 +163,20 @@ export const useSocialStore = create<SocialState>((set, get) => ({
]); ]);
const allRequests: TaggedFriendRequest[] = []; 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>(); const seen = new Set<string>();
for (const result of results) { for (const result of results) {
if (result.status !== 'fulfilled') continue; if (result.status !== 'fulfilled') continue;
const { requests, origin } = result.value; const { requests, origin } = result.value;
for (const request of requests) { for (const request of requests) {
const key = `${request.id}:${origin}`; // Use the other party's canonical identity for dedup
if (seen.has(key)) continue; const otherCanonicalId = request.user?.homeUserId ?? request.user?.id;
seen.add(key); if (otherCanonicalId) {
if (seen.has(otherCanonicalId)) continue;
seen.add(otherCanonicalId);
}
if (origin && request.user) normalizeUserAssets(request.user, origin); if (origin && request.user) normalizeUserAssets(request.user, origin);
allRequests.push({ ...request, _instanceOrigin: origin }); allRequests.push({ ...request, _instanceOrigin: origin });
} }
@@ -131,6 +185,8 @@ export const useSocialStore = create<SocialState>((set, get) => ({
set({ requests: allRequests, isLoading: false }); set({ requests: allRequests, isLoading: false });
} catch (err) { } catch (err) {
set({ error: (err as Error).message, isLoading: false }); set({ error: (err as Error).message, isLoading: false });
} finally {
_requestsLoadInFlight = false;
} }
}, },
+18
View File
@@ -26,6 +26,7 @@ export interface FederatedMutuals {
* Load mutual friends and mutual spaces across all connected instances. * Load mutual friends and mutual spaces across all connected instances.
* Follows the same Promise.allSettled fan-out pattern as socialStore.loadFriends(). * Follows the same Promise.allSettled fan-out pattern as socialStore.loadFriends().
* *
* - Waits for auto-connect to finish before fanning out (same guard as socialStore)
* - Deduplicates friends by canonical identity (homeUserId ?? id) * - Deduplicates friends by canonical identity (homeUserId ?? id)
* - Concatenates spaces (spaces on different instances are distinct) * - Concatenates spaces (spaces on different instances are distinct)
* - Normalizes assets for remote-origin results * - Normalizes assets for remote-origin results
@@ -34,6 +35,23 @@ export async function loadFederatedMutuals(
targetUserId: string, targetUserId: string,
targetHomeUserId?: string | null, targetHomeUserId?: string | null,
): Promise<FederatedMutuals> { ): Promise<FederatedMutuals> {
// Wait for all remote connections to establish before fanning out
if (!useInstanceStore.getState()._autoConnectDone) {
await 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();
}
});
}
const instances = useInstanceStore.getState().instances; const instances = useInstanceStore.getState().instances;
const connectedInstances = instances.filter(i => i.status === 'connected'); const connectedInstances = instances.filter(i => i.status === 'connected');