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.
Multi-tab sync: friend_request_sent appends the new outbound request to
socialStore (deduped by id+origin), no toast.
Async rollback: friend_request_relay_failed removes the row by id and
surfaces a warning toast with the target handle and reason. Wires the
client side of the rollback hook from T10.
Server now handles all parsing/routing/peering/lookup (T11-T14). Client
sends the trimmed username verbatim to /api/social/requests and surfaces
server errors via toast (added in T18-T20).
Note: FriendsPage.tsx and UserProfileModal.tsx will fail to compile
until T19 and T20 remove their now-dead try/catch blocks for the
deleted error classes. TypeScript catches it; pnpm dev will not start
until those tasks land.
Per code-review:
- Drop the inline comment in sendFriendRequest; the commit message
for the prior commit already covers the why and CLAUDE.md prefers
no comments when the code is self-explanatory.
- Add writable: true to the window.location defineProperty in the
test so re-firing beforeEach across jsdom version drift is safe.
Hostnames are case-insensitive (RFC 4343), and both right-hand
sides of the routing comparisons (window.location.host and
URL.host) are already canonical lowercase. The user-typed domain
substring was compared with strict ===, so ORBIT.ddns.net
failed to match an existing connected peer and popped a spurious
Connect Instance modal. Normalize at parse time.
When two instances each have a native user with the same username, the
Add Friend search card for the federated one sent its request to the
local namesake instead of the intended remote user.
Root cause: `isNative = !homeUserId` in socialStore's searchUsers and
loadFriends dedup. The server backfills native users' homeUserId to
their own id so federation tier-1 lookups succeed, so `homeUserId` is
set on natives too. Only `homeInstance` distinguishes native (null)
from replicated stubs. With the wrong check, no entry was ever "native"
and the home-origin stub of the remote user was kept over the true
native record — leaving `_instanceOrigin=''`, which caused the Send
button handler to drop the domain suffix and POST to the home API,
where "nova" resolved to a completely different local user.
Also fixes loadRequests dedup to prefer the target-native record so the
search card correctly flips to "Request Pending" after sending.
The socialStore WS-driven handlers (addFriendFromAccepted,
addIncomingRequest, removeFriendLocally, removeRequestById,
updateFriendPresence) used instance-local id:origin composite keys
for deduplication. When the client is connected to multiple instances,
both fire WS events for the same federated user with different local
IDs, bypassing the dedup and creating duplicate entries.
Switch all handlers to use homeUserId??id (canonical identity),
matching the pattern loadFriends/loadRequests already use. Also
replace the loadRequests() re-fetch in updateFriendRequest with
optimistic canonical removal to avoid racing S2S relay propagation.
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
Add removeRequestsForUser (socialStore), removeUser (discoverStore), and
clearTypingForUser (chatStore) to support isDeleted cleanup in the
user_updated WS handler.
Lower minimum query length from 2 to 1 character so single-letter
searches return results.
Fix dedup to prefer native profiles (homeUserId=null) over replicated
ones. Previously the first-seen result won, which was usually the
local replicated profile (no instance badge, namespaced username).
Now when a native profile is found on the remote instance, it replaces
the replicated copy — showing the clean username with the instance badge.
Replicated profiles on remote instances have different local IDs but
share the same homeUserId as the native profile. Use homeUserId ?? id
as the dedup key so the same person only appears once. Home instance
is queried first, so the native profile (with clean username) wins.
- SSRF protection: DNS resolution + private IP blocking on metadata fetcher
- Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff
- Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at
- Attachment ownership verification before linking to messages
- Message length limit (4000 chars) enforced on client and server
- Asset URL validation on avatar/banner updates
- Federation instance validation (domain regex, origin scheme, length limits)
- DB indexes on all FK columns for query performance
- Migrations: nullable moderator columns, dm_messages reply_to FK constraint
- File cleanup on avatar/banner replacement and space deletion
- Fastify trustProxy, AbortController on fetches, typing map size cap
Cancelled requests now disappear from receiver's UI instantly, and
declined requests revert the sender's discover card from "Request
Pending" to "Send Friend Request" — no page refresh needed.
Also includes the discover endpoint and sendFriendRequest return type
changes from the prior session.
Broadcast user_updated events over WebSocket when profile fields change,
updating members, DM participants, friends, and cached messages in real time.
Widen useVoiceParticipantMeta to return the full user object and add a
standalone avatarColor prop to Avatar so all ~16 callsites now resolve
the user's chosen gradient color instead of falling back to hash-based colors.
Rewrites socialStore to aggregate friends and requests from all connected
instances using Promise.allSettled. Parses user@domain in friend requests
to route to the correct instance. Removes !isHome guards on social WS
events so remote friend requests arrive in real-time. Shows "via hostname"
labels on remote friends/requests in the UI.
- Add Backspace-design-prototype.html: finalized "Aether Drift" design
language with warm matte surfaces and subtle frosted glass accents
- Update CLAUDE.md with DESIGN SYSTEM section and remove Discord clone references
- Rename all Opencord references to Backspace across the full codebase
- Archive outdated design experiments and Discord-specific assets
- Add science-backed accessibility fallback (prefers-reduced-transparency)
Wrap all multi-write DB operations in atomic transactions (server/channel
creation, message+attachment linking, DM creation, friend acceptance,
cascading deletes) to prevent partial-write corruption.
Batch N+1 queries in WS ready payload into O(1) bulk fetches with
chunked inArray() to respect SQLite's variable limit.
Fix chat history regression where background WS messages bypassed
channel load by switching the guard from messages.has() to hasMore.has().
Add LRU channel eviction (20 cached, evict to 15) and per-channel
message cap (200) to bound client memory growth.
Shorten WS heartbeat from 30s to 15s for aggressive proxy/NAT
environments. Clear all user-scoped stores on logout to prevent
cross-session data leaks.
Extract LiveKit internal accessors into shared livekitInternals utility.
- Add WS events: dm_channel_created, dm_channel_closed, friend_removed,
channel_created/updated/deleted, server_updated
- Fix first-ever DM: broadcast dm_channel_created to recipient
- Add DELETE /api/dm/:id for closing DMs with re-open support
- Wire Close DM button in sidebar
- Fix dm_message_created for unknown channels (safety net)
- Broadcast friend_removed on friend deletion
- Sound system: track realtimeMessageEvents separately from API loads,
play notification for messages in all channels, not just current
- Optimistic updates: send/edit/delete messages appear instantly with
rollback on failure, temp message deduplication on WS echo
- Channel CRUD broadcasts to all server members
- Server update broadcast on PATCH
- Server join registers user in connectionManager immediately
- Reconnect: only reload current channel, preserve other channel caches
- Activity panel, right panel, member list toggle components
Phase 1 - Feature Repair:
- Fix member kick/leave: add missing db.delete() call in servers.ts
- Stabilize invite codes: return existing code instead of regenerating
- Fix user search: use LIKE instead of exact match in social.ts
- Wire DM button on FriendsPage to create/navigate to DM channels
- Add cancel outgoing friend request (DELETE endpoint + frontend)
- Add accept/decline friend request actions with WS real-time events
- Fix replyToId persistence in message creation
- Hydrate reactions and replyTo in message queries
- Add joinByCode to API client and serverStore
- Add friend_request_received/accepted WebSocket events
Phase 2 - Discord UI Overhaul:
- Remove stray borders between layout columns
- Replace shadow-sm with shadow-header on content headers
- Replace all bg-gray-*/text-gray-* with Discord color tokens
- Ensure flat color contrast (#1E1F22, #2B2D31, #313338)
Testing:
- Set up vitest + @testing-library/react + jsdom
- Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing)
- Fix vite resolve.extensions to prefer .tsx over stale .js files