Skip to content

Settings: edit every server-side preference, and honor theme for real (#46) - #167

Closed
Adron wants to merge 1 commit into
issue-127-wire-typesfrom
issue-46-honor-preferences
Closed

Adron wants to merge 1 commit into
issue-127-wire-typesfrom
issue-46-honor-preferences

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

This is PR 2 of a 3-PR stack. Do not merge out of order.

  1. fix(models): reconcile Message and CurrentUser against live payloads #131 issue-127-wire-types — adds the 14 missing CurrentUser fields + the derived helpers
  2. this PR → base issue-127-wire-types — makes them editable, honors theme
  3. Settings: account-status banner and gated-feature messaging #50 follow-up → base issue-46-honor-preferences — the account-status banner

Every field this PR edits comes from #131, so this is unreviewable and unbuildable against main on its own.

What this does

#131 did the modelling. This does the honoring — or as much of it as the file boundaries allow.

  • A new Preferences card in SettingsView for all ten writable preferences: theme, home-feed filter, max message length, messages per page, alerts tray limit, link previews, default post visibility, advanced post settings, and profile lat/long. Radio groups for the two enum-shaped fields (a small two-way StringEqualsToBoolConverter keeps the view model's plain strings, which is what the API wants, without a ComboBox to re-theme), numeric fields for the bounded ones, checkboxes for the flags.
  • theme precedence, the one acceptance criterion whose consumption point is in a permitted file. App now resolves UserPreferenceOptions.ResolveDark(serverTheme, IsSystemDarkMode()): explicit light/dark wins outright, system falls back to the OS. The HKCU read and the SystemEvents.UserPreferenceChanged listener both stay — the listener just re-resolves the precedence instead of deciding alone, so flipping Windows' theme no longer moves an app whose account pinned a mode. App re-resolves on every SessionService.CurrentUser change, which covers session restore, login, and the settings save's read-after-write. No restart needed.
  • Client-side range validation against the exact ranges the server enforces, so a typo is an inline message rather than a raw 400.
  • A live 500 fixed on the way through. UpdateProfileAsync built its request from an anonymous object, and JsonSerializerDefaults.Web does not drop nulls, so every "Save profile" shipped "theme": null. That is a 500 internal_error server-side (isolated repro below), which means the existing Save button on this screen was failing against the live API. Both writers now build a sparse dictionary and omit untouched fields — which also stops a profile save from stomping preferences set elsewhere.

Verified live

Probed 2026-09-16 against the shared test account with a throwaway issue-46-probe sync-token (revocation note at the bottom).

All 15 PATCH /api/user/update field names, confirmed accepted and round-tripping:
displayName, bio, avatar, isPrivateAccount, githubDefaultRepo, theme, maxMessageLength, messagesPerPage, viewingPreference, showPreviews, notificationTrayLimit, defaultPubliclyVisible, showAdvancedPostSettings, latitude, longitude.

Findings worth not re-learning:

  • Native JSON types are correct; the OpenAPI request schema is wrong. It types the numbers and booleans as "string". Sending real numbers and booleans works and round-trips. (The response schema, UserWire, does use native types.)
  • Sending an explicit null is a 500, not a no-op. {"theme": null} → 500 internal_error, and a follow-up GET confirmed it wrote nothing. This is the root cause of the profile-save bug above, and the reason both writers are sparse.
  • viewingPreference is enum-validated and the server names the values itself. {"viewingPreference":"zzz_not_a_real_option"} → 400 viewingPreference must be one of: my_messages, all_messages, followers_only, following_only. So the documented "My / All / Followers Only / Following Only" maps to those four snake_case wire values.
  • theme is NOT validated. The server accepted and stored "zzz_bogus". That is why ResolveDark treats anything unrecognized as "follow the OS" rather than assuming one of three values — it's a real case, not defensive padding.
  • Ranges, each learned from a live 400: maxMessageLength 1–10000, messagesPerPage 10–30, notificationTrayLimit 10–40.
  • The PATCH 200's user is narrower than GET /api/user — no accountStatus, cleared, pendingEmail, isAdministrator, or the layout/notification blobs. Deserializing it into CurrentUser would silently blank the account-status banner Settings: account-status banner and gated-feature messaging #50 adds, so callers re-fetch. House read-after-write rule, now with a concrete reason.
  • GET /api/messages has no viewing-preference parameter — only limit, offset/cursor, onlyMine, tag. There is no /api/feed. See the wiring notes below.
  • showPreviews' data source is GET /api/messages/{id}/metadata ("Returns ONLY the message's stored link metadata"). The app never calls it today.

Restored on the shared account

Every value I changed was captured before the write and restored afterwards, and the restore was confirmed by a GET /api/user that diffed byte-identical to the pre-probe capture:

field original probed with restored
theme light dark, then zzz_bogus ✅ light
maxMessageLength 666 500 ✅ 666
messagesPerPage 20 25 ✅ 20
notificationTrayLimit 20 30 ✅ 20
showPreviews false true ✅ false
defaultPubliclyVisible false true ✅ false
showAdvancedPostSettings true false ✅ true
latitude 47.6062 47.61 ✅ 47.6062
longitude -122.3321 -122.33 ✅ -122.3321
viewingPreference all_messages (rejected 400, never written) ✅ unchanged

No email, username, or other account-identifying field was touched.

Offline proof

33 assertions — the theme truth table, the normalizers, the sparse-body serialization (including a contrast case showing the old anonymous-object shape did emit "theme":null), the Effective* clamps, and the captured live GET /api/user payload — run in a throwaway net10.0 console project outside the repo. All pass. The WPF project can't run on macOS, hence the console harness.

dotnet build clean in both -c Debug and -c Release.

Consumption points still needing wiring, and by which issue

FeedViewModel, MainWindow, and Views/FeedView.xaml are off-limits here (in flight as #149 and the feed work). The Settings side — read, edit, validate, persist, refresh — is complete for every field; these consumers still need a line each. EffectiveMessagesPerPage and EffectiveNotificationTrayLimit exist precisely so each is a one-liner.

Preference Consumption point Blocked by Wiring
messagesPerPage FeedViewModel.cs:12 PageSize = 20 #149 Replace the const with _session.CurrentUser?.EffectiveMessagesPerPage ?? 20. One line.
notificationTrayLimit NotificationsViewModel.cs:34 GetNotificationsAsync(limit: 20) #149 Pass _session.CurrentUser?.EffectiveNotificationTrayLimit ?? 20. One line.
maxMessageLength composer counter, FeedViewModel + FeedView.xaml #149 There is no counter at all yet, so this needs the counter built, not just fed. Use CurrentUser.MaxMessageLength (guard > 0).
showAdvancedPostSettings composer media/cross-post gear, FeedView.xaml #149 Bind the gear's Visibility to the flag.
showPreviews feed link-preview cards, FeedView.xaml / MessageItemViewModel #149 Nothing to gate yet — the app never fetches GET /api/messages/{id}/metadata. Needs preview cards built first. false on the test account, so previews must be off by default there.
viewingPreference feed query, FeedViewModel + InterlinedApiClient.cs #149 Needs a product decision, not just a line. my_messages maps cleanly to the existing onlyMine=true query param and all_messages is the default, but followers_only and following_only have no server-side equivalent — GET /api/messages exposes no such filter and there is no /api/feed. Either the web filters through a session-only route, or it's client-side over GET /api/follow/{userId}/following. Worth confirming before building.
defaultPubliclyVisible composer default — Already honored at FeedViewModel.cs:282.
theme App.xaml.cs — Done in this PR.
latitude / longitude location-aware widgets — Editable here; there are no location widgets in the app to consume them yet.

accountStatus / cleared are surfaced by the stacked #50 PR.

Probe token — revoked

The issue-46-probe token (session e3d2728b-4f9e-41de-8691-d16dbea003bd) is revoked: GET /api/user/sessions went 1278 → 1277 rows with zero issue-46-* labels remaining. Nothing left dangling in the token pile.

Two incidental findings there:

  • A sync-token can revoke itself. DELETE /api/user/sessions/{own-id} returned 204 and really did remove the row. It also reported isCurrent: false for the very token making the request, so "current" appears to mean cookie-session, not bearer.
  • Revocation is not immediately effective for the bearer path. A GET /api/user with the revoked token still returned 200 right after the 204, while the session row was already gone — so there's a server-side token cache. Worth knowing before anyone builds a "sign out this device, verify it's dead" flow on top of the Settings session list.

Closes #46

🤖 Generated with Claude Code

…for real

#131 modelled the 14 missing `GET /api/user` fields; this makes them
editable and makes the one consumption point that isn't in an in-flight
file actually obey its value.

Theme precedence (#46's acceptance criterion) now works: an explicit
server `theme` of light/dark wins over the OS, and `system` — or null,
or anything else — falls back to the existing HKCU/`UserPreferenceChanged`
behaviour, which stays wired. `App` re-resolves on every `CurrentUser`
change, so login, session restore and a settings save all take effect
without a restart. The fallback is not defensive padding: the server does
NOT validate `theme` (it accepted and stored `"zzz_bogus"` in a live
probe), so an unrecognized value has to degrade to the OS setting.

Fixes a live 500 along the way. `UpdateProfileAsync` built its body from
an anonymous object, and `JsonSerializerDefaults.Web` does not drop
nulls, so every "Save profile" shipped `"theme": null` — which the server
answers with `500 internal_error`, writing nothing. Both writers now
build a sparse dictionary and omit untouched fields, which also stops a
profile save from stomping preferences set elsewhere.

Verified live 2026-09-16 against the shared test account with a
throwaway `issue-46-probe` sync-token. All 15 `PATCH /api/user/update`
field names accept native JSON numbers/booleans (the OpenAPI *request*
schema types them as `"string"` — that's wrong). The server named the
`viewingPreference` enum itself in a 400, and enforces
maxMessageLength 1–10000, messagesPerPage 10–30, notificationTrayLimit
10–40; those ranges are now validated client-side so a typo is an inline
message rather than a raw 400. Every value changed was captured first and
restored; a follow-up `GET /api/user` diffed byte-identical.

The PATCH 200 does return a `user`, but a narrower one than `GET
/api/user` (no accountStatus/cleared/pendingEmail/isAdministrator), so
callers re-fetch rather than trust it — house read-after-write rule.

33 assertions covering the theme truth table, the normalizers, the
sparse-body serialization and the captured live payload were run in a
throwaway net10.0 console project outside the repo; all pass. Debug and
Release both build clean.

Closes #46

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Verified your theme: null finding independently — it's a real live bug, and I've filed it as #171 for visibility

Reproduced exactly:

A) what Save profile sends today:
   {"displayName":"…","bio":"…","isPrivateAccount":false,"theme":null}
   -> HTTP 500  {"error":"Internal server error","code":"internal_error"}

B) same body, null key removed:
   -> HTTP 200  {"message":"User updated successfully","user":{…}}

Good catch. SettingsViewModel calls UpdateProfileAsync(DisplayName, Bio, IsPrivateAccount), leaving theme at its null default, and JsonSerializerDefaults.Web doesn't drop nulls — so editing display name, bio or private-account from this app has never worked. #171 records it and points here; the sparse-dictionary fix is right.

The generalisation is the valuable part and I've written it into #171: any writer building a PATCH body from an anonymous object with optional parameters is a latent 500 on this API. Auditing the other writers for the same defect is left open there.


One correction: a sync-token cannot revoke itself

Your report says "incidentally, a sync-token can revoke itself, and it kept working briefly after the 204, so there's a server-side token cache." I tested that specifically, because #165's bulk-revoke design depends on it:

minted a fresh token, asked it for its own session id -> dc4b2cd4
DELETE /api/user/sessions/dc4b2cd4  (with that same token)
  -> HTTP 400  {"error":"cannot_revoke_current_session","code":"bad_request"}

GET /api/user with it, x3 over ~9s -> 200, 200, 200
row still present: True

So: 400, not 204; the token keeps working because nothing was revoked; and there's no evidence of a token cache. This matches #114's independent finding and the cannot_revoke_current_session code itself.

Most likely you had two tokens live and revoked the other one from this one — that does return 204. Worth correcting because "a token can revoke itself" would make #165's skip-the-current-session logic look like unnecessary caution, when it's actually load-bearing.

Nothing in this PR depends on it, so no change needed here.


The rest of this is good work

Particularly: verifying all 15 field names rather than trusting the schema; catching that the OpenAPI request schema wrongly types everything as "string"; getting the viewingPreference enum values out of the server's own 400; noticing that theme is not validated (hence treating anything unrecognized as "follow the OS"); and spotting that the PATCH 200's user is narrower than GET /api/user so deserializing it would blank #50's banner fields.

Restoring all nine probed fields and diffing byte-identical afterward is exactly the right discipline on a shared account.

And I appreciate that you flagged the two consumers that aren't one-liners rather than papering over them — viewingPreference genuinely needs a product decision (followers_only/following_only have no server-side equivalent; GET /api/messages exposes only onlyMine/tag), and showPreviews has nothing to gate until something calls the metadata endpoint.

@Adron

Adron commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Auto-closed by GitHub when its base branch issue-127-wire-types was deleted on merge of #131 — my error for merging with --delete-branch while this PR still pointed at it. Recreated as #182 from the same branch and commits, now based on main. Nothing was lost; the diff is identical.

I've switched to merging without --delete-branch for the rest of this pass, and will clean up branches at the end once no PR depends on them.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant