Skip to content

feat(settings): Profile settings — display name, bio, avatar, theme, message length, password (G34) #46

Description

@Adron

Summary

You cannot edit your own display name or bio from the macOS app at all. UpdateUserRequest already carries displayName, bio and theme (UserDTO.swift:169–171) and nothing calls them. Several other documented Profile settings are equally absent.

The documented pane (/help/settings ▸ Profile settings)

Setting Documented macOS today
Display Name "How you appear to others (defaults to your username if not set)" ❌
Bio "Short description shown on your public profile" ❌
Avatar "upload a file or set from a URL" ❌
Theme Light / dark / system ❌ (deliberately deferred — values were unconfirmed)
Max message length "Character limit per message (default 666)" ❌

Plus, from Security: Change password and Forgot password.

Live facts (probed 2026-09-07)

GET /api/user on the test account returns "theme":"light" and "maxMessageLength":666, so both are real, populated fields.

POST /api/user/avatar/from-url exists in UserEndpoint with no UserServicing method behind it.

POST /api/auth/forgot-password is already in Kit (AuthEndpoint.swift:19–26) and is not wired to any UI.

⚠️ There is no change-password route in the live spec. The only password routes are POST /api/auth/forgot-password and POST /api/auth/reset-password (plus an admin-only one). The web's "Change password: enter your current password and a new one" has no documented public endpoint. Probe before building — either find the route the web form posts to, or implement the reset-by-email flow and say so plainly in the pane. Do not guess a path.

Two things to get right

  1. Theme. UserSettings deliberately omits it because the valid value set was unconfirmed. The web offers exactly three (System / Light / Dark) and the live account holds "light". Probe PATCH /api/user/update with each of the three before widening the typed surface, and keep an .unknown(String) case so a future value cannot fail the decode.
  2. Max message length is not the server ceiling. GET /api/limits (shipped as G14) is the platform maximum; maxMessageLength on the account is a user-chosen cap underneath it. The composer must honour the lower of the two, and the pane must say which is which. Getting this backwards would let a user set a limit the server rejects, or silently raise their own ceiling past the platform's.

maxMessageLength is on UserDTO but absent from UpdateUserRequest — that is a Kit change, not just a UI one.

Division of labor

Kit

  • Add maxMessageLength to UpdateUserRequest.
  • Confirm avatar upload: POST /api/user/avatar/from-url is present; check whether a multipart file-upload route also exists and add it if so.
  • Probe and add the change-password route, or record its absence in the issue and fall back to reset-by-email.

Domain

  • Extend UserSettings (or a sibling ProfileSettings) with displayName, bio, theme, maxMessageLength; keep the existing change-gated PATCH discipline so untouched fields are never clobbered.
  • UserServicing methods for avatar-from-URL and avatar-file-upload.
  • A ContentLimits + account-cap reconciliation helper so the composer has one number to ask for.
  • Password reset / change service method.

App

  • A Profile pane in Settings: display name, bio, avatar (file picker and from-URL field, matching the web), theme picker, max-message-length stepper.
  • Reuse the existing change-gated Save pattern from PreferencesView — do not introduce a second save idiom.
  • Security section: change password (or reset-by-email) and forgot-password.
  • Theme: applying it must actually drive the app's appearance, not just persist a string. Decide whether the account theme overrides the system appearance or seeds it, and document the choice.

Tests

  • happy — each field round-trips through PATCH
  • invalid — empty display name falls back to the username; over-long bio rejected client-side
  • upstream-failure — PATCH fails → prior values restored, error surfaced, nothing half-saved
  • boundary — maxMessageLength at, below, and above the GET /api/limits ceiling; composer honours the lower of the two

Acceptance criteria

  • A user can set their display name, bio, and avatar without leaving the app.
  • The composer's character budget matches whatever the account cap says.
  • No password UI ships pointed at an unverified route.
  • Full E2E gate green.

Notes

work-consolidation.md tracks this as G34. Size M.


Implementation plan (added 2026-09-15) — all four probes done

Every "probe before building" in this issue was resolved live on 2026-09-15.

1. Theme — the three work, and so does anything else

PATCH /api/user/update {"theme":"system"}   → 200, stored "system"
                       {"theme":"dark"}     → 200, stored "dark"
                       {"theme":"light"}    → 200, stored "light"
                       {"theme":"auto"}     → 200, stored "auto"
                       {"theme":"nonsense"} → 200, stored "nonsense"

The field is unvalidated. So .unknown(String) is not defensive padding — it is the documented behaviour, and the picker must offer the account's own value when it is unrecognised. Otherwise the Picker has a selection matching no tag (SwiftUI renders a blank control) and the first edit to any other field silently rewrites the theme. Pinned by a test that asserts the PATCH body omits an untouched unknown theme.

2. There is no change-password route

The live spec carries only /api/auth/forgot-password, /api/auth/reset-password, and an admin-only /api/admin/users/{userId}/password. The web's "enter your current password and a new one" form has no public endpoint.

So the pane ships reset-by-email and says so plainly. No password UI points at an unverified route, which is this issue's own acceptance criterion.

3. maxMessageLength — and the ceiling trap, confirmed reachable

GET  /api/limits              → message.maxContentLength: 5000   ← the platform
PATCH {"maxMessageLength":500}   → 200
PATCH {"maxMessageLength":99999} → 400 "must be a positive integer between 1 and 10000"
PATCH {"maxMessageLength":0}     → 400 same

The account range reaches 10000 while the platform stops at 5000 — so a user really can set a cap above the ceiling. This issue named the trap and the numbers make it reachable rather than theoretical.

  • ContentLimits.effectiveMessageLength(accountCap:) is the single place that decides, returning the lower of the two.
  • ComposerViewModel.refreshLimits() used the platform ceiling alone, ignoring a cap the user had deliberately set. Fixed here.
  • CurrentUser carries maxMessageLength, for the same reason it carries defaultPubliclyVisible: it is on the GET /api/user payload already, so the composer gets it session-cached with no extra round-trip.
  • The setter clamps to 1...10000, so no caller can earn the 400.
  • The pane shows both numbers and warns when the account cap is the one not in force.

4. Avatar — both halves exist

POST /api/user/avatar/upload and POST /api/user/avatar/from-url. The pane offers a file picker and a URL field, matching the web. uploadAvatar already had a service method; setAvatarFromURL did not.

The avatar is written by its own route, outside the change-gated body — so both the working copy and the saved snapshot are updated, or Save would light up claiming an unsaved change that does not exist.

Division of labour as built

Kit — UpdateUserRequest += maxMessageLength (it was on UserDTO and absent from the request, so the value was readable and unwritable).

Domain — ProfileSettings + AppTheme + ProfileLocation; ContentLimits.effectiveMessageLength(accountCap:); CurrentUser.maxMessageLength; UserServicing.profileSettings / updateProfileSettings(_:changedFrom:) / setAvatarFromURL / requestPasswordReset.

ProfileSettings is kept separate from UserSettings on purpose: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave".

App — ProfileSettingsView + view model, registered as a Settings tab. Reuses PreferencesView's change-gated Save idiom rather than introducing a second save shape, as this issue asked.

Profile location — read-only, and #57 is why

Folded in from #57, which was re-scoped after its own probe: a location can be set through the API and cleared through nothing (#91). The pane therefore shows a published location — closing the "invisible" half of #57 — and offers no setter, because shipping set-without-clear would make macOS a way to publish an approximate home location that the user can never take back.

On the theme not driving appearance

This issue asked whether the account theme should override or seed the system appearance. Neither, for now: the value is stored on the account and applies on the web, and the Mac app follows the system appearance. Driving NSApp.appearance from a server preference is a behaviour change worth its own decision, and the pane says plainly what the setting does today rather than implying more.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestparityWeb-parity gap with the InterlinedList web app

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions