The sidebar's visibleChannels filter is keyed on the channelPermissions
Map. Creating a channel raced two state updates: the optimistic create
(added to channels with no permission entry) and the channel_created WS
event (the only thing that set the permission). When the optimistic add
won the race, the WS handler hit its dedup guard, skipped setChannels,
and set the permission by mutating the Map in place — no new reference,
so visibleChannels never recomputed and the channel stayed hidden until
loadSpace rebuilt the maps (i.e. leaving and returning to the space).
Centralize the logic in a new upsertChannel store action that replaces
channels and channelPermissions with fresh references, used by both the
create path and the channel_created handler. Also return the creator's
computed myPermissions (and isPrivate) from POST so the channel renders
immediately from the response, independent of WS timing.
Adds spaceStore.upsertChannel.test.ts covering the reference-identity
regression and the optimistic-reconcile path.
Brings the parked mobile voice + screenshare polish from wip/mobile-polish
(commit dbb9b2c) into main. Conflicts in MainContent.tsx and mobile-ui.md
expected per the WIP commit message — resolved manually.
Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.
Three compounding fixes:
1. Receiver authority checks now compare via `normalizeOriginForCompare` so
legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
`processOwnershipTransferEvent` receiver storage) routes through it. Full URL
is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
`newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
present and leaves existing values untouched otherwise (legacy-server safe).
Without this, `getOwnerInstanceForDm` returned the previous owner's home
after a successful WS broadcast, routing the next owner-only op to the wrong
instance.
Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.
Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
Snapshot of in-progress work parked here so group-DM-polish can land
cleanly on main. Touches MainContent + mobile-ui.md which overlap with
group-DM-polish; rebase onto post-merge main and resolve conflicts on
those two files manually.
Files: MainContent, MessageInput, MobileVoiceFullScreen (+test), StreamTile,
VoiceUser, useLiveKit, useVisualViewportInset, AudioManager, voiceStore,
voice utils, mobile-ui.md, voice.md, mobile-parity handoff doc.
Brings mobile to skeleton parity with desktop. Two mobile-specific renders, plus a fix for the empty-state mascot flashing on every space switch.
- MobileSpacesScreen: shimmer skeleton for the channel list while `useSpaceStore.loadingSpaceId === selectedSpaceId` (uncategorized rows + category header + categorized rows). Mirrors desktop ChannelSidebar's `showChannelSkeleton`. Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder.
- MobileMembersScreen: shimmer skeleton for the member list (two role-section headers + circular avatars + name bars). Mirrors desktop MemberSidebar's `showMemberSkeleton`.
- Empty-state "No channels yet." mascot was firing on EVERY space switch for ~50–200 ms — `state.channels` only ever holds the most-recently loaded space's channels, so `spaceChannels` filters to `[]` immediately on selection change while `loadSpaceDetail` is still in flight. New `loadedSpaceIds: Set<string>` on useSpaceStore, populated only on `loadSpaceDetail` success and pruned on space removal events. The mascot is now gated on `loadedSpaceIds.has(selectedSpaceId)` so it only fires when the space is *settled* with zero channels.
Reuses the existing `.skeleton` / `.skeleton-bar` / `.skeleton-circle` CSS primitives + `useDelayedLoading` hook (no new shared `<Skeleton />` primitive — sites need row-specific geometry, parameterizing would either over-abstract or duplicate the inline approach).
Specs: docs/systems/mobile-ui.md (Loading Skeletons inventory + per-screen skeleton bullets + settle-gate rationale); docs/systems/spaces.md (Client Load State subsection covering loadingSpaceId vs loadedSpaceIds).
Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.
MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.
Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.
Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.
MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.
RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
useWebSocket's ready handler calls loadMessages(channelId, true) from
two adjacent code paths in the same handler invocation (remote-space
refresh + per-origin cache clearing). Both fired in parallel because
force=true bypassed dedup, producing the duplicate /messages requests
visible in playwright network traces even after the parallel-mount
dedup landed.
Coalescing force=true with an in-flight call is safe: the requests hit
the same endpoint with the same params and would return the same data,
and the force caller still gets a fresh result via the shared Promise.
Live network trace via playwright showed every channel mount firing TWO
parallel GET /channels/:id/messages requests (AppLayout + MessageList
both call loadMessages on mount; neither can be removed because
MessageList is also rendered by VoiceChatPanel which doesn't call it),
and every fast-scroll burst firing N parallel loadMoreMessages calls
(handleScroll closure shares !isLoadingMore=true across the burst).
The hasMore-based guard only deduplicates calls AFTER the first
completes — it does not collapse parallel callers. On a NAT hairpin'd
LAN where some federated TCP connections hang, having N hung parallel
fetches keeps the pagination skeleton stuck while any one is still
waiting on the 30 s api-client timeout.
Fix: in-flight Promise dedup at the chatStore level. Module-level Maps
keyed on channelId; concurrent callers receive the same Promise.
Cleared in finally with self-check so a force-reload mid-flight isn't
dropped. force=true bypasses dedup (WS reconnect intent).
Two follow-on bugs from the initial S2S presence rollout:
(1) New friend stuck offline until they reload: presence_update fires only on
transitions, so a remote user already online when their stub is created
locally never receives a relay event seeding their actual status. The
stub defaulted to 'offline' at creation and stayed there until the next
transition. Fix: extend FederationRelayProfileSnapshot +
FederationUserLookupProfile with status. Sender-side buildProfileSnapshot,
getDmParticipants, and lookup endpoint responses populate it for native
users only (replicated stubs hold stale status owned elsewhere).
resolveOrCreateReplicatedUser uses hints.status to seed the new row's
status column. Threaded through every call site (DM participants, group
bootstrap, friend events, ownership transfer). Stub backfill worker also
heals existing rows whose status was stuck at 'offline' from creation.
(2) 'Online' text updates but green avatar dot stays grey on the same page:
spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
but never patches userViews — the cache useCanonicalUserView reads from.
The Avatar in FriendItem reads canonical.status; the text reads
friend.status (socialStore). Two sources, one stale until full
user_updated arrives. Fix: updateMemberPresence now mirrors status into
matching userViews entries, so canonical-view consumers re-render with
fresh status the moment the WS event lands.
RegisterPage step 2 writes the new token to localStorage but defers
initSession (and thus the authStore.token write) until after the avatar
upload, to keep AuthRedirect from yanking the user off /register
mid-upload. The home-origin branch of setTokenForOriginResolver was
reading authStore.token, so getTokenForOrigin('') returned null during
that window — transferStore.startUpload threw "not authenticated" and
the surrounding catch-and-ignore silently dropped the avatar. Align the
resolver with the home api client and read localStorage so both paths
share one source of truth for the home JWT.
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.
Without a sync-ready gate, a transient GET failure during autoConnectAll
left the local registry Map empty/incomplete while `set()` still computed
`registryUpdatedAt = Date.now()`. The trailing `syncRegistry()` would then
PUT the empty payload with a fresh timestamp; the server's LWW guard
accepted it and legitimate registry rows were wiped — including
remote-instance entries the user never explicitly removed.
Add `_registrySyncReady` flag, set true only after a successful initial
GET. `syncRegistry()` short-circuits while false, so the degraded mode
(GET failed) is display-only: the Map is still populated locally from
\`replicatedInstances\` synthesis (status \`auth_expired\`) so the
Connections UI shows the user's known remotes, but mutations don't push.
On the next session where GET succeeds, localStorage cached tokens
reseed the registry and \`syncRegistry()\` pushes the merged authoritative
state — no data lost, sync deferred until we have a complete picture.
\`reset()\` clears the flag alongside the registry. Spec updated with the
sync-ready gate and degraded-mode behavior. Unit tests cover the gate
on initial fail, mutations during degraded mode, recovery on next
successful GET, and the synthesis fallback for empty replicatedInstances.
Pending bubbles that survive a reload (or post-redeploy refresh) without a
FileSystemFileHandle had no way to recover the original File bytes, so the
Retry button silently no-op'd: resumeUpload set state back to 'paused' with
no surfaced error, leaving the user stuck.
- transferStore: add reactive hasInMemoryFile Set mirroring liveUploadFiles.
setInMemoryRef helper keeps both in sync at every set/delete site
(startUpload, resumeUpload, onSuccess, remove). Persisted shape is unchanged.
- resumeUpload: when no blob is reachable (no in-memory File and no FS handle),
call setError with an actionable "File no longer available — discard and
re-upload" message instead of silently flipping back to 'paused'.
- Message.tsx: compute canRetry reactively from transfersForRow + hasInMemoryFile;
hide the Retry button when retry is infeasible. Discard remains.
- AttachmentProgress: optional error prop surfaces transfer.error.message via
title= on the failed-state ring for hover context.
- Tests: existing 3 resumeUpload tests now assert state==='failed' with the
actionable message; +2 new tests for hasInMemoryFile lifecycle (start/remove,
abort retains).
Replaces the legacy /api/uploads call in RegisterPage with the tus-based
transferStore path. Extends Transfer to persist the server-assigned
filename (not just attachmentId) since downstream consumers store
attachment.filename on the user/space record.
- transferStore: rename setAttachmentId -> setAttachmentRef(id, attachmentId, filename)
and add attachmentFilename field to Transfer
- both startUpload + resumeUpload onSuccess paths now record filename
- new utils/waitForTransfer.ts: waitForTransferAttachment(transferId)
returns {attachmentId, filename}, with immediate-terminal handling
- RegisterPage: silent (tray:false) upload via transferStore, awaits
the helper, passes the server filename to api.users.update
When a caller passes the user's own instance origin (e.g. from an invite
snapshot's spaceInstanceOrigin), strip it to undefined before the remote
branch so the local api path is taken instead of erroneously failing with
NotConnectedError. Mirrors inviteParser's identical normalization.
Two new inline sections in the user-facing federation/connections settings
panel: 'Recent peering outcomes' (terminal-state notifications with
Retry-for-approved + Dismiss) and 'Pending peering approvals' (active
subscriber rows the user is waiting on, with Cancel). New WS handlers for
peering_subscription_changed and peering_notification_received refresh the
lists in real-time and surface a transient toast for online users. Retry
deep-link for friend_add prefills the friend-add input with the original
target handle (other reasons get Dismiss only — the gate doesn't wire
those paths yet).
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.
instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.
Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).
spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.
Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.
Closes backlog #27.
disconnectInstance and forceRemoveEntry now run
failoverDmOriginsFromDisconnected BEFORE removeInstanceSpaces so any DM
with a connected sibling survives the disconnect via rekey; only DMs
without alternatives are cleared alongside the rest of the instance.
Switched setInstanceStatus to the same static import (dmOriginFailover
lazily reads store state, so no import cycle).