Commit Graph
1706 Commits
Author SHA1 Message Date
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