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 1850c1c..c6f1cab 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).
+ /// viewingPreferenceis 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 54321bd..ba6a44c 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 4f7c554..43c8c4c 100644
--- a/InterlinedList/Views/SettingsView.xaml
+++ b/InterlinedList/Views/SettingsView.xaml
@@ -13,6 +13,7 @@
+
@@ -144,6 +145,26 @@
+
+
+
+
+
+
+
@@ -245,6 +266,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/// Two-way binds a group of RadioButtons to one string property: the
+/// button whose ConverterParameter equals the bound value is checked,
+/// and checking a button writes that parameter back. Lets the enum-shaped
+/// preferences (theme, viewing preference) stay plain strings on the view model
+/// — which is what the API wants — without a ComboBox to re-theme.
+///
+public sealed class StringEqualsToBoolConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ string.Equals(value as string, parameter as string, StringComparison.OrdinalIgnoreCase);
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ // Only the button being *checked* should write; the one being unchecked
+ // must stay quiet or it would immediately clobber the new selection.
+ value is true && parameter is string s ? s : Binding.DoNothing;
+}