From 3f1327447fe332ffde0853a2455f8d23e88c0eef Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:36:46 -0700 Subject: [PATCH] feat(settings): edit every server-side preference, and honor `theme` for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- InterlinedList/App.xaml.cs | 34 ++- .../Services/InterlinedApiClient.Account.cs | 111 +++++++++- .../ViewModels/SettingsViewModel.cs | 203 +++++++++++++++++- .../ViewModels/UserPreferenceOptions.cs | 77 +++++++ InterlinedList/Views/SettingsView.xaml | 185 ++++++++++++++++ .../Views/SettingsViewConverters.cs | 22 ++ 6 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 InterlinedList/ViewModels/UserPreferenceOptions.cs create mode 100644 InterlinedList/Views/SettingsViewConverters.cs diff --git a/InterlinedList/App.xaml.cs b/InterlinedList/App.xaml.cs index 16f26b4..0af4089 100644 --- a/InterlinedList/App.xaml.cs +++ b/InterlinedList/App.xaml.cs @@ -29,8 +29,13 @@ protected override async void OnStartup(StartupEventArgs e) try { base.OnStartup(e); + + // Before a session exists there is no server preference to honor, so + // the OS setting is the whole answer. ApplyEffectiveTheme takes over + // the moment CurrentUser lands (below, and on every later login). ApplyTheme(IsSystemDarkMode()); SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + AppServices.Session.PropertyChanged += OnSessionPropertyChanged; bool restored; try @@ -133,13 +138,40 @@ private void ShowMainWindow() protected override void OnExit(ExitEventArgs e) { SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + AppServices.Session.PropertyChanged -= OnSessionPropertyChanged; base.OnExit(e); } + // ── Theme: explicit server preference > OS setting (#46) ─────────────────── + private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) { + // The OS listener stays wired, but it no longer decides on its own: if + // the account pinned light/dark, flipping Windows' theme must not move + // the app. ApplyEffectiveTheme re-resolves the precedence either way. if (e.Category == UserPreferenceCategory.General) - Dispatcher.Invoke(() => ApplyTheme(IsSystemDarkMode())); + Dispatcher.Invoke(ApplyEffectiveTheme); + } + + private void OnSessionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // Fires on session restore, on login, and on the settings screen's + // read-after-write refresh — i.e. every moment the server's `theme` + // could have changed under us. + if (e.PropertyName == nameof(Services.SessionService.CurrentUser)) + Dispatcher.Invoke(ApplyEffectiveTheme); + } + + /// + /// Resolves the account's theme preference against the OS setting and + /// applies the result. light/dark win outright; system + /// — and any other value, since the server does not validate this field — + /// follows Windows. + /// + internal void ApplyEffectiveTheme() + { + var serverTheme = AppServices.Session.CurrentUser?.Theme; + ApplyTheme(ViewModels.UserPreferenceOptions.ResolveDark(serverTheme, IsSystemDarkMode())); } internal void ApplyTheme(bool dark) diff --git a/InterlinedList/Services/InterlinedApiClient.Account.cs b/InterlinedList/Services/InterlinedApiClient.Account.cs index 9b7a29b..2799725 100644 --- a/InterlinedList/Services/InterlinedApiClient.Account.cs +++ b/InterlinedList/Services/InterlinedApiClient.Account.cs @@ -15,14 +15,121 @@ namespace InterlinedList.Services; /// public sealed partial class InterlinedApiClient { + /// + /// The 15 writable fields on PATCH /api/user/update, exactly as the + /// server names them. Field names, native JSON types and validation were + /// probed live 2026-09-16 with a throwaway sync-token (every value changed + /// was captured first and restored afterwards; a follow-up + /// GET /api/user diffed byte-identical). + /// + /// + /// Findings worth not re-learning the hard way: + /// + /// The OpenAPI *request* schema types the numbers and booleans as + /// "string". That is wrong/loose — native JSON numbers and booleans + /// are accepted and round-trip correctly. (The *response* schema, + /// UserWire, does use native types.) + /// Sending an explicit null for a field is a 500, not a + /// no-op — {"theme": null} returns + /// 500 internal_error and writes nothing. Every writer here must + /// therefore omit untouched fields rather than send null, which is + /// why these methods build a sparse dictionary instead of an anonymous + /// object (JsonSerializerDefaults.Web does not drop nulls). + /// viewingPreference is enum-validated server-side: + /// my_messages, all_messages, followers_only, + /// following_only. theme is not validated — the server + /// accepted an arbitrary string — so readers must treat anything that isn't + /// light/dark as "follow the OS". + /// Ranges are enforced with 400s: maxMessageLength 1–10000, + /// messagesPerPage 10–30, notificationTrayLimit 10–40. + /// The 200 response carries a user object, but it is + /// narrower than GET /api/user — accountStatus, + /// cleared, pendingEmail, isAdministrator and the + /// layout/notification blobs are all absent. Deserializing it into + /// would silently blank the account-status + /// banner, so callers re-fetch (read-after-write), per the house rule. + /// + /// + public static class UserUpdateFields + { + public const string DisplayName = "displayName"; + public const string Bio = "bio"; + public const string Avatar = "avatar"; + public const string IsPrivateAccount = "isPrivateAccount"; + public const string GithubDefaultRepo = "githubDefaultRepo"; + public const string Theme = "theme"; + public const string MaxMessageLength = "maxMessageLength"; + public const string MessagesPerPage = "messagesPerPage"; + public const string ViewingPreference = "viewingPreference"; + public const string ShowPreviews = "showPreviews"; + public const string NotificationTrayLimit = "notificationTrayLimit"; + public const string DefaultPubliclyVisible = "defaultPubliclyVisible"; + public const string ShowAdvancedPostSettings = "showAdvancedPostSettings"; + public const string Latitude = "latitude"; + public const string Longitude = "longitude"; + } + public Task UpdateProfileAsync( string? displayName, string? bio, bool? isPrivateAccount = null, string? theme = null, CancellationToken ct = default) - => SendVoidAsync(HttpMethod.Patch, "api/user/update", - new { displayName, bio, isPrivateAccount, theme }, ct); + { + // Sparse on purpose: this used to send an anonymous object, so a plain + // "Save profile" always shipped `"theme": null` — which the server + // answers with a 500 (see UserUpdateFields). Omitting untouched fields + // is the fix, and it also stops a profile save from stomping + // preferences the user set elsewhere. + var body = new Dictionary(); + Set(body, UserUpdateFields.DisplayName, displayName); + Set(body, UserUpdateFields.Bio, bio); + Set(body, UserUpdateFields.IsPrivateAccount, isPrivateAccount); + Set(body, UserUpdateFields.Theme, theme); + return SendVoidAsync(HttpMethod.Patch, "api/user/update", body, ct); + } + + /// + /// Writes the server-side viewing/composer preferences. Every argument is + /// optional and only the ones supplied are sent — see + /// for why null must never go on the wire. + /// Callers should re-fetch GET /api/user afterwards rather than trust + /// the (narrower) response envelope. + /// + public Task UpdatePreferencesAsync( + string? theme = null, + int? maxMessageLength = null, + int? messagesPerPage = null, + string? viewingPreference = null, + bool? showPreviews = null, + int? notificationTrayLimit = null, + bool? defaultPubliclyVisible = null, + bool? showAdvancedPostSettings = null, + double? latitude = null, + double? longitude = null, + CancellationToken ct = default) + { + var body = new Dictionary(); + Set(body, UserUpdateFields.Theme, theme); + Set(body, UserUpdateFields.MaxMessageLength, maxMessageLength); + Set(body, UserUpdateFields.MessagesPerPage, messagesPerPage); + Set(body, UserUpdateFields.ViewingPreference, viewingPreference); + Set(body, UserUpdateFields.ShowPreviews, showPreviews); + Set(body, UserUpdateFields.NotificationTrayLimit, notificationTrayLimit); + Set(body, UserUpdateFields.DefaultPubliclyVisible, defaultPubliclyVisible); + Set(body, UserUpdateFields.ShowAdvancedPostSettings, showAdvancedPostSettings); + Set(body, UserUpdateFields.Latitude, latitude); + Set(body, UserUpdateFields.Longitude, longitude); + + return body.Count == 0 + ? Task.CompletedTask + : SendVoidAsync(HttpMethod.Patch, "api/user/update", body, ct); + } + + private static void Set(Dictionary body, string field, object? value) + { + if (value is not null) body[field] = value; + } public async Task> GetSessionsAsync(CancellationToken ct = default) { diff --git a/InterlinedList/ViewModels/SettingsViewModel.cs b/InterlinedList/ViewModels/SettingsViewModel.cs index db8b710..4b174a4 100644 --- a/InterlinedList/ViewModels/SettingsViewModel.cs +++ b/InterlinedList/ViewModels/SettingsViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -48,6 +49,41 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string deleteConfirmUsername = ""; + // ── Server-side preferences (see #46) ─────────────────────────────────────── + // These mirror the writable fields on PATCH /api/user/update. They are the + // *edit* surface; each one also has a consumption point elsewhere in the app + // that has to obey it, which is the other half of #46. + + [ObservableProperty] + private string theme = UserPreferenceOptions.ThemeSystem; + + [ObservableProperty] + private string viewingPreference = UserPreferenceOptions.ViewingAllMessages; + + [ObservableProperty] + private string maxMessageLength = "666"; + + [ObservableProperty] + private string messagesPerPage = "20"; + + [ObservableProperty] + private string notificationTrayLimit = "20"; + + [ObservableProperty] + private bool showPreviews; + + [ObservableProperty] + private bool defaultPubliclyVisible; + + [ObservableProperty] + private bool showAdvancedPostSettings; + + [ObservableProperty] + private string latitude = ""; + + [ObservableProperty] + private string longitude = ""; + public SettingsViewModel(SessionService session) { _session = session; @@ -101,14 +137,8 @@ private async Task LoadAsync() IsBusy = true; try { - // Prefill profile fields from the current user. - var user = _session.CurrentUser; - if (user is not null) - { - DisplayName = user.DisplayName ?? ""; - Bio = user.Bio ?? ""; - IsPrivateAccount = user.IsPrivateAccount; - } + // Prefill profile + preference fields from the current user. + PrefillFromUser(_session.CurrentUser); var prefs = await _session.Api.GetNotificationPreferencesAsync(); Preferences.Clear(); @@ -167,6 +197,75 @@ private async Task SaveProfileAsync() try { await _session.Api.UpdateProfileAsync(DisplayName, Bio, IsPrivateAccount); + await RefreshCurrentUserAsync(); + ErrorMessage = null; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsBusy = false; + } + } + + // ── Preferences ───────────────────────────────────────────────────────────── + + /// + /// Writes the preference block, then re-reads GET /api/user so the rest of + /// the app sees the change immediately. The re-read is deliberate: the PATCH + /// 200 does return a user object, but a narrower one than GET (no + /// accountStatus/cleared/pendingEmail), so trusting it would blank state + /// other views depend on. + /// + [RelayCommand] + private async Task SavePreferencesAsync() + { + if (!TryParsePreferenceNumbers(out var maxLen, out var perPage, out var trayLimit) + || !TryParseCoordinates(out var lat, out var lon)) + { + return; // ErrorMessage already set with the offending range. + } + + IsBusy = true; + try + { + await _session.Api.UpdatePreferencesAsync( + theme: UserPreferenceOptions.NormalizeTheme(Theme), + maxMessageLength: maxLen, + messagesPerPage: perPage, + viewingPreference: UserPreferenceOptions.NormalizeViewingPreference(ViewingPreference), + showPreviews: ShowPreviews, + notificationTrayLimit: trayLimit, + defaultPubliclyVisible: DefaultPubliclyVisible, + showAdvancedPostSettings: ShowAdvancedPostSettings, + latitude: lat, + longitude: lon); + + // Assigning CurrentUser raises PropertyChanged, which is what makes a + // theme change take effect without a restart (App listens for it). + await RefreshCurrentUserAsync(); + ErrorMessage = "Preferences saved."; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsBusy = false; + } + } + + /// Discards local edits and re-reads the server's values. + [RelayCommand] + private async Task ReloadPreferencesAsync() + { + IsBusy = true; + try + { + await RefreshCurrentUserAsync(); ErrorMessage = null; } catch (InterlinedApiException ex) @@ -179,6 +278,94 @@ private async Task SaveProfileAsync() } } + private async Task RefreshCurrentUserAsync() + { + _session.CurrentUser = await _session.Api.GetCurrentUserAsync(); + PrefillFromUser(_session.CurrentUser); + } + + private void PrefillFromUser(CurrentUser? user) + { + if (user is null) return; + + DisplayName = user.DisplayName ?? ""; + Bio = user.Bio ?? ""; + IsPrivateAccount = user.IsPrivateAccount; + + Theme = UserPreferenceOptions.NormalizeTheme(user.Theme); + ViewingPreference = UserPreferenceOptions.NormalizeViewingPreference(user.ViewingPreference); + // Show the *effective* paging values, not raw zeros, when the server + // hasn't set them — otherwise the boxes read "0" and a save would 400. + MaxMessageLength = (user.MaxMessageLength > 0 ? user.MaxMessageLength : 666).ToString(CultureInfo.InvariantCulture); + MessagesPerPage = user.EffectiveMessagesPerPage.ToString(CultureInfo.InvariantCulture); + NotificationTrayLimit = user.EffectiveNotificationTrayLimit.ToString(CultureInfo.InvariantCulture); + ShowPreviews = user.ShowPreviews; + DefaultPubliclyVisible = user.DefaultPubliclyVisible; + ShowAdvancedPostSettings = user.ShowAdvancedPostSettings; + Latitude = user.Latitude?.ToString(CultureInfo.InvariantCulture) ?? ""; + Longitude = user.Longitude?.ToString(CultureInfo.InvariantCulture) ?? ""; + } + + // Validate client-side against the ranges the server itself enforces, so a + // typo surfaces inline instead of as a raw 400 from the API. + private bool TryParsePreferenceNumbers(out int maxLen, out int perPage, out int trayLimit) + { + maxLen = perPage = trayLimit = 0; + + if (!TryParseRange(MaxMessageLength, + UserPreferenceOptions.MinMaxMessageLength, UserPreferenceOptions.MaxMaxMessageLength, + "Max message length", out maxLen)) + return false; + + if (!TryParseRange(MessagesPerPage, + UserPreferenceOptions.MinMessagesPerPage, UserPreferenceOptions.MaxMessagesPerPage, + "Messages per page", out perPage)) + return false; + + return TryParseRange(NotificationTrayLimit, + UserPreferenceOptions.MinNotificationTrayLimit, UserPreferenceOptions.MaxNotificationTrayLimit, + "Notification tray limit", out trayLimit); + } + + private bool TryParseRange(string text, int min, int max, string label, out int value) + { + if (!int.TryParse(text?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value) + || value < min || value > max) + { + ErrorMessage = $"{label} must be a whole number between {min} and {max}."; + return false; + } + + return true; + } + + // Latitude/longitude are optional: blank clears nothing (the field is simply + // omitted from the PATCH) because sending null is a 500 server-side. + private bool TryParseCoordinates(out double? latitude, out double? longitude) + { + latitude = longitude = null; + + if (!TryParseOptionalDegrees(Latitude, -90, 90, "Latitude", out latitude)) return false; + return TryParseOptionalDegrees(Longitude, -180, 180, "Longitude", out longitude); + } + + private bool TryParseOptionalDegrees(string text, double min, double max, string label, out double? value) + { + value = null; + var trimmed = text?.Trim() ?? ""; + if (trimmed.Length == 0) return true; + + if (!double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + || parsed < min || parsed > max) + { + ErrorMessage = $"{label} must be a number between {min} and {max}, or blank."; + return false; + } + + value = parsed; + return true; + } + [RelayCommand] private async Task RevokeAsync(ApiSession s) { diff --git a/InterlinedList/ViewModels/UserPreferenceOptions.cs b/InterlinedList/ViewModels/UserPreferenceOptions.cs new file mode 100644 index 0000000..e078084 --- /dev/null +++ b/InterlinedList/ViewModels/UserPreferenceOptions.cs @@ -0,0 +1,77 @@ +namespace InterlinedList.ViewModels; + +/// +/// The accepted wire values for the two enum-shaped user preferences, plus the +/// theme resolution the whole app shares. Kept next to the settings view model +/// because that is the only place that *writes* them, but +/// is what App reads to decide a theme. +/// +/// +/// Both value sets were confirmed live 2026-09-16 against +/// PATCH /api/user/update: viewingPreference is enum-validated by +/// the server, which named the four values itself in its 400 body +/// ("viewingPreference must be one of: my_messages, all_messages, +/// followers_only, following_only"). theme is not validated — the +/// server stored an arbitrary string without complaint — hence +/// treats anything unrecognized as "follow the OS" +/// instead of assuming the value is one of three. +/// +public static class UserPreferenceOptions +{ + // ── theme ─────────────────────────────────────────────────────────────────── + + public const string ThemeSystem = "system"; + public const string ThemeLight = "light"; + public const string ThemeDark = "dark"; + + /// + /// Theme precedence in one place: an explicit server-side light/ + /// dark wins, and system — or null, or any unvalidated value + /// the server let through — falls back to the OS preference. + /// + public static bool ResolveDark(string? serverTheme, bool osPrefersDark) => + Normalize(serverTheme) switch + { + ThemeLight => false, + ThemeDark => true, + _ => osPrefersDark + }; + + /// Maps a stored theme onto one of the three values the picker offers. + public static string NormalizeTheme(string? serverTheme) => + Normalize(serverTheme) switch + { + ThemeLight => ThemeLight, + ThemeDark => ThemeDark, + _ => ThemeSystem + }; + + // ── viewingPreference ─────────────────────────────────────────────────────── + + public const string ViewingMyMessages = "my_messages"; + public const string ViewingAllMessages = "all_messages"; + public const string ViewingFollowersOnly = "followers_only"; + public const string ViewingFollowingOnly = "following_only"; + + /// Falls back to all_messages for an unknown/empty value. + public static string NormalizeViewingPreference(string? value) => + Normalize(value) switch + { + ViewingMyMessages => ViewingMyMessages, + ViewingFollowersOnly => ViewingFollowersOnly, + ViewingFollowingOnly => ViewingFollowingOnly, + _ => ViewingAllMessages + }; + + // ── server-enforced ranges (each learned from a live 400) ─────────────────── + + public const int MinMaxMessageLength = 1; + public const int MaxMaxMessageLength = 10000; + public const int MinMessagesPerPage = 10; + public const int MaxMessagesPerPage = 30; + public const int MinNotificationTrayLimit = 10; + public const int MaxNotificationTrayLimit = 40; + + private static string Normalize(string? value) => + value?.Trim().ToLowerInvariant() ?? ""; +} diff --git a/InterlinedList/Views/SettingsView.xaml b/InterlinedList/Views/SettingsView.xaml index 48996d0..8fa641e 100644 --- a/InterlinedList/Views/SettingsView.xaml +++ b/InterlinedList/Views/SettingsView.xaml @@ -13,6 +13,7 @@ + + + + + + + + @@ -244,6 +265,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +