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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user