feat(client-federation): user-view cache for cross-instance DM render
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.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
normalizeOriginToHost,
|
||||
canonicalUserKey,
|
||||
isDeliveryFromHome,
|
||||
isFederationGlobeApplicable,
|
||||
} from './identity';
|
||||
|
||||
describe('normalizeOriginToHost', () => {
|
||||
it('returns empty for falsy inputs', () => {
|
||||
expect(normalizeOriginToHost('')).toBe('');
|
||||
expect(normalizeOriginToHost(null)).toBe('');
|
||||
expect(normalizeOriginToHost(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('extracts host from full URLs', () => {
|
||||
expect(normalizeOriginToHost('https://nova.ddns.net')).toBe('nova.ddns.net');
|
||||
expect(normalizeOriginToHost('http://localhost:3000')).toBe('localhost:3000');
|
||||
expect(normalizeOriginToHost('https://orbit.example.com:8443/path')).toBe('orbit.example.com:8443');
|
||||
});
|
||||
|
||||
it('returns bare-domain inputs unchanged', () => {
|
||||
expect(normalizeOriginToHost('nova.ddns.net')).toBe('nova.ddns.net');
|
||||
expect(normalizeOriginToHost('localhost:3000')).toBe('localhost:3000');
|
||||
});
|
||||
|
||||
it('returns empty string for malformed URL inputs (defensive)', () => {
|
||||
expect(normalizeOriginToHost('https://')).toBe('');
|
||||
expect(normalizeOriginToHost('://broken')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalUserKey', () => {
|
||||
it('keys purely-local users by id only (empty host segment)', () => {
|
||||
expect(canonicalUserKey({ id: '123' })).toBe(':123');
|
||||
expect(canonicalUserKey({ id: '123', homeUserId: null, homeInstance: null })).toBe(':123');
|
||||
});
|
||||
|
||||
it('keys federated users by their home host + homeUserId', () => {
|
||||
expect(canonicalUserKey({
|
||||
id: '999', // local id on the receiving instance (irrelevant)
|
||||
homeUserId: '291641217365663744',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
})).toBe('nova.ddns.net:291641217365663744');
|
||||
});
|
||||
|
||||
it('produces the same key for stubs of the same person across instances', () => {
|
||||
const fromOrbit = canonicalUserKey({
|
||||
id: 'orbitLocalId',
|
||||
homeUserId: 'nova-axel',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
const fromAnotherPeer = canonicalUserKey({
|
||||
id: 'otherPeerLocalId',
|
||||
homeUserId: 'nova-axel',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
expect(fromOrbit).toBe(fromAnotherPeer);
|
||||
});
|
||||
|
||||
it('falls back to local id when homeInstance is set but homeUserId is missing', () => {
|
||||
expect(canonicalUserKey({
|
||||
id: 'localId',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
homeUserId: null,
|
||||
})).toBe('nova.ddns.net:localId');
|
||||
});
|
||||
|
||||
it('local users do not collide with federated keys', () => {
|
||||
const local = canonicalUserKey({ id: '291641217365663744' });
|
||||
const federated = canonicalUserKey({
|
||||
id: '999',
|
||||
homeUserId: '291641217365663744',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
expect(local).not.toBe(federated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeliveryFromHome', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { host: 'nova.ddns.net' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats native users delivered by our home connection as home view', () => {
|
||||
expect(isDeliveryFromHome({ homeInstance: null }, '')).toBe(true);
|
||||
expect(isDeliveryFromHome({ homeInstance: undefined }, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats native users delivered by a remote connection as home view of that remote', () => {
|
||||
expect(isDeliveryFromHome({ homeInstance: null }, 'https://orbit.ddns.net')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks federated user as home view when delivering origin is their home', () => {
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'nova.ddns.net' },
|
||||
'https://nova.ddns.net',
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks federated user as home view when our home connection (origin "") IS their home', () => {
|
||||
// We are at nova; user.homeInstance is nova; delivery from origin '' means our home.
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'nova.ddns.net' },
|
||||
'',
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects sibling-stub deliveries (orbit delivering Axel whose home is nova)', () => {
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'nova.ddns.net' },
|
||||
'https://orbit.ddns.net',
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects our-home delivery of a user whose home is a different instance', () => {
|
||||
// We are at nova; user.homeInstance is orbit; delivery from '' (our home).
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'orbit.ddns.net' },
|
||||
'',
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles bare-domain homeInstance against full-URL delivering origin', () => {
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'orbit.ddns.net' },
|
||||
'https://orbit.ddns.net',
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFederationGlobeApplicable', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { host: 'nova.ddns.net' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false for purely-local users (no @domain in username)', () => {
|
||||
expect(isFederationGlobeApplicable({ username: 'axel' })).toBe(false);
|
||||
expect(isFederationGlobeApplicable({ username: 'youruser' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the username domain matches our own host (the load-bearing case)', () => {
|
||||
// Logged in to nova; viewing orbit-stub of Axel whose username is "axel@nova.ddns.net".
|
||||
expect(isFederationGlobeApplicable({ username: 'axel@nova.ddns.net' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for genuinely remote users', () => {
|
||||
expect(isFederationGlobeApplicable({ username: 'jannis@orbit.ddns.net' })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,114 @@ export function resolveDisplayIdentity(user: User, homeUser: User | null): User
|
||||
return user;
|
||||
}
|
||||
|
||||
// ─── Cross-instance origin / canonical-identity helpers ─────────────────────
|
||||
// Used by the userViews cache (spaceStore) and any code that needs to compare
|
||||
// a user's home instance against a delivering connection's origin. Bare-domain
|
||||
// `users.home_instance` and full-URL connection origins must always agree
|
||||
// through these helpers — never via ad-hoc string comparisons.
|
||||
|
||||
/**
|
||||
* Extract the bare host from a delivering-origin string.
|
||||
*
|
||||
* '' → '' (the empty-origin sentinel for "home connection")
|
||||
* null / undefined → ''
|
||||
* 'https://nova.ddns.net' → 'nova.ddns.net'
|
||||
* 'http://localhost:3000' → 'localhost:3000'
|
||||
* 'nova.ddns.net' → 'nova.ddns.net'
|
||||
*
|
||||
* Empty inputs return `''`. Use {@link deliveringHost} when you need the
|
||||
* concrete host that an origin represents (which substitutes
|
||||
* `window.location.host` for the empty sentinel).
|
||||
*/
|
||||
export function normalizeOriginToHost(input: string | null | undefined): string {
|
||||
if (!input) return '';
|
||||
if (input.includes('://')) {
|
||||
try {
|
||||
return new URL(input).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a delivering origin to its concrete host. Substitutes
|
||||
* `window.location.host` for the empty-origin sentinel (`''` = our home
|
||||
* connection). All other inputs are normalized via {@link normalizeOriginToHost}.
|
||||
*/
|
||||
function deliveringHost(origin: string): string {
|
||||
if (origin === '') return typeof window === 'undefined' ? '' : window.location.host;
|
||||
return normalizeOriginToHost(origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cross-instance cache key for a user.
|
||||
*
|
||||
* Federated user: `<homeInstanceHost>:<homeUserId>` — same key for the same
|
||||
* person regardless of which instance's local stub we're holding.
|
||||
*
|
||||
* Purely local user: `:<id>` (homeInstance and homeUserId are null) — local
|
||||
* users never collide with federated keys because the host segment is empty.
|
||||
*
|
||||
* Defensive fallback: if homeInstance is set but homeUserId is missing
|
||||
* (legacy stubs from before homeUserId was populated), the local id is used
|
||||
* as the identifier portion. This is rare and self-corrects when fresh
|
||||
* profile data arrives.
|
||||
*/
|
||||
export function canonicalUserKey(
|
||||
user: { id: string; homeUserId?: string | null; homeInstance?: string | null },
|
||||
): string {
|
||||
const host = normalizeOriginToHost(user.homeInstance);
|
||||
const ident = user.homeUserId ?? user.id;
|
||||
return `${host}:${ident}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the delivering origin is the user's home — i.e. the receiving
|
||||
* payload contains the authoritative view of this user.
|
||||
*
|
||||
* Cases:
|
||||
* - `user.homeInstance` is null/empty: the user is native to whatever
|
||||
* instance delivered them. Always a home view.
|
||||
* - `user.homeInstance` is set: home view iff the delivering host equals
|
||||
* the user's home host (with `''` resolving to `window.location.host`).
|
||||
*
|
||||
* Used as the "isHome" tier in the userViews preference rule. Stub views
|
||||
* never overwrite home views; home views always upgrade stubs.
|
||||
*/
|
||||
export function isDeliveryFromHome(
|
||||
user: { homeInstance?: string | null },
|
||||
deliveringOrigin: string,
|
||||
): boolean {
|
||||
const dh = deliveringHost(deliveringOrigin);
|
||||
const uh = user.homeInstance ? normalizeOriginToHost(user.homeInstance) : dh;
|
||||
return uh === dh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the federation-globe indicator render for this user, from the
|
||||
* current client's perspective?
|
||||
*
|
||||
* True iff the user is genuinely remote: their username carries an `@domain`
|
||||
* suffix AND that domain is NOT our own host. Catches the bug where a stub
|
||||
* delivered by a sibling instance (e.g. orbit-side `axel@nova.ddns.net`
|
||||
* viewed from a session logged in to nova) would otherwise show the globe.
|
||||
*
|
||||
* Compose with {@link useCanonicalUserView} at render sites: resolve the
|
||||
* canonical view first, then run this predicate so the answer reflects the
|
||||
* best-known view of the user, not whichever stub the carrying channel
|
||||
* happened to land on.
|
||||
*/
|
||||
export function isFederationGlobeApplicable(
|
||||
user: { username: string },
|
||||
): boolean {
|
||||
const { domain } = parseFederatedUsername(user.username);
|
||||
if (!domain) return false;
|
||||
if (typeof window === 'undefined') return true; // SSR fallback
|
||||
return domain !== window.location.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Federation-safe check: do two user-like objects represent the same person?
|
||||
* Uses cascading strategies to handle missing homeUserId on old replicated users.
|
||||
|
||||
@@ -52,6 +52,10 @@ export async function loadFederatedMutuals(
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy import — avoids pulling spaceStore's transitive dependency chain into
|
||||
// test environments that don't set up AudioWorkletNode.
|
||||
const { useSpaceStore } = await import('../stores/spaceStore');
|
||||
|
||||
const instances = useInstanceStore.getState().instances;
|
||||
const connectedInstances = instances.filter(i => i.status === 'connected');
|
||||
|
||||
@@ -82,6 +86,7 @@ export async function loadFederatedMutuals(
|
||||
seenFriends.add(canonicalId);
|
||||
if (origin) normalizeUserAssets(friend, origin);
|
||||
allFriends.push({ ...friend, _instanceOrigin: origin });
|
||||
useSpaceStore.getState().upsertUserView(friend, origin);
|
||||
}
|
||||
|
||||
// Spaces on different instances are distinct — deduplicate within same origin
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../audio/AudioManager', () => ({
|
||||
AudioManager: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
setOutputDevice: vi.fn(),
|
||||
setVolume: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../stores/instanceStore', () => ({
|
||||
useInstanceStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }),
|
||||
{
|
||||
getState: () => ({ instances: [], _autoConnectDone: true }),
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/authStore', () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ user: null, token: null }),
|
||||
{
|
||||
getState: () => ({ user: null, token: null }),
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
import { useSpaceStore } from '../stores/spaceStore';
|
||||
import { getCanonicalUserView } from './userViewLookup';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
function makeUser(extras: Partial<User> & Pick<User, 'id' | 'username'>): User {
|
||||
return {
|
||||
displayName: extras.username,
|
||||
avatar: '',
|
||||
avatarColor: 'mint',
|
||||
homeUserId: null,
|
||||
homeInstance: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
bio: null,
|
||||
banner: null,
|
||||
isAdmin: false,
|
||||
isDeleted: false,
|
||||
discoverable: true,
|
||||
showActivity: true,
|
||||
createdAt: 0,
|
||||
...extras,
|
||||
} as User;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { host: 'nova.ddns.net' },
|
||||
writable: true,
|
||||
});
|
||||
useSpaceStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('getCanonicalUserView', () => {
|
||||
it('returns the input unchanged on cache miss', () => {
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
expect(getCanonicalUserView(stub)).toBe(stub);
|
||||
});
|
||||
|
||||
it('returns the cached entry when one exists for the same canonical key', () => {
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
const homeFromNova = makeUser({
|
||||
id: 'nova-local-id',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
useSpaceStore.getState().upsertUserView(homeFromNova, 'https://nova.ddns.net');
|
||||
|
||||
const resolved = getCanonicalUserView(stub);
|
||||
expect(resolved).toBe(homeFromNova);
|
||||
expect(resolved.avatarColor).toBe('teal');
|
||||
});
|
||||
|
||||
it('returns the input on miss even after cache holds different users', () => {
|
||||
const someOther = makeUser({
|
||||
id: 'unrelated',
|
||||
username: 'unrelated',
|
||||
avatarColor: 'sky',
|
||||
});
|
||||
useSpaceStore.getState().upsertUserView(someOther, '');
|
||||
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
expect(getCanonicalUserView(stub)).toBe(stub);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from '@backspace/shared';
|
||||
import { useSpaceStore } from '../stores/spaceStore';
|
||||
import { canonicalUserKey } from './identity';
|
||||
|
||||
/**
|
||||
* Synchronous lookup into the userViews cache. Returns the best-known view of
|
||||
* the user from any connected origin, or the input unchanged on cache miss.
|
||||
*
|
||||
* Use from non-React paths (event handlers, helpers, predicates). React
|
||||
* render sites should use {@link useCanonicalUserView} so subscriptions tick
|
||||
* when the cache updates.
|
||||
*
|
||||
* Pass User-shaped inputs. Returning the cache entry replaces the input
|
||||
* reference; callers that depend on extra fields (UI-augmented types) should
|
||||
* either route the input through this helper before extending it, or call
|
||||
* with the underlying User and re-augment.
|
||||
*/
|
||||
export function getCanonicalUserView(user: User): User {
|
||||
const key = canonicalUserKey(user);
|
||||
const entry = useSpaceStore.getState().userViews.get(key);
|
||||
return entry ? entry.user : user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive lookup into the userViews cache. Subscribes to the specific cache
|
||||
* entry so the calling component re-renders when an upsert lands a better
|
||||
* view (e.g. nova's home view of Axel arriving after orbit's stub
|
||||
* populated the cache first). Returns the input unchanged on cache miss; the
|
||||
* site falls back to the current best information until the cache fills.
|
||||
*
|
||||
* Composes with `isSelf` / `resolveDisplayIdentity` rather than replacing
|
||||
* them — call those for self-detection / self-rendering as before, and pass
|
||||
* non-self users through this hook for cross-instance view resolution.
|
||||
*/
|
||||
export function useCanonicalUserView(user: User): User {
|
||||
const key = canonicalUserKey(user);
|
||||
const entry = useSpaceStore((state) => state.userViews.get(key));
|
||||
return entry ? entry.user : user;
|
||||
}
|
||||
Reference in New Issue
Block a user