Fixes a render bug where a federated user (e.g. axel@nova) appeared with the federation globe icon and a broken avatar when viewed on his own home instance. Root cause: `populateFromReady` is first-wins by federatedId and discards the entire skipped DM payload — including its `members` array — so when a sibling instance's ready arrived first, the home instance's view of every shared user was dropped on the floor. Adds a render-only `userViews` cache that mirrors the `dmAlternatives` philosophy: information from skipped ready payloads is preserved for rendering. Every wire surface that delivers a User upserts into the cache regardless of dedup outcome; render sites read through a Zustand selector hook to surface the home view when one is loaded. The DM channel ingestion race is left untouched — the existing no-flapping invariant on origin reconnect is intentional and load-bearing for failover. Layered changes: - `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`, `isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers for origin/host normalization and the home/stub tier decision. - `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView` action with the home-wins preference rule, prune by `deliveredBy` in `removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset` clears. - `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for React) + `getCanonicalUserView` (sync getter for non-React paths). Render reactivity is structural via the selector, not coincidence on legacy update paths. - `populateFromReady` upsert pass runs BEFORE the federatedId dedup so members of skipped DMs still reach the cache. - WS handlers (dm_message_*, message_*, user_updated, member_joined, friend_request_*, dm_channel_created, dm_member_added) and REST hydrators (socialStore, discoverStore, mutuals) feed the cache with their delivering origin. - Render-site routing through `useCanonicalUserView` at every audited user-rendering site (sidebar, header, search, message bubble, reply chips, profile popout/modal, group settings, voice tiles, mention chips, member lists, friends, invites). Self-rendering sites compose alongside via existing `isSelf`/`resolveDisplayIdentity`. - Globe predicate hoisted to `isFederationGlobeApplicable` and applied at three sites, gating on `domain !== window.location.host` so we never show the globe for users whose home IS our own. Tests: 31 new unit tests across `identity`, `userViews` store, and `userViewLookup`. Full suite 276/276. Docs: `client-federation.md` §3 gains a "User View Cache" section parallel to "DM Origin Failover"; `dm-system.md` notes the new store action and WS handler upserts. Bug 3 (federation profile-sync gap — orbit's stale profile data on nova-Axel after a clear/color-change on nova never propagated) remains open. The user-view cache routes around it for the common case (home instance is connected), but the underlying S2S relay gap is its own diagnosis and follows in a separate branch.
443 lines
18 KiB
TypeScript
443 lines
18 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';
|
|
|
|
// ─── 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;
|
|
addOutboundRequest: (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, userId?: 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();
|
|
|
|
// Lazy import — avoids pulling spaceStore's transitive chain (voiceStore →
|
|
// AudioManager) into test environments that mock only instanceStore.
|
|
const { useSpaceStore } = await import('./spaceStore');
|
|
|
|
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 (homeInstance is null) replace stubs when found.
|
|
// Note: homeUserId alone is NOT a native indicator — the server backfills
|
|
// native users' homeUserId to their own id so federation tier-1 lookups
|
|
// can find them. Only homeInstance distinguishes native from replicated.
|
|
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.homeInstance;
|
|
const existingIdx = seen.get(canonicalId);
|
|
|
|
if (existingIdx !== undefined) {
|
|
// Replace replicated stub with native profile when found
|
|
if (isNative) {
|
|
if (origin) normalizeUserAssets(friend, origin);
|
|
allFriends[existingIdx] = { ...friend, _instanceOrigin: origin };
|
|
// Upsert the upgraded (native) view into the userViews cache.
|
|
// Friend carries all identity/avatar fields the cache needs.
|
|
useSpaceStore.getState().upsertUserView(friend as unknown as User, origin);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
seen.set(canonicalId, allFriends.length);
|
|
if (origin) normalizeUserAssets(friend, origin);
|
|
allFriends.push({ ...friend, _instanceOrigin: origin });
|
|
useSpaceStore.getState().upsertUserView(friend as unknown as User, 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();
|
|
|
|
// Lazy import — same pattern as loadFriends; avoids AudioManager TDZ in tests.
|
|
const { useSpaceStore } = await import('./spaceStore');
|
|
|
|
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.
|
|
// Prefer the record from the instance where the other party is native
|
|
// (homeInstance is null), because that record's ids and _instanceOrigin
|
|
// line up with the discover/search cards and the UserProfileModal —
|
|
// this is what lets buttons like "Request Pending" match correctly.
|
|
// Note: homeUserId alone is NOT a native indicator — see loadFriends.
|
|
const seen = new Map<string, number>();
|
|
|
|
for (const result of results) {
|
|
if (result.status !== 'fulfilled') continue;
|
|
const { requests, origin } = result.value;
|
|
for (const request of requests) {
|
|
const otherCanonicalId = request.user?.homeUserId ?? request.user?.id;
|
|
const otherIsNativeHere = !request.user?.homeInstance;
|
|
const existingIdx = otherCanonicalId ? seen.get(otherCanonicalId) : undefined;
|
|
|
|
if (existingIdx !== undefined) {
|
|
// Replace prior stub-origin record with native one
|
|
if (otherIsNativeHere) {
|
|
if (origin && request.user) normalizeUserAssets(request.user, origin);
|
|
allRequests[existingIdx] = { ...request, _instanceOrigin: origin };
|
|
if (request.user) useSpaceStore.getState().upsertUserView(request.user, origin);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (otherCanonicalId) seen.set(otherCanonicalId, allRequests.length);
|
|
if (origin && request.user) normalizeUserAssets(request.user, origin);
|
|
allRequests.push({ ...request, _instanceOrigin: origin });
|
|
if (request.user) useSpaceStore.getState().upsertUserView(request.user, 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 res = await api.social.sendRequest(username.trim());
|
|
set({ isLoading: false });
|
|
// Server emits friend_request_sent over WS; useWebSocket appends the row
|
|
// optimistically. As a safety net for tabs that race the WS event, refresh
|
|
// from server too.
|
|
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);
|
|
|
|
// Optimistically remove all requests from the same canonical user —
|
|
// the S2S relay will eventually clean up the other instance, but
|
|
// re-fetching immediately would race with relay propagation.
|
|
const canonicalId = request?.user?.homeUserId ?? request?.user?.id;
|
|
set((state) => ({
|
|
requests: canonicalId
|
|
? state.requests.filter(r => (r.user?.homeUserId ?? r.user?.id) !== canonicalId)
|
|
: state.requests.filter(r => r.id !== id),
|
|
isLoading: false,
|
|
}));
|
|
|
|
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);
|
|
const canonicalId = request?.user?.homeUserId ?? request?.user?.id;
|
|
set((state) => ({
|
|
requests: canonicalId
|
|
? state.requests.filter(r => (r.user?.homeUserId ?? r.user?.id) !== canonicalId)
|
|
: 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 {
|
|
// Lazy import — avoids AudioManager TDZ in test environments.
|
|
const { useSpaceStore } = await import('./spaceStore');
|
|
|
|
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 (homeInstance is null) over replicated ones.
|
|
// Note: homeUserId alone is NOT a native indicator — the server
|
|
// backfills native users' homeUserId to their own id so federation
|
|
// tier-1 lookups can find them. Only homeInstance distinguishes
|
|
// native from replicated.
|
|
const canonicalId = user.homeUserId ?? user.id;
|
|
const isNative = !user.homeInstance;
|
|
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 };
|
|
useSpaceStore.getState().upsertUserView(user, origin);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
seen.set(canonicalId, allUsers.length);
|
|
if (origin) normalizeUserAssets(user, origin);
|
|
allUsers.push({ ...user, _instanceOrigin: origin });
|
|
useSpaceStore.getState().upsertUserView(user, 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 canonicalId = request.user?.homeUserId ?? request.user?.id;
|
|
if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) {
|
|
return state;
|
|
}
|
|
return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] };
|
|
});
|
|
},
|
|
|
|
// Called from WS handler for multi-tab sync when this user creates an outbound request
|
|
addOutboundRequest: (request: FriendRequest, origin: string) => {
|
|
set((state) => {
|
|
const canonicalId = request.user?.homeUserId ?? request.user?.id;
|
|
if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) {
|
|
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 canonicalId = friend.homeUserId ?? friend.id;
|
|
const alreadyExists = state.friends.some(f => (f.homeUserId ?? f.id) === canonicalId);
|
|
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.homeUserId !== userId),
|
|
}));
|
|
},
|
|
|
|
// Called from WS handler when a friend request is cancelled or declined
|
|
removeRequestById: (requestId: string, _origin: string, userId?: string) => {
|
|
set((state) => ({
|
|
requests: state.requests.filter(r => {
|
|
if (r.id === requestId) return false;
|
|
// Also match by canonical identity — the WS event may carry a different
|
|
// request ID than the one stored (different instance's copy)
|
|
if (userId) {
|
|
const canonical = r.user?.homeUserId ?? r.user?.id;
|
|
if (canonical === userId || r.user?.id === userId || r.user?.homeUserId === userId) return false;
|
|
}
|
|
return true;
|
|
}),
|
|
}));
|
|
},
|
|
|
|
// 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.homeUserId === 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 }),
|
|
}));
|