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:
Jannis Braun
2026-05-05 01:59:05 +02:00
parent fb96b1457a
commit 49e9047005
33 changed files with 2150 additions and 791 deletions
+156
View File
@@ -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);
});
});
+108
View File
@@ -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.
+5
View File
@@ -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);
});
});
+39
View File
@@ -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;
}