Two mirror-image bugs from voice/DM-call transitions leaving stale state.
DM call → space channel (stuck "Connecting…"):
The last participant to leave a DM call for a space channel receives a
`dm_call_ended` echo (server empties the DM room on their `voice_join`).
The handlers called `disconnectFn()` unconditionally, tearing down the
space room they had just connected to. Route `dm_call_ended` /
`dm_call_rejected` / terminal `dm_call_undeliverable` through a new
`teardownDmCall()` that only disconnects LiveKit when not in a space
channel (`currentVoiceChannelId` null).
Space channel → DM call (still shown as "in" the voice channel):
1. Entering a DM call never cleared `currentVoiceChannelId`, so
`VoiceChannel` mapped the DM call's live LiveKit participants onto the
old space channel. Add `clearSpaceVoiceForDmCall()`, called in
`connect()` when `isDm`, restoring the invariant that a DM call has no
`currentVoiceChannelId`.
2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`,
so a caller already in a space channel was never connected to the DM
room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room).
Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts.
Docs: docs/systems/voice.md.
Voice presence (voiceStates/voiceUserStates/spaceVoiceStates) was only ever
delivered in the WS `ready` payload — i.e. at connect/reload. A user joining a
space mid-session got `member_joined` (no voice data) and a bare space object;
`GET /api/spaces/:id` (the channel-sidebar hydrator) carries no voice state
either. So members already sitting in a voice channel stayed invisible in the
new member's sidebar until a full page reload.
Fix at the systemic root: ConnectionManager.addUserSpace — the single chokepoint
every join path funnels through (invite, public join, join-request approval),
and which is NOT used on reconnect (that path uses setUserSpaces) — now pushes a
scoped `space_voice_state` snapshot to the joining user. The snapshot is built by
a new buildSpaceVoiceState(spaceId, userId) helper that is also the single source
of truth feeding buildReadyPayload (refactored to use it), so the connect-time
and join-time paths can never drift.
Robustness:
- Delivered over the same ordered WebSocket as voice_state_update deltas — no
REST snapshot-vs-event-stream race.
- VIEW_CHANNEL-filtered via computePermissions exactly like `ready`: a joiner is
never told who occupies a voice channel they cannot see.
- Client applies it scoped to the space (utils/voiceStateSync.applySpaceVoiceState):
merges occupants/statuses and rebuilds only that space's restriction keys,
never disturbing voice state in other spaces.
- Skipped when the space has no active voice and no restrictions (e.g. space
creation).
Tests: server helper behavior, the join push, and private-channel exclusion;
client scoped-apply. Specs updated (websocket.md, voice.md, spaces.md).
Replace ripped Discord audio with self-authored Ogg Vorbis files (avoids
copyright on open-source launch) and fix three cue bugs:
- Anti-pop envelope: playSound now applies a 10ms fade-in (+ tail fade-out
for one-shots) so buffers no longer start at a non-zero amplitude. Kills
the pop on the looping call cues. Mirrors playTestTone.
- Deafen no longer plays mute+deafen at once: toggleDeafen flips isMuted and
isDeafened atomically, so SoundController saw both transitions. New tested
pure helper selectVoiceStateSound() suppresses the side-effect mute cue.
- Viewer-side watch feedback: stream_user_joined/left now play locally on the
watcher's own machine for explicit Watch/Stop actions, via
handleViewerWatchToggle. Direct local playback (not a watchingStreams diff)
keeps auto-teardown silent and works identically on Safari and Electron.
Docs: docs/systems/sounds.md updated (Ogg, dual-audience cue rows, mute/deafen
selection, viewer feedback, anti-pop envelope).
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.
Three layered bugs all manifesting as "the group name doesn't show / the
placeholder is a 40-character wall of names":
1. **WS ready payload was missing `name`/`icon`.** The handler serialized
DmChannel rows with only `id, federatedId, ownerId, createdAt, members,
lastMessage`. The optional metadata fields were silently dropped, so
the client store never received `dm.name` until a subsequent
`dm_channel_updated` event fired (i.e. only mid-session renames worked,
never the initial render). `ownerHomeUserId`, `ownerHomeInstance`, and
`metadataUpdatedAt` were also missing — added too because federated
routing depends on `ownerHomeInstance` (`getDmOwnerHomeInstance`).
2. **Header surfaces silently dropped `dm.name`.** `MainContent` (desktop
chat header) and `MobileChatScreen` always rendered the joined member
names, even when `dm.name` was set. Five other surfaces (`DmListItem`,
`MobileDmsScreen`, `MessageList` welcome hero, `MobileGroupDmInfo`,
`GroupDmSettings`) honored it correctly, so a renamed group showed
different titles depending on which surface you looked at.
3. **Message-input placeholder rendered joined names.** Once a group
has 4+ members "Message #Alice, Bob, Charlie, Dave" overflows the
textarea and obscures the call-to-action.
Consolidates the display-name logic behind two utilities in
`dmFormatters.ts`:
- `formatDmHeaderName(dm, currentUser)` — `dm.name` verbatim if set,
else joined names (excluding self); falls back to `'Group'` /
`'Direct Message'`. Used by all 5 header surfaces (was inlined
5 different ways).
- `formatDmInputLabel(dm, currentUser)` — `'#<name>'` if set,
`'the group'` for unnamed groups (collapses the unreadable
joined-names form), `'@<partner>'` for 1-on-1.
`MessageInput` accepts an optional `placeholder` prop that bypasses the
default `Message {#|@}<channelName>` derivation; DM call sites use it
to inject the `formatDmInputLabel`-based form. 1-on-1 DMs keep the
canonical-view lookup so replicated aliases still surface the home
account's displayName; the placeholder reuses the canonical `dmName`
so header + placeholder stay aligned even when raw partner ≠ canonical.
13 new unit tests covering `formatDmHeaderName` (8 cases: named, blank,
joined, federated-username base, empty group, 1-on-1, no-displayName,
no-partner) and `formatDmInputLabel` (4 cases: named, unnamed,
whitespace-only, 1-on-1). 365 → 377 web tests, 1053 server tests,
typecheck clean.
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.
Transferring ownership or kicking a member surfaced "Target user is not a
member of this DM channel" whenever the target was a federated user.
Root cause: the client passed `canonical.id` from `useCanonicalUserView`,
which returns the user's HOME id when the home view is in the userViews
cache. After owner-routing the request to the owner instance, that
instance's `dm_members.userId` (its own local replicated id) never
matched the home id, so `isDmMember` returned false. The same failure
mode applied across any cross-instance scenario where the
channel-serving instance and the owner-serving instance disagree on the
local replicated user id for the same federated user.
Fix: both endpoints now accept federated identification, mirroring the
existing pattern on `POST /api/dm/:id/members`:
- `POST /api/dm/:id/transfer` body: `{ newOwnerId? } | { homeUserId, homeInstance }`.
Federated args win when both are supplied (strictly more specific).
- `DELETE /api/dm/:id/members/:targetUserId` reads optional
`?homeInstance=<origin>` query; when present, the URL segment is
treated as a homeUserId and resolved via `resolveOrCreateReplicatedUser`.
Client `api.dm.kickMember` and `api.dm.transferOwnership` gain an
optional `federated` argument; `DmRosterPanel` and `MobileGroupDmInfo`
pass it whenever the target has `homeUserId` + `homeInstance` populated.
Adds 5 server tests (3 transfer + 2 kick) covering federated targets,
the federated-wins-over-local precedence rule, and federated-non-member
rejection. Updates 2 client routing tests and 2 DmRosterPanel test
assertions for the new signature. Updates `docs/systems/dm-system.md`
and `docs/systems/api.md`.
Server: 965 tests pass (was 960). Web: 362 tests pass (was 360).
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.
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.
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
Chromium gates the PulseAudio loopback path behind the
PulseaudioLoopbackForScreenShare feature flag. Without it, returning
audio: 'loopback' from setDisplayMediaRequestHandler rejects the whole
getDisplayMedia request, so screen share never starts when the user has
"Share system audio" enabled. Also surface a clear warning toast on
loopback failure (PipeWire-only without pulse compat, macOS without
Catap) instead of failing silently — no auto-retry, since the picker
selection is already consumed.
DmLastMessagePreview lacked a `type` field, so the sidebar rendered
`lastMessage.content` verbatim — surfacing JSON like
`{"event":"space_invite",...}` for space invites and member-add events.
Adds `type` to the preview payload (populated server-side from
`dm_messages.type`) and routes all sidebar call sites through a single
`formatDmSidebarPreview` helper that renders human-readable text for
each system event and skips the group `Sender:` prefix on system rows.
vitest+jsdom puts test code in a different realm than Node's TextEncoder,
so `toBeInstanceOf(Uint8Array)` rejects the encoder's return value even
though it is structurally a Uint8Array. `Object.prototype.toString.call`
checks the @@toStringTag tag, which is realm-safe.
LocalTrackPublished registers a one-shot onended on the camera track's
MediaStreamTrack. The handler:
- skips when consumeIntentionalCameraOff() flag is set (user-initiated)
- re-probes getUserMedia to distinguish NotAllowedError (permission
revoked) from NotFoundError (disconnected) from other errors
- tears down camera state via the unified path
Also reset _intentionalCameraOff in voiceActions if setCameraEnabled(false)
rejects, so a failed disable doesn't poison the next genuine unplug.
- Voice-bar and mobile camera buttons now use the canonical handler
(fixes mobile no-op and voice-bar wrong-preset bugs)
- Remove dead useLiveKit.toggleCamera
- Add _intentionalCameraOff flag with mark/consume helpers
Without this, when the local admin gate fires on friend-add, the user
got a generic 'Could not send friend request' fallback. Now they see
'Your admin needs to approve federation with this instance' with a
pointer to Connections settings where Task 12's pending-approvals
section shows their queued request.
Removes try/catch on the deleted InstanceNotConnectedError/Disconnected
classes (T17). Server now returns structured error codes; client maps
them to human-readable toasts via the new mapServerErrorToMessage helper.
The friend-add flow no longer triggers ConnectInstanceModal — the server
handles all routing/peering/lookup. The modal itself stays for Connections
settings and space-join flows.
buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone."
for the single-failure terminal case; multi-failure + non-terminal paths
fall through to existing lines (which already fold the new reason in by
peer label). TDD — four new assertions.
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.
failoverDmOriginsFromDisconnected(origin) walks pinned DMs and re-keys
them to a connected sibling origin's local channel id (via dmAlternatives
federatedId lookup). Preference: home first, then any connected remote in
insertion order. rekeyDmChannel performs the atomic rename across
spaceStore (dmChannels / channelOriginMap / channelLastMessageIds /
dmAlternatives), chatStore (via rekeyChannelState), and the URL (via
history.replaceState when viewing the rekeyed DM). Voice state is
intentionally untouched — LiveKit sessions can't migrate across origins.
Old origin's local id is retained in dmAlternatives for possible later
fail-back without another ready round-trip.
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
Enable federated DM calls to route accept/reject/end through the correct
WebSocket connection using callOrigin, and include federatedCallId in all
dm_call payloads for server-side FederatedCallEntry lookup.
PNG conversion strips GIF animation. GIFs are now detected by URL
pattern (.gif extension or Tenor/Klipy CDN) and copied as URL text
instead, so pasting back into chat re-renders the animated GIF.
Implements image save/download (blob fetch + anchor trigger, fallback to window.open)
and clipboard copy (PNG write via ClipboardItem, fallback to URL text copy) with
toast feedback. Also adds ClipboardItem polyfill and Response.blob() content-type
fix to the jsdom test setup so the clipboard test suite runs correctly.
- buildScreenShareOptions reads hwOverdrive from voiceStore instead of config.codec; both VP9 and H.264 paths now always get a VP8 SIMULCAST backup
- startScreenShare activates SDP override before WebRTC negotiation and deactivates on failure
- stopScreenShare and handleScreenShareUnpublished both deactivate the override and reset hwOverdrive: false
- scheduleEncoderDetection checks encoderImplementation in WebRTC stats after 4s and shows a warning toast if OpenH264 (software) is detected