The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.
Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).
Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.
The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.
Closes#37
* ci: run typecheck, build & tests on PRs and main
Add a GitHub Actions workflow (.github/workflows/ci.yml) that installs on
Node 20 (the pinned runtime), builds shared/server/web, typechecks desktop,
and runs the full vitest suite across all packages on every pull request and
push to main. The repo already had 500+ tests but only ever ran them locally.
Also wires up the missing test/typecheck plumbing this exposed:
- web package had 55 test files but no test script — add test/test:watch/typecheck
- add root-level test and typecheck aggregate scripts
- fix a latent web type error the new typecheck surfaced: reference
vite-plugin-pwa/react types so virtual:pwa-register/react is typed
(useRegisterSW callback params were implicit any; the tsc half of the web
build script has been failing, masked by vite build ignoring type errors)
* test: fix two latent failures surfaced by CI on Node 20
- server: exclude dist/** from vitest. Vitest 4's default exclude dropped
dist/, so after `pnpm build` emits compiled .test.js files, vitest ran those
stale copies alongside src/*.test.ts and they failed (compiled vi.mock paths
resolve differently).
- web: polyfill Blob.prototype.stream in the jsdom test setup. jsdom's Blob has
no .stream() on Node 20 (Node 25 provided one, masking this locally); undici's
Response constructor calls blob.stream(), so new Response(blob) threw
'object.stream is not a function'.
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.
Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
admin still short-circuit first, so they are unaffected).
Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
space id against the local table so a spoofed spaceInstanceOrigin can't slip
past it.
- InviteModal hides the invite affordances for request spaces.
Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.
Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.
Reported-by: BadAtCaptchas (#2)
Finding 1: handleReactionAdd/Remove now drop reactions on a dead 1-on-1
(isDeadOneOnOne) — previously a survivor could react on a Deleted-User
thread and the relay fanned out to all peers via undefined target-origins.
Client Message.tsx withdraws add/toggle reaction affordances for dead DMs
(existing reactions still display read-only).
Finding 2: dmMembership purge test now runs with foreign_keys=ON (matches
prod) and asserts dm_members/dm_messages cascade cleanup on channel purge.
Finding 3: tombstone group-DM ownership transfer filters isDeleted=0 so
ownership can never move to a tombstoned member; covered by a new test.
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.
The flat app-icon.svg's gradient B mark has a bright (#fff) sheen that runs
to the badge perimeter with no dark separation. At favicon sizes (16/32px)
that edge anti-aliases into a white halo that reads as a border around the
icon — visible in Safari browser tabs, and the same defect in the small
Windows .ico / Linux launcher reps that also rendered from the flat SVG.
The committed 3D raster masters (used by every >=128px output already) frame
the mark in a dark surround and stay clean down to 16px. Set RASTER_THRESHOLD
0 so all app-icon sizes route through the raster path; the flat SVG is kept
as a gated source, re-enablable only with a corrected flat mark. Regenerated
favicons + small desktop reps; output remains byte-deterministic. Updated the
generator header/comments, README source matrix, and the dated icon spec.
The server's `playable` flag is computed Chromium-first, but HEVC
web-playability is browser-dependent: WebKit (Safari on macOS/iOS) decodes
HEVC via the OS while Chromium/Firefox/Electron can't. Treating the flag as
global wrongly showed Safari users the download fallback for files they can
play inline.
The client now treats `playable === false` as "needs a capability check": it
pre-renders the fallback only when the current browser also can't decode the
format, gated on a one-time canPlayType probe (BROWSER_SUPPORTS_HEVC). Capable
browsers attempt inline playback; the <video> onError handler remains the
safety net for genuine failures.
macOS screen recordings are HEVC inside a .mov container, which Chromium,
Firefox and stock Electron can't decode. The file uploaded fine and a
server-side ffmpeg poster was generated, but inline <video> playback failed
silently — stuck at 0:00 with no error, since AttachmentRenderer had no error
handling. Root cause: the system had no concept of web-playability.
Server detects, client degrades:
- mediaPlayable.ts: classifyVideoPlayable(mimetype, codec) — tri-state
(false = known-undecodable e.g. HEVC/ProRes, true = web codec in web
container, null = unknown/optimistic). Never widens `false` beyond codecs
that fail everywhere, so ffmpeg-less instances keep prior behaviour.
- probeMediaMeta now captures the video codec_name; the upload finish hook
stores the verdict in the new attachments.playable column (migration 0007).
- Flag propagated through every serializer: space messages, DMs, WS, and
federation relay (outbound + inbound) — federation-compatible.
- VideoAttachment component: playable===false renders a download card (poster
+ "Can't play here — download" + name/duration/size) with no dead-player
flash; otherwise plays inline with an onError fallback to the same card.
Specs updated: uploads.md, database.md, federation.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).
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.
The scroll container's space-y-[2px] utility set margin-bottom:0 (via
Tailwind's space-y reverse mechanism) on every category wrapper except
the first, with higher specificity (.space-y > :not ~ :not = 0,3,0) than
each category's mb-[19px] (0,1,0). This zeroed the 19px separator for all
but the first category, so the inter-category gap rendered only below the
first category and shifted when categories were reordered.
Remove the redundant container-level space-y; the per-category mb-[19px]
is the intended separator and now applies uniformly.
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).
Eliminates the visible push-down/snap-back when the pagination loading
skeleton appears at the top of the message list. Achieved by rendering a
constant-height (~200px) top-of-list slot above messages whenever
hasMore===true; the skeleton's grey-bar contents toggle inside that slot
rather than the slot itself mounting/unmounting. Companion changes:
load-more trigger raised to fire before the slot enters the viewport with
an iOS Safari rubber-band guard, prepend scroll-restore math corrected to
handle non-zero prevScrollTop, useDelayedLoading threshold lowered to
50ms for pagination only.
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.
Each AvatarTile rendered at `size × size` with a 2px border under
`box-sizing: border-box`, giving a content area of `(size − 4) × (size − 4)`.
The inner `<Avatar size={size}>` exceeded the padding box, and the
`overflow-hidden + rounded-full` clip — centered on the wrapper — combined
with the avatar contents anchored at the padding-edge top-left to displace
photos and especially the centered initials gradient + letter toward the
lower-right of the visible disc, leaving a sliver of background opposite.
Compounding this, `Avatar`'s `inline-flex` root sat on the line-box text
baseline, so any inherited `line-height ≠ 1` (the DM list inherits the
row's line-height) drifted the avatar a further several px vertically.
Two corrections:
• Size the inner Avatar to `size − 2·TILE_BORDER_WIDTH` (matches the
padding box) — extracted as a constant so the dependency between
`border-2` and the inner size is visible.
• Center geometrically via `flex items-center justify-center` on the
wrapper, bypassing inline-flow placement so the Avatar is anchored
regardless of inherited type metrics.
Verified in the live app (DM sidebar, chat header, welcome header) and
with a 4-tile diamond at sizes 32 and 80: visible offset is now exactly
the border width on every tile, letters/photos sit dead-center.
Adds a regression test that pins both invariants (flex centering classes
present + inner Avatar style.width === tileSize − 4) so a future change
that re-introduces the bug fails fast. 12/12 AvatarStack tests, 365/365
web tests, typecheck clean.
design-system.md spec updated with the AvatarTile geometry contract.
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).
The old 2x2 grid for 3+ members produced a cramped, misaligned look
(circles cut off inside their own border, no overlap, just four small
disks in a grid). The 2-member overlap aesthetic — equal-size tiles
that overlap diagonally — was the visual gold standard but only
existed for that one count.
Extend the same "huddle of overlapping faces" aesthetic to all
member counts:
• 3 members → equilateral triangle of 62%-size tiles (top, bl, br)
• 4 members → diamond of 58%-size tiles (top, right, bottom, left)
• 5-10 members → diamond with `+N` overflow occupying the bottom
slot (z-index boosted above neighbors so the digit is never clipped)
Tiles are positioned radially around the box center on a circle of
radius (S − T) / 2 so the farthest edges of each tile graze the
bounding rect — no clipping, no wasted whitespace. Z-index descends
clockwise from the top slot so each tile tucks slightly under its
clockwise neighbor, mirroring the 2-member z-stack.
The 0/1/2-member cases and the icon-override branch are unchanged.
Props interface unchanged — no call-site updates needed.
Tests updated to assert the new layout markers (`triangle`, `diamond`)
and to verify the geometry (top tile in triangle has smallest `top`;
overflow tile in diamond has largest `top`). 11/11 AvatarStack tests
pass; 360/360 web tests pass; typecheck clean.
design-system.md spec updated with the new layout table + geometry
section.
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.