Commit Graph
100 Commits
Author SHA1 Message Date
Jannis Braun bc66ddc633 fix(server): canonicalize spaceInstanceOrigin before storing payload
Empty-string (local) origin is now stored as the absolute home origin
so relayed DM space-invite cards carry the correct value to remote
recipients instead of resolving against the wrong instance.
2026-04-29 22:35:34 +02:00
Jannis Braun 44e9a4234c fix(server): local-fast-path for invite snapshot — skip HTTP self-reach
POST /api/dm/space-invite was hanging 5s and returning invite_invalid
for any local-space invite. fetchSpaceInviteSnapshot was being called
against our own public domain from inside the backspace container, which
fails (Docker NAT loopback) and aborts on timeout.

Add getLocalInviteSnapshot — reads the snapshot directly from the DB —
and branch in dm.ts so local invites bypass the HTTP roundtrip entirely.
Cross-instance invites still go through fetchSpaceInviteSnapshot with
its existing SSRF guard.

Also refactor the GET /api/spaces/invite/:code/preview handler to use
the same helper, keeping the snapshot shape in one place.

Tests assert fetchSpaceInviteSnapshot is NOT called for the local case
(critical regression guard) and that the cross-instance path still hits
the HTTP fetch.
2026-04-29 22:29:44 +02:00
Jannis Braun 5481eb9e7e fix(server): SSRF guard on cross-instance invite preview fetch 2026-04-29 22:19:10 +02:00
Jannis Braun 4476c55963 docs: invite friends overhaul — space_invite system message, relay type field, in-instance /join interception 2026-04-29 22:11:56 +02:00
Jannis Braun 65ca54f556 feat(web): useDeepLinkHandler also subscribes to onOpenInternalRoute 2026-04-29 22:09:20 +02:00
Jannis Braun 7d98558816 feat(web): push connected-instance origins to Electron main process 2026-04-29 22:08:30 +02:00
Jannis Braun b00d21df0e feat(desktop): preload exposes setConnectedOrigins + onOpenInternalRoute 2026-04-29 22:07:26 +02:00
Jannis Braun eb57d2fd5c feat(desktop): intercept own-instance /join URLs in setWindowOpenHandler 2026-04-29 22:05:50 +02:00
Jannis Braun a46d35f5f8 feat(web): InviteModal — friend picker + restyled share-link footer, drop deep link 2026-04-29 22:02:23 +02:00
Jannis Braun 1859f2963c feat(web): SystemMessage dispatches space_invite to SpaceInviteCard 2026-04-29 21:55:06 +02:00
Jannis Braun e351d53dd5 fix(web): SpaceInviteCard — real embed idiom and correct getApiForOrigin import path 2026-04-29 21:52:20 +02:00
Jannis Braun 82533c1434 feat(web): SpaceInviteCard component with snapshot/live/revoked render states
Renders space invite system messages in DMs as embed-style cards on the chat
surface. Three render states: snapshot-only on mount (Join enabled, loading
dot), live-confirmed (memberCount refreshed from preview), revoked (gray-out
+ glass-pill indicator). Join targets the space's home origin via
joinByCode(code, spaceInstanceOrigin || undefined) — the three-way
federation correctness rule. Re-exports getApiForOrigin from api/client to
expose the cross-store resolver under a natural import surface.
2026-04-29 21:50:01 +02:00
Jannis Braun 657cc0e1b9 feat(web): add dm.spaceInvite() api client method 2026-04-29 21:44:40 +02:00
Jannis Braun 7303c23b20 test(server): integration tests for POST /api/dm/space-invite 2026-04-29 21:43:25 +02:00
Jannis Braun 5cb0aa9d66 feat(server): POST /api/dm/space-invite endpoint with rate-limited per-friend invite
Adds POST /api/dm/space-invite which fetches a space-invite snapshot
server-to-server from the space's home instance, ensures a 1-on-1 DM
between the caller and a friend, and posts a type='system' message
carrying SpaceInviteSystemPayload. Snapshot is never trusted from the
client. Rate-limited 30/60s per caller. Federation relay queued when the
recipient is on a remote instance (system message type forwarded by
Tasks 1-3).

Adds an `ensureOneOnOneDmChannel` helper that mirrors the dedup-or-create
behavior of the existing POST /api/dm handler — including federatedId
computation and the dm_channel_created notification payload — without
modifying that handler. Duplication is intentional; consolidation is a
separate follow-up.
2026-04-29 21:37:44 +02:00
Jannis Braun 855410f88c feat(shared): add SpaceInviteRequest / SpaceInviteResponse / SpaceInviteSystemPayload 2026-04-29 21:34:07 +02:00
Jannis Braun 889dfe9b4a feat(server): add fetchSpaceInviteSnapshot helper for cross-instance preview fetch 2026-04-29 21:33:19 +02:00
Jannis Braun c4f84f8c68 test(federation): buildRelayPayload type-field forwarding 2026-04-29 21:31:56 +02:00
Jannis Braun a995bd4148 feat(federation): processCreateEvent inserts system-typed messages from relay 2026-04-29 21:30:51 +02:00
Jannis Braun 6c68b4b574 feat(federation): buildRelayPayload forwards system-typed messages 2026-04-29 21:30:01 +02:00
Jannis Braun 04c66c4bcf feat(federation): add optional type field to FederationRelayEvent.message 2026-04-29 21:29:01 +02:00
Jannis Braun 281e01713b Merge feat/registration-invites: registration toggles + invite links
Adds registrationOpen / federatedRegistrationOpen split, full invite-link
CRUD (create, edit, revoke, reinstate, delete, redemptions viewer),
RegisterPage invite UX with URL + manual entry, federation banner in
Connections, and design polish aligning the panel with PEERED INSTANCES
+ ConnectInstanceModal patterns.
2026-04-29 03:07:44 +02:00
Jannis Braun d31a9cf579 refactor(web): RegistrationPanel — glass-bubble modal buttons + clearer Revoke copy 2026-04-29 03:01:47 +02:00
Jannis Braun f0ac847d9e refactor(web): RegistrationPanel sibling parity — Edit/Reinstate redesign + Redemptions header 2026-04-29 02:51:44 +02:00
Jannis Braun a52bab4c16 refactor(web): CreateInviteModal — icon-badge header + uppercase section labels + dominant submit 2026-04-29 02:47:44 +02:00
Jannis Braun 526043a3b4 feat(web): RegistrationPanel sort + status filter via FilterDropdown 2026-04-29 02:44:32 +02:00
Jannis Braun b91bc89c58 refactor(web): RegistrationPanel InviteRow → collapsed/expanded model 2026-04-29 02:40:33 +02:00
Jannis Braun 3fdf501620 refactor(web): RegistrationPanel tab strip restyle + count badges 2026-04-29 02:36:52 +02:00
Jannis Braun 33547038f4 feat(invites): expose lastRedeemedAt on InviteLinkSummary 2026-04-29 02:33:49 +02:00
Jannis Braun 5abe13166a docs(systems): document invite-links + split registration gate 2026-04-29 01:57:58 +02:00
Jannis Braun a7c321a9d4 feat(web): show federated-registration warning banner in Connections add-instance form 2026-04-29 01:49:32 +02:00
Jannis Braun 641d4efcb9 fix(web): RegisterPage invite-fallback validation, IIFE preference, open-registration silence 2026-04-29 01:46:48 +02:00
Jannis Braun 0dae217787 feat(web): RegisterPage invite-token URL pickup + manual entry + closed-state UX 2026-04-29 01:38:52 +02:00
Jannis Braun 5b51980278 fix(web): make ConfirmDialog Escape capture-phase to prevent leaking to ancestor modals 2026-04-29 01:26:38 +02:00
Jannis Braun bde4d829ef feat(web): RegistrationPanel Edit/Revoke/Reinstate/Delete + Redemptions 2026-04-29 01:21:14 +02:00
Jannis Braun ba5c84fc6b fix(web): add Custom… expiry preset to CreateInviteModal per spec §4.2 2026-04-29 01:13:22 +02:00
Jannis Braun 234ec8e70d feat(web): RegistrationPanel Create-invite modal 2026-04-29 01:06:48 +02:00
Jannis Braun 42d1be54fa fix(web): guard RegistrationPanel invite-list against stale-fetch race 2026-04-29 00:57:57 +02:00
Jannis Braun dcd3daf390 feat(web): RegistrationPanel invite-list with Active/Archived tabs 2026-04-29 00:53:39 +02:00
Jannis Braun 7dd6bd6263 refactor(web): move registrationOpen toggle out of GeneralPanel 2026-04-29 00:44:35 +02:00
Jannis Braun cad6c50bff feat(web): RegistrationPanel scaffold with both registration toggles 2026-04-29 00:40:04 +02:00
Jannis Braun 7edfd8ae26 refactor(web): rename invites.patch → invites.update for codebase consistency
Other namespaces use 'update' (users.update, spaces.update, channels.update,
roles.update); the new 'invites.patch' breaks the pattern. Renamed before
Tasks 17-19 import it. Plan updated to match.
2026-04-28 21:11:43 +02:00
Jannis Braun 50756b3e65 feat(web): add invites + checkInvite to api client 2026-04-28 21:08:12 +02:00
Jannis Braun dbcc399ebb test(settings): tighten 'preserves field when omitted' to actually prove preservation
The original test seeded the DB with the schema default (1) and asserted
the response was true after a partial PATCH. That passes both for
'untouched' and 'reset to default' — doesn't distinguish them. Now the
test toggles the DB column to false BEFORE the PATCH, then asserts the
false value survives both in the response AND in the DB row directly.
2026-04-28 21:06:05 +02:00
Jannis Braun 8d7ba33c21 feat(settings): expose federatedRegistrationOpen in /settings/instance + /instance/info
Surfaces the federatedRegistrationOpen flag (Task 1 schema column) on the
admin settings GET/PATCH endpoints and the public /api/instance/info
endpoint. Closes the 3 deferred TypeScript errors from Task 2 by
populating the now-required InstanceAdminSettings/InstanceInfoResponse
field.

Adds smoke tests (routes/instance.test.ts, routes/settings.test.ts) that
lock in the JSON contract the Connections UI (Task 21) and admin
RegistrationPanel (Task 15) consume, plus boolean-validation coverage
for the PATCH path. Updates docs/systems/admin.md with the new field in
both InstanceAdminSettings and the public info response schema.
2026-04-28 21:01:50 +02:00
Jannis Braun 87301bd4d2 test(auth): polish register handler — comment, test isolation, +1 coverage
Quality-review polish on Task 11:

1. One-line comment near the federatedRegistrationOpen default behavior
   noting that the missing-row case is unreachable post-migration but
   falls federation-closed defensively (asymmetric with registrationOpen
   which falls back to env config — by design).

2. The "federated gate blocks token registration" test now sets ONLY
   federatedRegistrationOpen=0, isolating the federated-gate-alone
   effect rather than a both-gates-closed compound.

3. New test: open registration + revoked token → 201 (silently ignored).
   Locks the spec §5.7 invariant "no validation when registration is open"
   against future "let's just validate it for safety" regressions.

4. auth.md prose explicitly notes that federated stub upgrade and new-
   account paths do NOT enter redeemInvite — surfacing the structural
   enforcement of spec §1.3 "tokens never unlock federated creation".
2026-04-28 20:58:16 +02:00
Jannis Braun 0559ea369b feat(auth): split registration gate by homeInstance + atomic invite redemption
The /api/auth/register handler now branches on homeInstance:

- Local path (no homeInstance): gated by registrationOpen. When closed, a
  valid inviteToken bypasses the gate and is consumed atomically inside
  redeemInvite()'s transaction (user insert + usedCount bump + redemption
  row all commit together, or all roll back). When open, inviteToken is
  silently ignored.

- Federated path (homeInstance set): gated by federatedRegistrationOpen.
  Token is ignored entirely on this path -- tokens never unlock federated
  creation. Closed → 403 with "Federated registration is closed".

InviteUnavailableError thrown by redeemInvite() (concurrent revoke,
last-slot race, expiry between check-invite and submit) is mapped to 403
"Invalid or expired invite". The in-txn re-derive closes the TOCTOU window.

9 new tests cover the toggle matrix from spec §5.6 + invite consumption
semantics + federated-gate independence + last-slot race rejection.

Updates docs/systems/auth.md: rewrites the Registration Gate section to
describe the three-path model (open / invite / federated), adds the toggle
matrix, adds an Invite Tokens subsection with the atomic-redemption shape,
notes that the federated stub upgrade is always gated by
federatedRegistrationOpen, never by an invite token.
2026-04-28 20:51:49 +02:00
Jannis Braun 1b76b8bf2a test(invites): tighten check-invite assertions to byte-identical responses
Per quality review: replace per-property assertions with toEqual()
object-equality on the invalid-response bodies. Locks the enumeration-
shield contract — revoked/unknown/malformed/missing must all return the
SAME body, not just bodies that happen to satisfy individual assertions.
2026-04-28 20:46:24 +02:00
Jannis Braun effb36c7f4 feat(auth): GET /api/auth/check-invite with collapsed enumeration shield 2026-04-28 20:42:46 +02:00
Jannis Braun ade5c0d998 test(invites): tighten requireAdmin mock to match real Fastify contract 2026-04-28 20:40:24 +02:00
Jannis Braun 0c9865ad90 feat(invites): /api/admin/invites CRUD endpoints 2026-04-28 20:36:09 +02:00
Jannis Braun b775e3bcc9 refactor(invites): typed InviteUnavailableReason + real rollback test
Quality-review polish on Task 8:

1. InviteUnavailableError gains a typed public readonly `reason` field
   (the union 'not found' | 'revoked' | 'expired' | 'exhausted'). Task 11
   route handler can switch on the discriminant to produce user-facing
   copy without parsing the message string.

2. The original "aborts transaction if insertUser throws" test was
   vacuous — the callback threw before any DB write, so SQLite ROLLBACK
   never fired and the post-conditions were trivially true. Replaced
   with two tests: one that explicitly validates the synchronous
   short-circuit (no DB work happens at all), and a second that writes
   a real users row inside the callback then throws AFTER the write,
   proving the SQLite ROLLBACK actually reverts the in-callback write.
2026-04-28 20:32:42 +02:00
Jannis Braun 95737ba405 feat(invites): redeemInvite (atomic txn) + deleteInvite 2026-04-28 20:26:57 +02:00
Jannis Braun b7557aa6fa test(invites): document empty-updates guard + Path A rollback test
Quality-review polish: a one-line comment over the empty-updates guard
in reinstateInvite explains why removing it would re-leak a confusing
Drizzle error. A new test verifies that when Path A (revoked->active)
fails its post-state check, the original token is preserved by the
SQLite transaction rollback (not replaced by the would-be new token).
2026-04-28 20:24:15 +02:00
Jannis Braun ee405cba7c feat(invites): reinstateInvite with revoked-vs-archived branching 2026-04-28 20:20:13 +02:00
Jannis Braun 45b2eabed3 refactor(invites): thread tx through resolveCreatorUsername + tighten test assertions
Inside patchInvite/revokeInvite txn bodies, all reads now go through
the tx proxy. Pre-Task-7 hygiene: locks in the consistent pattern that
reinstateInvite (Task 7) and redeemInvite (Task 8) will copy.

Createinvite test failures now pin to InviteValidationError, catching
regressions where the wrong error class would otherwise pass silently.
2026-04-28 20:17:41 +02:00
Jannis Braun 23338419df feat(invites): patchInvite + revokeInvite with txn re-derive + foldUsername helper
Both functions wrap their read-modify-write in a Drizzle better-sqlite3
db.transaction((tx) => ...) with in-txn re-fetch so concurrent admin
mutations are serialized by SQLite's writer lock.

- patchInvite: 404 on missing, 409 on revoked, 400 on maxUses < usedCount,
  allows expiresAt to be moved into the past (effective soft-shut).
- revokeInvite: 404 on missing, 409 on already-revoked (explicit reject,
  not silent no-op).

Also extracts foldUsername() to collapse the duplicated
(username, isDeleted) -> display string fold across resolveCreatorUsername,
listInvites, and listRedemptions (deferred refactor from Task 5 review).

Note: the plan's example used db.transaction(cb)() with an IIFE,
which is the raw better-sqlite3 signature. Drizzle's wrapper
returns the callback's return value directly, so we use the
(tx) => ... form consistent with the rest of the codebase
(userDeletion, federation, channels, etc.).

Tests: 36 invite-service tests pass (27 prior + 9 new).
Full server suite: 40 files / 311 tests pass.
2026-04-28 20:11:47 +02:00
Jannis Braun ce5d4c10c8 feat(invites): listInvites + listRedemptions with creator/current JOINs
Adds two query helpers to inviteService:

- listInvites(filter): single-query LEFT JOIN against users to surface
  createdByUsername, with status filtered in TS via the canonical
  inviteStatus() derivation. Avoids N+1 the spec calls out (§3.1).
  'archived' = expired | exhausted | revoked. Sort: createdAt DESC.

- listRedemptions(inviteId): LEFT JOIN against users via userId to
  expose currentUsername alongside the registrantUsername snapshot.
  Three null-handling branches per spec §3.1: live (username),
  tombstoned ('Deleted User', isDeleted=true), and hard-deleted
  (userId null, currentUsername null, isDeleted false).
  Sort: redeemedAt DESC.

Also fixes a mistitled DB-miss test in getInviteByToken: the original
'returns null when token not found' used a 24-char string that fails
the format regex *before* the DB lookup. Split into two tests covering
both the format-reject path and the well-formed-but-missing path.

40 files / 302 tests passing.
2026-04-28 20:04:18 +02:00
Jannis Braun daf2e958f7 feat(invites): createInvite + getInviteByToken with validation 2026-04-28 19:58:03 +02:00
Jannis Braun 967c62d488 feat(invites): inviteStatus derivation + token generator 2026-04-28 19:52:16 +02:00
Jannis Braun e984ce910c refactor(shared): rename PatchInviteRequest → UpdateInviteRequest + JSDoc
Matches existing codebase convention (UpdateChannelRequest,
UpdateSpaceRequest, etc.). Adds JSDoc on InviteStatus and
InviteLinkSummary.url / createdByUsername fields with server-side
semantics. Plan updated for downstream task naming consistency.
2026-04-28 19:50:05 +02:00
Jannis Braun 884b3d9fe9 feat(shared): add invite link types + federatedRegistrationOpen 2026-04-28 19:45:25 +02:00
Jannis Braun 22788890f3 feat(db): add invite_links + invite_redemptions tables + federatedRegistrationOpen 2026-04-28 18:52:15 +02:00
Jannis Braun 76b6621d62 feat(storage): remove upload-size cap, add MB/GB unit toggle
Storage panel's max-upload-size input now accepts any positive integer
(bounded only by JS safe-integer ceiling) and offers an MB/GB unit
toggle. Multipart limit relaxed to MAX_SAFE_INTEGER — actual cap is
enforced per-request from the DB setting, not at the framework layer.
2026-04-28 18:37:16 +02:00
Jannis Braun 213fbc943b docs(sounds): correct self-stream-end suppression note (synchronous, not deferred) 2026-04-28 15:03:22 +02:00
Jannis Braun 89b6c80031 docs: cross-reference sounds.md from voice.md + CLAUDE.md subsystem table 2026-04-28 14:55:39 +02:00
Jannis Braun 37de0feb31 docs(systems): add sounds.md — inventory + trigger map + mechanism notes 2026-04-28 14:54:11 +02:00
Jannis Braun 82ae487765 feat(sounds): add 'Play sound for every message' toggle to Voice settings 2026-04-28 14:50:41 +02:00
Jannis Braun 081937a030 fix(sounds): drop singleton AudioManager from SoundController effect deps 2026-04-28 14:49:03 +02:00
Jannis Braun 494585adf2 fix(sounds): full SoundController rewrite — every file fires for the right audience
- stream_started/ended audible to all (was self-only)
- stream_user_joined/left wired to the watcher data-channel diff (was misused)
- effective-mute/deafen audible mid-call (was self-toggle only)
- message sound DM + federation-aware mention default (was every channel)
- self-stream-end suppresses per-watcher stream_user_left stack
2026-04-28 14:43:05 +02:00
Jannis Braun 7c0b67bcc9 feat(sounds): broadcast stream_watch from StreamTile click sites
Wire broadcastStreamWatch (LiveKit data-channel ping) to all three
explicit user-action sites in StreamTile: "Stop Watching" context-menu
item, "Watch Stream" context-menu item, and the handleWatch button
callback. Broadcast is intentionally confined to click sites only —
not wired in voiceStore actions — to prevent noisy stream_user_left
sounds on automatic teardown paths in useLiveKit.ts.
2026-04-28 14:38:39 +02:00
Jannis Braun 62aa87c737 feat(sounds): receive stream_watch pings + evict watchers on participant disconnect 2026-04-28 14:35:14 +02:00
Jannis Braun 005559d95e feat(sounds): add streamWatchers map + recordStreamWatch/clearStreamWatchers/evictWatcher 2026-04-28 14:30:45 +02:00
Jannis Braun a3bd0313a8 feat(sounds): add messageSoundAllChannels preference to voiceStore 2026-04-28 14:26:41 +02:00
Jannis Braun 00daa14dcd test(sounds): use realm-safe Uint8Array check (TextEncoder vs jsdom)
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.
2026-04-28 14:25:25 +02:00
Jannis Braun dacb6df871 test(sounds): tighten encodeStreamWatch round-trip assertion to verify Uint8Array contract 2026-04-28 14:23:54 +02:00
Jannis Braun 6a4eac13a3 feat(sounds): add stream_watch data-channel protocol helpers 2026-04-28 14:19:41 +02:00
Jannis Braun 40dd2ab52f feat(sounds): add shouldPlayMessageSound filter (DM + federation-aware mention) 2026-04-28 14:13:56 +02:00
Jannis Braun eede7aeb38 chore(branding): clean up product description strings
Strip the desktop package description to "Backspace" so Windows Task
Manager shows the bare product name instead of the long tagline.
Unify the web meta description and PWA manifest description on a single
positioning line that names Discord and TeamSpeak as the comparison
targets — improves link-preview copy and SEO surface.
2026-04-28 11:39:08 +02:00
Jannis Braun cf72f9d87d docs(voice): note dormant-by-default preview in two-mode preview section 2026-04-27 21:15:06 +02:00
Jannis Braun 4748df8b24 fix(camera): dormant-by-default preview, never trigger camera on tab open
Voice & Video tab no longer fires getUserMedia on mount. Uses
navigator.permissions.query({name:'camera'}) for state detection,
which is passive (no LED activation). Preview only opens when the
user explicitly clicks the dormant tile or the prompt-state CTA.
In-call mode unchanged (attaches existing LK track, no extra LED).

Spec and plan updated to reflect the corrected design — the
"auto-start preview, no Test Camera toggle" rationale was wrong;
macOS holds the camera LED on for ~2s after release, so any
incidental getUserMedia call (probe, transient mount) flashes the
LED in the user's face even when they aren't on the Voice & Video
panel. Privacy-correct UX: never light the LED without an explicit
user action.
2026-04-27 21:13:58 +02:00
Jannis Braun 02e881fd00 docs(voice): document camera device selection subsystem 2026-04-27 20:57:13 +02:00
Jannis Braun 2bec5b9d7f feat(settings): rename Voice tab to 'Voice & Video' to reflect added video section 2026-04-27 20:55:42 +02:00
Jannis Braun 93e1478346 feat(voice-panel): mount Video section between Volume and Voice Processing 2026-04-27 20:47:19 +02:00
Jannis Braun 47653ab14a feat(video-section): tab-visibility cleanup and currently-using subline 2026-04-27 20:46:04 +02:00
Jannis Braun 37caf30b84 feat(video-section): in-call preview attaches to LK track
Two-mode preview: in-call attaches to the LiveKit local camera track
(no double-capture); pre-call uses getUserMedia. Modes transition
reactively on isCameraOn changes.
2026-04-27 20:43:40 +02:00
Jannis Braun 4c6bc9e822 feat(video-section): pre-call preview tile auto-starts getUserMedia 2026-04-27 20:41:03 +02:00
Jannis Braun 9b402ed291 feat(video-section): enumerate cameras and render dropdown
Empty labels fall back to 'Camera N'; duplicate labels are
disambiguated with ' (1)', ' (2)' suffixes by enumeration order.
'No cameras detected' subline appears when enumeration is empty.
2026-04-27 20:39:29 +02:00
Jannis Braun 5494460834 feat(video-section): scaffold component with permission-probe state machine 2026-04-27 20:35:29 +02:00
Jannis Braun c9b08cdfd2 feat(devices): sweep stale persisted device ids on mount and devicechange 2026-04-27 20:30:54 +02:00
Jannis Braun 786823946a feat(camera): handle camera-track end with branched toast (unplug vs permission revoke)
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.
2026-04-27 20:29:32 +02:00
Jannis Braun e8534339fb feat(camera): syncCamera effect hot-swaps publication on cameraDeviceId change
Uses room.switchActiveDevice('videoinput', id) to swap the underlying
MediaStreamTrack without re-publishing. Compares against the published
track's actual getSettings().deviceId so null→explicit-same-device is a
no-op. Rolls back on failure with a 'Could not switch camera' toast.
2026-04-27 20:23:00 +02:00
Jannis Braun 2e41edbc99 feat(camera): use cameraDeviceId from voiceStore on camera enable 2026-04-27 20:17:12 +02:00
Jannis Braun 51ad54fda5 refactor(camera): unify toggle paths through handleCameraAction
- 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
2026-04-27 20:16:14 +02:00
Jannis Braun 486448607e feat(voice-store): add pruneStaleDevices to clear stale persisted device ids 2026-04-27 20:08:42 +02:00
Jannis Braun 770a77a473 feat(voice-store): add cameraDeviceId persisted field 2026-04-27 20:07:23 +02:00
Jannis Braun 8ba644fa44 fix(message-list): pagination flag and scroll restore leak across channel switches
Two bugs in handleScroll's loadMoreMessages flow surfaced after the
smooth-scroll race fix.

(1) isLoadingMore stuck across channels. setIsLoadingMore(true) → await
loadMoreMessages → setIsLoadingMore(false) was unguarded. If the user
switched channels during the await, the new channel inherited the flag (same
component instance, same useState slot) and rendered the pagination skeleton
even with no load in flight. Cleared only when the original await resolved
or the component remounted (e.g., navigating to Friends and back).

(2) Wrong-channel scroll restore. The post-await rAF set
container.scrollTop = container.scrollHeight - prevScrollHeight against the
new channel's container with the old channel's prevScrollHeight, yanking
the new channel to a wrong position.

Fix:
- try/finally around the await so setIsLoadingMore(false) always runs.
- currentChannelIdRef tracks the live channelId; capture requestChannelId at
  load start and compare both before scheduling the rAF and inside the rAF
  callback (the 16ms frame gap is enough for a switch).
- Belt-and-suspenders: setIsLoadingMore(false) in the channel-switch effect
  covers the case where the await never resolves (network hang). Without it,
  a stuck await would leave the new channel inheriting the flag indefinitely.

No request cancellation — out of scope; AbortController plumbing through
chatStore is a bigger refactor and the channelId guard already silently
drops stale results.

Spec updated. Smooth-scroll fix from the previous commit untouched.
2026-04-27 18:11:31 +02:00
Jannis Braun b6b830568c fix(message-list): close smooth-scroll-to-bottom race against late-loading media
Smooth scrolls toward the bottom (new-message arrival in Effect A and the
Jump-to-Present click) animate scrollTop over many frames. Each intermediate
handleScroll measurement saw a large distanceFromBottom and flipped
isAtBottomRef to false, closing the Effect B/C gates. Lazy media (avatars,
embeds, Spotify thumbs) finishing mid-animation grew scrollHeight while the
gate was closed, so the smooth scroll landed at its originally-computed
target — leaving the user above the new bottom by ~the height of what loaded.

Fix: typed smoothScrollIntentRef ('bottom' | 'message' | null) with an 800ms
deadline. handleScroll suppresses the at-bottom flip while intent is 'bottom'
and the user hasn't wheeled past the 5000px nearBottom threshold. Effect D
fires a final defensive instant pin via native scrollend (Chrome 114+,
Safari 18+) or a setTimeout(800) fallback. 'message' intent (jump-to-message
from search) does NOT suppress — the gate flips honestly so the user is left
at the targeted message.

Verified live on nova.ddns.net Orbit → general: Jump-to-Present
lands flush at bottom; new Spotify-link messages stay at bottom as embeds
arrive via WS. docs/systems/message-list.md updated.
2026-04-27 17:51:55 +02:00
Jannis Braun de2ef10129 fix(brand): home tile = Element 1 with #000 bg, drop tile lavender
Iterating-by-paint-over wasn't working. Going back to first
principles: the brand IS the badge — full Element 1 mass, mark with
its own internal padding, dark frame around it. That's what reads as
"Backspace" on every other surface (dock, taskbar, favicon, apple-
touch). The sidebar home tile should look the same.

The earlier "ugly grey" complaint about Element 1 in the sidebar
came down to one specific colour mismatch: the source SVG's
#1d1d1b (warm near-black) sat next to the sidebar's #1a1a23 (cool
near-black) and read as an off-grey rectangle. Fix: in the generator,
do a string-replace `#1d1d1b → #000000` on app-icon.svg before
rendering logo.png only — every other output (.icns, .ico, favicons,
PWA) keeps the original brand `#1d1d1b`. Pure black against the
sidebar's #1a1a23 reads as a deliberately darker tile, not a hue
mismatch.

Reverts the SpaceSidebar lavender-tile workaround: with the badge
filling the tile via object-cover, the button's overflow-hidden +
rounded-[20px → 13px] morph already animates the badge cleanly.
backgroundStyle for type === 'dm' returns undefined again; deps trim
back to what they were.

Determinism gate verified — only logo.png changed (other 21 outputs
byte-identical across two regen runs).
2026-04-27 15:39:54 +02:00
Jannis Braun 343fdd6e3d fix(sidebar): home tile uses brand lavender, not neutral grey
Translucent white over the cool-dark sidebar reads as muddy grey;
that's the same root cause as the earlier #1d1d1b warm-grey complaint.
Any unsaturated tint loses against the dark sidebar bg.

Switching the home tile to accent-lavender (RGB 196,181,253 — the
endpoint of the mark's gradient) breaks the grey because saturation
defeats the grey-on-grey muddiness. Lavender also (a) complements
the gradient mark sitting on top, (b) parallels Discord's brand-
coloured home button pattern, and (c) gives the sidebar a clear
semantic colour hierarchy: action = mint, home = lavender (brand),
spaces = user-defined.

Three states escalate by alpha (0.08 / 0.16 / 0.28) so corner-morph
and fill-brightness both signal interaction.
2026-04-27 15:32:48 +02:00