Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion InterlinedList/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/// <summary>
/// Resolves the account's <c>theme</c> preference against the OS setting and
/// applies the result. <c>light</c>/<c>dark</c> win outright; <c>system</c>
/// — and any other value, since the server does not validate this field —
/// follows Windows.
/// </summary>
internal void ApplyEffectiveTheme()
{
var serverTheme = AppServices.Session.CurrentUser?.Theme;
ApplyTheme(ViewModels.UserPreferenceOptions.ResolveDark(serverTheme, IsSystemDarkMode()));
}

internal void ApplyTheme(bool dark)
Expand Down
111 changes: 109 additions & 2 deletions InterlinedList/Services/InterlinedApiClient.Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,121 @@ namespace InterlinedList.Services;
/// </summary>
public sealed partial class InterlinedApiClient
{
/// <summary>
/// The 15 writable fields on <c>PATCH /api/user/update</c>, 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
/// <c>GET /api/user</c> diffed byte-identical).
/// </summary>
/// <remarks>
/// Findings worth not re-learning the hard way:
/// <list type="bullet">
/// <item>The OpenAPI *request* schema types the numbers and booleans as
/// <c>"string"</c>. That is wrong/loose — native JSON numbers and booleans
/// are accepted and round-trip correctly. (The *response* schema,
/// <c>UserWire</c>, does use native types.)</item>
/// <item><b>Sending an explicit <c>null</c> for a field is a 500, not a
/// no-op</b> — <c>{"theme": null}</c> returns
/// <c>500 internal_error</c> and writes nothing. Every writer here must
/// therefore <i>omit</i> untouched fields rather than send null, which is
/// why these methods build a sparse dictionary instead of an anonymous
/// object (<c>JsonSerializerDefaults.Web</c> does not drop nulls).</item>
/// <item><c>viewingPreference</c> <i>is</i> enum-validated server-side:
/// <c>my_messages</c>, <c>all_messages</c>, <c>followers_only</c>,
/// <c>following_only</c>. <c>theme</c> is <b>not</b> validated — the server
/// accepted an arbitrary string — so readers must treat anything that isn't
/// <c>light</c>/<c>dark</c> as "follow the OS".</item>
/// <item>Ranges are enforced with 400s: <c>maxMessageLength</c> 1–10000,
/// <c>messagesPerPage</c> 10–30, <c>notificationTrayLimit</c> 10–40.</item>
/// <item>The 200 response carries a <c>user</c> object, but it is
/// <b>narrower than <c>GET /api/user</c></b> — <c>accountStatus</c>,
/// <c>cleared</c>, <c>pendingEmail</c>, <c>isAdministrator</c> and the
/// layout/notification blobs are all absent. Deserializing it into
/// <see cref="CurrentUser"/> would silently blank the account-status
/// banner, so callers re-fetch (read-after-write), per the house rule.</item>
/// </list>
/// </remarks>
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<string, object?>();
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);
}

/// <summary>
/// Writes the server-side viewing/composer preferences. Every argument is
/// optional and only the ones supplied are sent — see
/// <see cref="UserUpdateFields"/> for why null must never go on the wire.
/// Callers should re-fetch <c>GET /api/user</c> afterwards rather than trust
/// the (narrower) response envelope.
/// </summary>
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<string, object?>();
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<string, object?> body, string field, object? value)
{
if (value is not null) body[field] = value;
}

public async Task<List<ApiSession>> GetSessionsAsync(CancellationToken ct = default)
{
Expand Down
Loading
Loading