The add-sound control was gated on channelPermissions, which carries the
per-channel bitfield; MANAGE_SPACE lives in the space-level one. The check
silently evaluated false for everybody, including owners, so the button never
rendered and there was no way to add a sound at all.
Also raise the clip cap to 2 MB — a 30-second clip at a high bitrate cleared
1 MB — and stop reporting every upload failure as 'too large', which sent
people to shrink a file that was not the problem.
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.
Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.
Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.
Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.
Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at
the seven call sites that reach them, so no path can be missed, and
destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise
leak open rows.
A restart leaves sessions open with an unknowable end time. They are closed at
startedAt, discarding that time rather than inventing it: crediting the gap
would hand someone hours they never spent, and the numbers are the point.
Mirrors the existing users.status sweep on boot.
Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.
The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.
Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.
Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
Favourites are stored server-side per user, so one made on the phone is there
on the desktop — the point of favouriting. The whole result is stored rather
than an id: the provider offers no lookup by id, so an id-only favourite could
not be rendered without re-finding it through search.
Category chips translate their label but not their query, which goes to a
provider that indexes in English.
The star sits beside the tile button rather than inside it: a button within a
button is invalid and swallows the click. Toggling is optimistic and reverts
on failure, and favourites skip both the loading skeleton and the infinite
scroll, which belong to provider-backed browsing only.
Server caps favourites per user and rejects non-http(s) URLs, which become
<img src> in everyone's picker.
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.
The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.
Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.
Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.
Scope is read-only: user-read-currently-playing and user-read-playback-state.
Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
Nothing in the project was translatable — every string sat inline in English.
en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.
Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.
Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
The activity selector sat below `if (!isOpen || !user) return null`. `user`
arrives asynchronously, so the hook ran on some renders and not others; React
counts hooks per render and tore the tree down with error #310 as soon as a
profile finished loading.
Move it above the guard and let the selector tolerate a null user. Typecheck
and the suite both passed with the bug in place — TypeScript cannot see hook
order and nothing renders this modal across the null-to-loaded transition.
The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.
Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.
Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.
Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.
Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
The profile popout already existed and was reachable from eleven places —
messages, mentions, avatars, member list, DMs, activity panel — but no voice
surface opened it, so clicking someone during a call did nothing.
Wire it into the voice user rows (VoiceChannel's sidebar list) and the name
label on grid tiles, whose avatar was already a ProfileAvatar; the name beside
it not reacting read as the click failing.
Left mobile alone deliberately: MobileSpacesScreen already opens the profile
from its row wrapper, and MobileVoiceJoinSheet would layer a history-pushed
full-screen profile inside a bottom sheet, which cannot be verified here.
The composer's GIF button drew a filled rounded rect with the letters knocked
out, which reads as a solid square rather than a picker. Invert it: stroked
outline with filled letters, reusing the original glyph paths scaled to centre.
Banners already accept absolute URLs on both ends (server isValidAssetUrl
allows http(s); the profile render branches on banner.startsWith('http')), so
the picker stores the remote URL directly with no upload path. Previews can now
hold either a blob: or an https: URL, so revoking is guarded — calling
revokeObjectURL on a remote URL is a silent no-op that would hide a mistake.
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).