diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 3b7c2347..799c4dee 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -200,8 +200,10 @@ export const useInstanceStore = create((set, get) => ({ throw new Error("You're already logged into this instance"); } - // Reject duplicates - if (get().instances.some(i => i.origin === origin)) { + // Reject duplicates — but only if already connected/connecting. + // 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'); } @@ -783,8 +785,51 @@ export const useInstanceStore = create((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 - if (currentUser.replicatedInstances.length === 0 && registry.size === 0) { + if (instancesToConnect.length === 0 && registry.size === 0) { set({ _autoConnectDone: true }); return; } @@ -793,11 +838,11 @@ export const useInstanceStore = create((set, get) => ({ // - withToken: have a cached token and should auto-connect // - withoutToken: no cached token → add as error placeholder // - userDisconnected: user explicitly disconnected → add as disconnected placeholder (no auto-connect) - const withToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = []; - const withoutToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0] }> = []; - const userDisconnected: 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 instancesToConnect)[0] }> = []; + 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}`; // Never connect to ourselves — home WS is managed separately if (isSelfOrigin(origin)) continue; diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index fcdd33e9..0110352b 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -36,6 +36,30 @@ function getApiForOrigin(origin: string) { 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 { + if (useInstanceStore.getState()._autoConnectDone) return; + return new Promise((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 { @@ -67,8 +91,13 @@ export const useSocialStore = create((set, get) => ({ 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'); @@ -80,15 +109,28 @@ export const useSocialStore = create((set, get) => ({ ]); const allFriends: TaggedFriend[] = []; - const seen = new Set(); + // 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(); // 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 key = `${friend.id}:${origin}`; - if (seen.has(key)) continue; - seen.add(key); + 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 }); } @@ -97,12 +139,19 @@ export const useSocialStore = create((set, get) => ({ 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'); @@ -114,15 +163,20 @@ export const useSocialStore = create((set, get) => ({ ]); 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(); 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); + // 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 }); } @@ -131,6 +185,8 @@ export const useSocialStore = create((set, get) => ({ set({ requests: allRequests, isLoading: false }); } catch (err) { set({ error: (err as Error).message, isLoading: false }); + } finally { + _requestsLoadInFlight = false; } }, diff --git a/packages/web/src/utils/mutuals.ts b/packages/web/src/utils/mutuals.ts index 7da638b3..ed1c751d 100644 --- a/packages/web/src/utils/mutuals.ts +++ b/packages/web/src/utils/mutuals.ts @@ -26,6 +26,7 @@ export interface FederatedMutuals { * Load mutual friends and mutual spaces across all connected instances. * 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) * - Concatenates spaces (spaces on different instances are distinct) * - Normalizes assets for remote-origin results @@ -34,6 +35,23 @@ export async function loadFederatedMutuals( targetUserId: string, targetHomeUserId?: string | null, ): Promise { + // Wait for all remote connections to establish before fanning out + if (!useInstanceStore.getState()._autoConnectDone) { + await new Promise((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 connectedInstances = instances.filter(i => i.status === 'connected');