User Settings
A Discord-style fullscreen settings dialog for message/communication preferences, kept deliberately separate from the global account/profile settings on the /user/settings route. The split follows the backend boundary: the dialog holds exactly what the userSettingsInMessage table persists; the route holds users-backed identity.
The two surfaces
| Surface | Panels | Persistence |
|---|---|---|
| Message-scoped dialog | Appearance · Voice & Video · Notifications · Keybinds | userSettingsInMessage (synced); device IDs + UI collapsibles in localStorage |
Global route /user/settings | Account · Profile | users (UserIntroductionCard + UserProfileCard + SAS avatar upload) |
| Theme | top-right toggle, not a panel | cookie (THEME_COOKIE_NAME) — hard SSR constraint (flash-free first paint) |
The dialog is opened by the gear in MessageLeftSideBarStatusBar and mirrors the room settings pattern (SettingsType enum → list-item map → content map → Type/* panels) with its own parallel wrappers under Message/Model/User/Settings/. Unlike room settings there is no permission gating — every panel is self-scoped to the current user.
Both settings dialogs share three conventions: panels are lazy async components rendered inside <Suspense> with a shared MessageModelSettingsSkeleton fallback (shown on every tab switch); every settings mutation is optimistic (apply to the store immediately, mutate in the background, roll back + surface the error on failure — useMutation); and the sidebar section rail is StyledSlideIndicator stretched across all visible sections, pinned to the target while a click-scroll runs.
They also share the responsive shell: the sidebar drawer (MessageModelSettingsLeftSideBar) is permanent only on desktop and becomes a temporary overlay on smAndDown, opened by a mdi-menu hamburger the content header renders on mobile and closed on selection. The user dialog holds that open flag as isDrawerOpen on its dialog store; the room dialog threads it through its Dialog. See room settings for the diagram.
Sync by default, per-device by exception: preferences live in the DB and sync across devices; only hardware device IDs (mic/speaker/camera — a device chosen on one machine must not apply on another) and UI collapsibles stay localStorage.
Data model
userSettingsInMessage (packages/db-schema/src/schema/userSettingsInMessage.ts), 1:1 on userId (PK, cascade), under messageSchema:
| Column | Type | Default |
|---|---|---|
voiceInputMode | voice_input_mode enum (VoiceActivity | PushToTalk) | VoiceActivity |
pushToTalkKeybind | text | "" |
pushToTalkReleaseDelayMs | integer (CHECK 0..2000) | 20 |
inputSensitivityDecibels | integer (CHECK −100..0) | −50 |
microphoneVolumePercentage / speakerVolumePercentage | integer | 100 |
noiseSuppressionMode | enum → voice & video settings | |
isMuteOnJoin / isDeafenOnJoin | boolean | false |
autoIdleThresholdMs | integer (CHECK 60_000..86_400_000) | 600_000 |
Every column is communication-scoped — no account/profile/theme columns, reinforcing the surface split. Read returns the row or an unpersisted defaults object; the first update upserts (onConflictDoUpdate on userId).
Procedures
On the user router:
| Procedure | Auth | Input | Purpose |
|---|---|---|---|
readUserSettings | authed | — | the user's row, or defaults if none yet |
updateUserSettings | authed | partial settings (updateUserSettingsInputSchema, refineAtLeastOne) | upsert; returns the full row |
The client store (store/message/user/settings/index.ts) applies updates optimistically and reverts + alerts on failure. The read is one record for the whole session, so it goes through useCachedRead — every surface that raises the dialog asks for it, the concurrent asks join one request, and no write invalidates it because updateUserSettings stores the row it is answered with.
Navigation / scrollspy
The dialog uses a Discord-style two-level nav: a v-list-group per UserSettingsListItemMap category whose sections come from UserSettingsSectionMap (per-panel subsection enums whose values double as section title and DOM id).
- Scroll tracking is the repo-wide mechanism, not one written for this dialog:
useVisibleSectionIdsover the panel's section ids, bounded by the scroll container, and theStyledSlideIndicatorrail stretched across every section currently on screen. What that guarantees, and why it is neither a scroll handler norv-intersect, is section navigation. - The panel header sits outside the scroll container (the shared shell's fixed
#headerslot above theflex-1scroll div). That structural choice is what keeps the scrollspy free of offset math here: a section clipped above the scroll area is genuinely not visible, anduseVGoTolands a section title just below the header.
Key files
| File | Role |
|---|---|
packages/db-schema/src/schema/userSettingsInMessage.ts | table + enums + range constants |
packages/app/server/trpc/routers/user.ts | readUserSettings + updateUserSettings |
packages/app/app/models/message/user/UserSettingsType.ts | panel enum (values double as titles) |
packages/app/app/services/message/user/settings/ | list-item / content / section maps |
packages/app/app/store/message/user/settings/index.ts | DB-backed store (optimistic + revert) |
packages/app/app/store/message/user/settings/voiceDevice.ts | device-local store (localStorage device IDs) |
packages/app/app/store/message/user/settings/dialog.ts | dialog UI store (visibility, panel, mobile isDrawerOpen) |
packages/app/app/components/Message/Model/User/Settings/ | dialog + wrappers + Type/* panels |
packages/app/app/pages/user/settings.vue | global account/profile surface |
Notes
The Voice & Video panel's content and how each setting applies to live LiveKit calls is its own page: voice & video settings. The Appearance panel's Message Display density is covered by room UI, and per-participant call volume — which is not a column here — by per-user volume.
