Esposter
Esposter

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

SurfacePanelsPersistence
Message-scoped dialogAppearance · Voice & Video · Notifications · KeybindsuserSettingsInMessage (synced); device IDs + UI collapsibles in localStorage
Global route /user/settingsAccount · Profileusers (UserIntroductionCard + UserProfileCard + SAS avatar upload)
Themetop-right toggle, not a panelcookie (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:

ColumnTypeDefault
voiceInputModevoice_input_mode enum (VoiceActivity | PushToTalk)VoiceActivity
pushToTalkKeybindtext""
pushToTalkReleaseDelayMsinteger (CHECK 0..2000)20
inputSensitivityDecibelsinteger (CHECK −100..0)−50
microphoneVolumePercentage / speakerVolumePercentageinteger100
noiseSuppressionModeenum → voice & video settings
isMuteOnJoin / isDeafenOnJoinbooleanfalse
autoIdleThresholdMsinteger (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:

ProcedureAuthInputPurpose
readUserSettingsauthedthe user's row, or defaults if none yet
updateUserSettingsauthedpartial 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.

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: useVisibleSectionIds over the panel's section ids, bounded by the scroll container, and the StyledSlideIndicator rail stretched across every section currently on screen. What that guarantees, and why it is neither a scroll handler nor v-intersect, is section navigation.
  • The panel header sits outside the scroll container (the shared shell's fixed #header slot above the flex-1 scroll 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, and useVGoTo lands a section title just below the header.

Key files

FileRole
packages/db-schema/src/schema/userSettingsInMessage.tstable + enums + range constants
packages/app/server/trpc/routers/user.tsreadUserSettings + updateUserSettings
packages/app/app/models/message/user/UserSettingsType.tspanel 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.tsDB-backed store (optimistic + revert)
packages/app/app/store/message/user/settings/voiceDevice.tsdevice-local store (localStorage device IDs)
packages/app/app/store/message/user/settings/dialog.tsdialog UI store (visibility, panel, mobile isDrawerOpen)
packages/app/app/components/Message/Model/User/Settings/dialog + wrappers + Type/* panels
packages/app/app/pages/user/settings.vueglobal 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.