From 3827d903a3aa7dc4aa2b36511b7a51587f2d724a Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 10 Nov 2026 01:26:39 +0100 Subject: [PATCH] House rules: numbers through one formatter, no made-up server errors, and the rules fail the build - Every number shown on a page goes through NumbersExtensions: counts, percentages, the nerd readouts and the values written into a style. - A failure the client builds itself (offline, timeout, retries run out, an answer it can't read) is a 400 that names its kind; only a server's own 5XX passes through. - Login, sign-up, recovery and password confirmations leave autocomplete to the browser, so it can offer the saved login. - The Daisy components start with empty lists and attributes, and a decorative image still renders alt="". - Arasaka.HouseRules.Analyzers checks all of this in every build, together with PrivaPub.ClientModels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd --- CLAUDE.md | 43 +++++++++++-------- Components/ActDialog.razor | 2 +- Components/Admin/BackupsPanel.razor | 4 +- Components/Admin/StatisticsPanel.razor | 2 +- Components/Composer.razor | 2 +- Components/Content.razor | 14 +++--- Components/Daisy/Actions/DDropdown.razor | 2 +- Components/Daisy/DComponentBase.cs | 2 +- Components/Daisy/DataDisplay/DAvatar.razor | 4 +- .../Daisy/DataDisplay/DHoverGallery.razor | 11 ++--- .../Daisy/DataDisplay/DTextRotate.razor | 9 ++-- Components/Daisy/DataInput/DFilter.razor | 13 +++--- Components/EmojiPicker.razor | 2 +- Components/Feed/GlobePanel.razor | 4 +- Components/Nerd/NerdRow.cs | 12 +++--- Components/Settings/AccountEditor.razor | 10 ++--- Components/Settings/TotpEditor.razor | 4 +- Directory.Build.props | 14 ++++++ Extensions/GenericExtensions.cs | 9 ---- Extensions/NumbersExtensions.cs | 33 ++++++++++++++ Pages/Groups.razor | 2 +- Pages/Index.razor | 2 +- Pages/Join.razor | 4 +- Pages/Login.razor | 4 +- Pages/PasswordRecovery.razor | 4 +- Pages/Setup.razor | 2 +- Services/AccountService.cs | 4 +- Services/AuthService.cs | 6 +-- Services/IHttpService.cs | 16 +++---- Services/InstanceAdminService.cs | 2 +- Services/MediaService.cs | 2 +- Services/OverlayService.cs | 4 +- Services/PushService.cs | 4 +- Services/StatusService.cs | 2 +- Services/StreamingService.cs | 4 +- Services/TimelineService.cs | 2 +- nuget.config | 16 +++++++ 37 files changed, 165 insertions(+), 111 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Extensions/NumbersExtensions.cs create mode 100644 nuget.config diff --git a/CLAUDE.md b/CLAUDE.md index 2437050..e522749 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -849,22 +849,24 @@ in both builds, kept out of the precache; see **Web Push**): `push` shows the no the app on that persona's notifications, `pushsubscriptionchange` subscribes again with the same key. They control no page and have no fetch handler. -## Development rules +## House rules -These rules are adapted from the owner's b2b.next project. They are enforced by review: the build doesn't check them, -so follow them in every change. +The owner's rules for every .NET project (`~/RiderProjects/HouseRules/RULES.md`, repo `Arasaka/HouseRules`) apply +here and win over anything else in this file. `Arasaka.HouseRules.Analyzers` (`Directory.Build.props`) reports the +ones with an HR id as errors in every build and in Rider, here and in `PrivaPub.ClientModels`; the rest are reviewed. | Area | Rule | |---|---| -| All | `var` everywhere; uninitialised locals are `var x = default(T);` | -| All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields | -| All | Every async method takes a `CancellationToken` as its last parameter | +| All | `var` everywhere; uninitialised locals are `var x = default(T);` (HR0001) | +| All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields (HR0004) | +| All | Every async method takes a `CancellationToken` as its last parameter (HR0003) | | All | Early return on error: guard, handle, `return`; never if/else for success vs failure | -| Models / state | Lists and arrays `= []`, never null; no `= string.Empty` / `""` defaults; explicit enum defaults | -| Components | No `@{ }` blocks in markup: locals become `@code` members | -| Components | `[Inject]` in `@code`, never `@inject` | -| Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state | -| Components | Every text/number input declares `autocomplete` | +| Models / state | Lists and arrays `= []`, never null (HR0006); no `= string.Empty` / `""` defaults (HR0005); explicit enum defaults | +| Components | No `@{ }` blocks in markup: locals become `@code` members (HR0102) | +| Components | `[Inject]` in `@code`, never `@inject` (HR0101) | +| Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state (HR0104) | +| Components | Every text/number input declares `autocomplete`, except credentials (HR0103, HR0105) | +| Services | A failure the client builds itself is a 400 that names its kind in `ErrorCode` (`FailureCodes`), never a 5XX; only a server's own 5XX passes through (HR0007) | | Services | `Task` for mutations, `Task>` (`[]` on error) for lists, `Task` with a default for values | | Localization | Every user-facing string through `Localizer`, its key in **both** `AllStrings.resx` and `AllStrings.it.resx` | @@ -881,7 +883,7 @@ List posts = []; // DO @(posts?.Count ?? 0) // DON'T ``` -`= ""` is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's `Alt`). +A string that must render as `""` coalesces in the markup instead: a decorative image is `alt="@(Alt ?? "")"`. **Pages that load data.** Pages fetch in `LoadData` and keep lifecycle concerns in the lifecycle method: @@ -930,22 +932,27 @@ join a list into one string with `
` or `\n`. **Autocomplete values:** - `autocomplete="off"` for search, filter and free text; -- `new-password` when setting a password; +- `new-password` when setting a password; `off` on a password field is wrong, since it doesn't stop saved passwords + (HR0105); - `one-time-code` for OTP (DOtp sets it); -- `username` and `current-password` on the login form, so password managers fill it; +- omitted on login credentials, contact e-mails and password confirmations, so the browser can help: the files are + listed in `HouseRulesAutocompleteOmit` (Login, Join, Setup, PasswordRecovery, AccountEditor, TotpEditor, Groups, + ActDialog, BackupsPanel); - inside `Components/Daisy`, attributes pass through from the caller. **Localization:** - Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly. - Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide on them and one of the two silently shows the other's text. Reuse the existing key. -- Use `Localizer[key, args]`, never `string.Format(Localizer[key], …)`. Don't write `.Value`. +- Use `Localizer[key, args]`, never `string.Format(Localizer[key], …)`. Don't write `.Value` (HR0009). - Enum members shown through `Localizer[value.ToString()]` are keys too: when you add a member, add its key. - Language names in the language picker stay in their own language. -**Formatting.** Date formats come from `SUtility.dateTimeFormat` / `timeFormat` / `dateTimeFormatExt`. Relative times -and sizes come from `GetPassedTime` / `GetFileSize` (`Extensions/GenericExtensions.cs`). No inline format strings for -display. +**Formatting.** Numbers go through `NumbersExtensions` (`Extensions/NumbersExtensions.cs`, HR0011): `ToDefaultCount`, +`Compact` (999, 1.2k), `ToDefaultPercent`, `ToWholePercent`, `ToSignedPercent`, `ToReadout` (nerd tips), `ToFixed`, +and `ToCssNumber` for values written into a style. Date formats come from `SUtility.dateTimeFormat` / `timeFormat` / +`dateTimeFormatExt`. Relative times and sizes come from `GetPassedTime` / `GetFileSize` +(`Extensions/GenericExtensions.cs`). No inline format strings for display. **JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads `pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case). diff --git a/Components/ActDialog.razor b/Components/ActDialog.razor index fdf6098..aef097c 100644 --- a/Components/ActDialog.razor +++ b/Components/ActDialog.razor @@ -34,7 +34,7 @@ } @if (AskPassword) { - diff --git a/Components/Admin/BackupsPanel.razor b/Components/Admin/BackupsPanel.razor index f458b02..b6d1125 100644 --- a/Components/Admin/BackupsPanel.razor +++ b/Components/Admin/BackupsPanel.razor @@ -148,7 +148,7 @@ @if (ticket is null) {

@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]

- } @@ -182,7 +182,7 @@ {

@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]

} - diff --git a/Components/Admin/StatisticsPanel.razor b/Components/Admin/StatisticsPanel.razor index 793b7b1..9c130c1 100644 --- a/Components/Admin/StatisticsPanel.razor +++ b/Components/Admin/StatisticsPanel.razor @@ -43,7 +43,7 @@ @Localizer["Deliveries that arrived"] - @(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—") + @(overview.DeliverySuccess is { } success ? (success * 100).ToDefaultPercent() : "—") @Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)] diff --git a/Components/Composer.razor b/Components/Composer.razor index f29efc8..86ba37e 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -162,7 +162,7 @@ } else { - @Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")] + @Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToDefaultCount()] } diff --git a/Components/Content.razor b/Components/Content.razor index 9a387db..983f0ad 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -804,7 +804,7 @@ var option = poll.Options[i]; var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
-
+
@option.Title @@ -813,7 +813,7 @@ } - @share.ToString("0")% + @share.ToWholePercent()
} @@ -1318,7 +1318,7 @@ // where a cropped picture keeps its subject: Mastodon's focus runs from -1 to 1, left to right and bottom to top static string FocalPoint(MediaAttachment media) => media.Meta?.Focus is { } focus - ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"object-position: {(focus.X + 1) * 50:0.#}% {(1 - focus.Y) * 50:0.#}%") + ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"object-position: {((focus.X + 1) * 50).ToCssNumber()}% {((1 - focus.Y) * 50).ToCssNumber()}%") : null; static bool IsWebLink(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; @@ -1474,7 +1474,7 @@ yield return NerdRow.Of(Localizer["server"], server.NodeName is { Length: > 0 } name && name != server.Host ? $"{server.Host} ({name})" : server.Host); yield return NerdRow.Of(Localizer["software"], $"{server.Software} {server.Version}".Trim()); yield return NerdRow.Of(Localizer["Users"], server.Users is { } users - ? server.ActiveMonth is { } active ? $"{users:N0} ({active:N0} {Localizer["active this month"]})" : users.ToString("N0") + ? server.ActiveMonth is { } active ? $"{users.ToDefaultCount()} ({active.ToDefaultCount()} {Localizer["active this month"]})" : users.ToDefaultCount() : null); yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts); yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null); @@ -1553,7 +1553,7 @@ _ => Localizer["Near you"] }); yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}"); - yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm:0} km"); + yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm.ToDefaultCount()} km"); if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo) { yield return NerdRow.Of(Localizer["network"], geo.Network); @@ -1561,7 +1561,7 @@ yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt); } if (InstanceService.Known(InstanceService.SelfHost)?.Geo is { Latitude: { } selfLatitude, Longitude: { } selfLongitude } && location.Source is not LocationSource.NearYou) - yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude):N0} km"); + yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude).ToDefaultCount()} km"); } static IEnumerable MediaRows(MediaAttachment media) @@ -1570,7 +1570,7 @@ yield return NerdRow.Of("size", media.Meta?.Original?.Size); yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect); yield return NerdRow.Of("length", media.Meta?.Length); - yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X:0.##}, {focus.Y:0.##}" : null); + yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X.ToReadout()}, {focus.Y.ToReadout()}" : null); yield return NerdRow.Of("blurhash", media.Blurhash); yield return NerdRow.Of("served by", Uri.TryCreate(media.Url, UriKind.Absolute, out var served) ? served.Host : null); yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null); diff --git a/Components/Daisy/Actions/DDropdown.razor b/Components/Daisy/Actions/DDropdown.razor index 30e0127..2f76822 100644 --- a/Components/Daisy/Actions/DDropdown.razor +++ b/Components/Daisy/Actions/DDropdown.razor @@ -36,7 +36,7 @@ [Parameter] public bool CloseOnContentClick { get; set; } [Parameter] public string TriggerClass { get; set; } /// Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones. - [Parameter] public IReadOnlyDictionary TriggerAttributes { get; set; } + [Parameter] public IReadOnlyDictionary TriggerAttributes { get; set; } = new Dictionary(); [Parameter] public string ContentClass { get; set; } /// Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger. [Parameter] public bool SheetOnNarrow { get; set; } diff --git a/Components/Daisy/DComponentBase.cs b/Components/Daisy/DComponentBase.cs index 4e13ab6..269f66a 100644 --- a/Components/Daisy/DComponentBase.cs +++ b/Components/Daisy/DComponentBase.cs @@ -10,7 +10,7 @@ public abstract class DComponentBase : ComponentBase { [Parameter] public string Class { get; set; } - [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary AdditionalAttributes { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary AdditionalAttributes { get; set; } = new Dictionary(); protected static string Cx(params string[] classes) => DCx.Join(classes); } diff --git a/Components/Daisy/DataDisplay/DAvatar.razor b/Components/Daisy/DataDisplay/DAvatar.razor index 1a6caf5..1e65b18 100644 --- a/Components/Daisy/DataDisplay/DAvatar.razor +++ b/Components/Daisy/DataDisplay/DAvatar.razor @@ -7,7 +7,7 @@
@if (Src is not null) { - @Alt + @(Alt ?? } else { @@ -19,7 +19,7 @@ @code { [Parameter] public string Src { get; set; } // "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image. - [Parameter] public string Alt { get; set; } = ""; + [Parameter] public string Alt { get; set; } /// Shown in the frame when there is no Src, e.g. initials with Placeholder. [Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public DAvatarStatus Status { get; set; } diff --git a/Components/Daisy/DataDisplay/DHoverGallery.razor b/Components/Daisy/DataDisplay/DHoverGallery.razor index cbccf36..f191210 100644 --- a/Components/Daisy/DataDisplay/DHoverGallery.razor +++ b/Components/Daisy/DataDisplay/DHoverGallery.razor @@ -4,19 +4,16 @@ across it shows the others. Up to 10 images of the same size, from Images or as children; cap its width in Class (max-w-60), or it fills the container. *@
- @if (Images is not null) + @foreach (var src in Images.Take(10)) { - foreach (var src in Images.Take(10)) - { - @Alt - } + @(Alt ?? } @ChildContent
@code { - [Parameter] public IEnumerable Images { get; set; } + [Parameter] public IEnumerable Images { get; set; } = []; // "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image. - [Parameter] public string Alt { get; set; } = ""; + [Parameter] public string Alt { get; set; } [Parameter] public RenderFragment ChildContent { get; set; } } diff --git a/Components/Daisy/DataDisplay/DTextRotate.razor b/Components/Daisy/DataDisplay/DTextRotate.razor index 7b874f1..73bfc48 100644 --- a/Components/Daisy/DataDisplay/DTextRotate.razor +++ b/Components/Daisy/DataDisplay/DTextRotate.razor @@ -5,19 +5,16 @@ duration-* in Class (default 10s). *@ - @if (Items is not null) + @foreach (var item in Items.Take(6)) { - foreach (var item in Items.Take(6)) - { - @item - } + @item } @ChildContent @code { - [Parameter] public IEnumerable Items { get; set; } + [Parameter] public IEnumerable Items { get; set; } = []; [Parameter] public RenderFragment ChildContent { get; set; } /// Centre the lines horizontally. [Parameter] public bool Centered { get; set; } diff --git a/Components/Daisy/DataInput/DFilter.razor b/Components/Daisy/DataInput/DFilter.razor index 323ae9a..06a06eb 100644 --- a/Components/Daisy/DataInput/DFilter.razor +++ b/Components/Daisy/DataInput/DFilter.razor @@ -7,19 +7,16 @@
- @if (Options is not null) + @foreach (var option in Options) { - foreach (var option in Options) - { - - } + }
@code { - [Parameter] public IEnumerable Options { get; set; } + [Parameter] public IEnumerable Options { get; set; } = []; /// An option's button text; ToString() when null. [Parameter] public Func TextOf { get; set; } [Parameter] public string ResetText { get; set; } = "×"; diff --git a/Components/EmojiPicker.razor b/Components/EmojiPicker.razor index 7e095c1..9ba2ab5 100644 --- a/Components/EmojiPicker.razor +++ b/Components/EmojiPicker.razor @@ -103,7 +103,7 @@ /// The choices can't be made now (the caller is busy with the last one). [Parameter] public bool ChoicesDisabled { get; set; } /// Passed to the dropdown: its class, a data- hook. - [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary AdditionalAttributes { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary AdditionalAttributes { get; set; } = new Dictionary(); bool isOpen; string query; diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index 7199d73..741e1df 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -251,9 +251,9 @@ yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°"); yield return NerdRow.Of(Localizer["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°"); yield return NerdRow.Of(Localizer["Earth–Sun distance"], - $"{sun.Distance.ToString("0.00000", CultureInfo.InvariantCulture)} AU ({sun.Distance * 149_597_870.7:N0} km)"); + $"{sun.Distance.ToFixed(5)} AU ({(sun.Distance * 149_597_870.7).ToDefaultCount()} km)"); yield return NerdRow.Of(Localizer["irradiance"], - $"{sun.Irradiance * 1361:0} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture)}%)"); + $"{(sun.Irradiance * 1361).ToDefaultCount()} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToSignedPercent()})"); yield return NerdRow.Of(Localizer["Place"], sun.Place is { } place ? $"{place} ({Fixed(sun.PlaceLatitude)}, {Fixed(sun.PlaceLongitude)})" : null); yield return NerdRow.Of(Localizer["elevation"], sun.Elevation is { } elevation ? $"{Fixed(elevation)}°" : null); yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null); diff --git a/Components/Nerd/NerdRow.cs b/Components/Nerd/NerdRow.cs index 1c49054..e7639e4 100644 --- a/Components/Nerd/NerdRow.cs +++ b/Components/Nerd/NerdRow.cs @@ -1,5 +1,7 @@ using System.Globalization; +using decePubClient.Extensions; + namespace decePubClient.Components.Nerd; /// One line of a NerdTip; a row without a value is not shown. @@ -9,7 +11,7 @@ public sealed record NerdRow(string Label, string Value) { null => null, DateTime time => Time(time), - double number => number.ToString("0.###", CultureInfo.InvariantCulture), + double number => number.ToReadout(), bool flag => flag ? "yes" : "no", IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() @@ -27,8 +29,8 @@ public sealed record NerdRow(string Label, string Value) span = span.Duration(); return sign + (span.TotalSeconds switch { - < 1 => $"{span.TotalMilliseconds:0} ms", - < 60 => $"{span.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s", + < 1 => $"{span.TotalMilliseconds.ToFixed(0)} ms", + < 60 => $"{span.TotalSeconds.ToFixed(1)} s", < 3600 => $"{span.Minutes} min {span.Seconds} s", < 86400 => $"{(int)span.TotalHours} h {span.Minutes} min", _ => $"{(int)span.TotalDays} d {span.Hours} h" @@ -40,8 +42,8 @@ public sealed record NerdRow(string Label, string Value) { null => null, < 1024 => $"{bytes} B", - < 1024 * 1024 => $"{(bytes.Value / 1024d).ToString("0.0", CultureInfo.InvariantCulture)} KiB", - _ => $"{(bytes.Value / 1048576d).ToString("0.0", CultureInfo.InvariantCulture)} MiB" + < 1024 * 1024 => $"{(bytes.Value / 1024d).ToFixed(1)} KiB", + _ => $"{(bytes.Value / 1048576d).ToFixed(1)} MiB" }; /// The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km). diff --git a/Components/Settings/AccountEditor.razor b/Components/Settings/AccountEditor.razor index 259da37..243367d 100644 --- a/Components/Settings/AccountEditor.razor +++ b/Components/Settings/AccountEditor.razor @@ -58,9 +58,9 @@ { - -
@@ -75,7 +75,7 @@ { -
@@ -107,7 +107,7 @@ @Localizer["Change the password"]

@Localizer["Every other device signed in to this login is signed out."]

-
@Localizer["Delete this login"]

@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]

- diff --git a/Components/Settings/TotpEditor.razor b/Components/Settings/TotpEditor.razor index 33bd20d..2523427 100644 --- a/Components/Settings/TotpEditor.razor +++ b/Components/Settings/TotpEditor.razor @@ -65,7 +65,7 @@ { - @if (disableWithRecoveryCode) { @@ -133,7 +133,7 @@ { -
diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..202dc8b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + true + NumbersExtensions + Login.razor,Join.razor,Setup.razor,PasswordRecovery.razor,AccountEditor.razor,TotpEditor.razor,Groups.razor,ActDialog.razor,BackupsPanel.razor + $(WarningsAsErrors);HR0001;HR0002;HR0003;HR0004;HR0005;HR0006;HR0007;HR0008;HR0009;HR0010;HR0011;HR0101;HR0102;HR0103;HR0104;HR0105;HR0106;HR0201 + + + + + + + diff --git a/Extensions/GenericExtensions.cs b/Extensions/GenericExtensions.cs index 903a7e5..4f74de0 100644 --- a/Extensions/GenericExtensions.cs +++ b/Extensions/GenericExtensions.cs @@ -30,15 +30,6 @@ public static class GenericExtensions }; } - // a count as a phone's narrow action buttons show it: 999, 1.2k, 12k, 1.2M - public static string Compact(this int count) => count switch - { - < 1000 => count.ToString(CultureInfo.CurrentCulture), - < 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k", - < 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k", - _ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M" - }; - public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch { >= 1_048_576 => localizer["{0}MB", size / 1_048_576], diff --git a/Extensions/NumbersExtensions.cs b/Extensions/NumbersExtensions.cs new file mode 100644 index 0000000..b9881f7 --- /dev/null +++ b/Extensions/NumbersExtensions.cs @@ -0,0 +1,33 @@ +using System.Globalization; + +namespace decePubClient.Extensions; + +public static class NumbersExtensions +{ + public static string ToDefaultCount(this int value) => value.ToString("N0", CultureInfo.CurrentCulture); + + public static string ToDefaultCount(this long value) => value.ToString("N0", CultureInfo.CurrentCulture); + + public static string ToDefaultCount(this double value) => value.ToString("N0", CultureInfo.CurrentCulture); + + public static string Compact(this int count) => count switch + { + < 1000 => count.ToString(CultureInfo.CurrentCulture), + < 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k", + < 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k", + _ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M" + }; + + public static string ToDefaultPercent(this double percent) => percent.ToString("0.#", CultureInfo.CurrentCulture) + "%"; + + public static string ToWholePercent(this double percent) => percent.ToString("0", CultureInfo.CurrentCulture) + "%"; + + public static string ToSignedPercent(this double percent) => percent.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture) + "%"; + + public static string ToCssNumber(this double value) => value.ToString("0.#", CultureInfo.InvariantCulture); + + public static string ToReadout(this double value) => value.ToString("0.###", CultureInfo.InvariantCulture); + + public static string ToFixed(this double value, int decimals) => + value.ToString("F" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); +} diff --git a/Pages/Groups.razor b/Pages/Groups.razor index c21af77..7b0afe9 100644 --- a/Pages/Groups.razor +++ b/Pages/Groups.razor @@ -57,7 +57,7 @@ placeholder="@Localizer["Invitation code"]" data-group-code> - @Localizer["Join"] diff --git a/Pages/Index.razor b/Pages/Index.razor index 9fe9d8b..441ed5c 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -692,7 +692,7 @@ if (RequestStats.Latest(1).FirstOrDefault() is { } last) { yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}"); - yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs:0} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}"); + yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs.ToDefaultCount()} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}"); yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining); } } diff --git a/Pages/Join.razor b/Pages/Join.razor index 9361143..36b9aeb 100644 --- a/Pages/Join.razor +++ b/Pages/Join.razor @@ -29,13 +29,13 @@ - @Localizer["Your login"] diff --git a/Pages/Login.razor b/Pages/Login.razor index 274473d..fd0a70c 100644 --- a/Pages/Login.razor +++ b/Pages/Login.razor @@ -60,13 +60,13 @@ diff --git a/Pages/PasswordRecovery.razor b/Pages/PasswordRecovery.razor index e63facf..e334b50 100644 --- a/Pages/PasswordRecovery.razor +++ b/Pages/PasswordRecovery.razor @@ -35,7 +35,7 @@ @@ -43,7 +43,7 @@ diff --git a/Pages/Setup.razor b/Pages/Setup.razor index ced5463..09c955b 100644 --- a/Pages/Setup.razor +++ b/Pages/Setup.razor @@ -41,7 +41,7 @@ @Localizer["Your login"] diff --git a/Services/AccountService.cs b/Services/AccountService.cs index 163255f..3756778 100644 --- a/Services/AccountService.cs +++ b/Services/AccountService.cs @@ -279,7 +279,7 @@ namespace decePubClient.Services var account = await response.MastodonReadFromJsonAsync(cancellationToken); return account?.Id is null - ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) + ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest) : new WebResult { Data = account }; } @@ -293,7 +293,7 @@ namespace decePubClient.Services var account = await response.MastodonReadFromJsonAsync(cancellationToken); return account?.Id is null - ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) + ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest) : new WebResult { Data = account }; } diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 5e7ccd9..ab75260 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -77,7 +77,7 @@ namespace decePubClient.Services var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); if (string.IsNullOrEmpty(jwtUser?.Token)) - return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); + return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest); await authStateProvider.SetSession(jwtUser, cancellationToken); return new WebResult { Data = jwtUser }; @@ -262,7 +262,7 @@ namespace decePubClient.Services var setup = await response.DefaultReadFromJsonAsync(cancellationToken); if (string.IsNullOrEmpty(setup?.Secret)) - return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); + return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest); return new WebResult { Data = setup }; } @@ -282,7 +282,7 @@ namespace decePubClient.Services var enabled = await response.DefaultReadFromJsonAsync(cancellationToken); if (string.IsNullOrEmpty(enabled?.Session?.Token)) - return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); + return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest); await ContinueSession(enabled.Session, nameof(TotpEnable), cancellationToken); return new WebResult { Data = enabled.RecoveryCodes }; diff --git a/Services/IHttpService.cs b/Services/IHttpService.cs index a2fd82e..be7bd22 100644 --- a/Services/IHttpService.cs +++ b/Services/IHttpService.cs @@ -159,7 +159,7 @@ namespace decePubClient.Services if (string.IsNullOrEmpty(token?.AccessToken)) { response.Dispose(); - return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway), null); + return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadRequest), null); } return (response, token.AccessToken); @@ -205,7 +205,7 @@ namespace decePubClient.Services { await logger.ProcessError(ex, where, cancellationToken); return SUtility.ClassifiedFailure(FailureCodes.ClientError, - localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.ServiceUnavailable); + localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.BadRequest); } var delay = attempt < RetryRampUpDelaysMs.Length ? RetryRampUpDelaysMs[attempt] : RetryIndefiniteDelayMs; @@ -216,7 +216,7 @@ namespace decePubClient.Services if (transportException is not null) return await DescribeUnreachable(transportException, attempt + 1, cancellationToken); return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted, - localizer["The server is still not answering after {0} attempts. Try again later.", attempt + 1], HttpStatusCode.ServiceUnavailable); + localizer["The server is still not answering after {0} attempts. Try again later.", attempt + 1], HttpStatusCode.BadRequest); } await logger.ProcessWarning($"Attempt {attempt + 1} failed, retrying in {delay}ms. {failure}", where, cancellationToken); @@ -245,18 +245,18 @@ namespace decePubClient.Services { if (!await statusService.IsOnline(cancellationToken)) return SUtility.ClassifiedFailure(FailureCodes.DeviceOffline, - localizer["This device is offline. Check the connection and try again."], HttpStatusCode.ServiceUnavailable); + localizer["This device is offline. Check the connection and try again."], HttpStatusCode.BadRequest); if (exception is TaskCanceledException { InnerException: TimeoutException }) return SUtility.ClassifiedFailure(FailureCodes.Timeout, - localizer["The server took too long to answer. Try again later."], HttpStatusCode.ServiceUnavailable); + localizer["The server took too long to answer. Try again later."], HttpStatusCode.BadRequest); if (attempts > 1) return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted, - localizer["The server is still not answering after {0} attempts. Try again later.", attempts], HttpStatusCode.ServiceUnavailable); + localizer["The server is still not answering after {0} attempts. Try again later.", attempts], HttpStatusCode.BadRequest); return SUtility.ClassifiedFailure(FailureCodes.ApiUnreachable, - localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable); + localizer["The server can't be reached. Try again later."], HttpStatusCode.BadRequest); } Task SendAnonymousRequest(Func requestFactory, CancellationToken cancellationToken) => @@ -403,7 +403,7 @@ namespace decePubClient.Services using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout); var started = Stopwatch.GetTimestamp(); - HttpResponseMessage response; + var response = default(HttpResponseMessage); try { response = await httpClient.SendAsync(request, timeout.Token); diff --git a/Services/InstanceAdminService.cs b/Services/InstanceAdminService.cs index 497846f..7705e1b 100644 --- a/Services/InstanceAdminService.cs +++ b/Services/InstanceAdminService.cs @@ -65,7 +65,7 @@ namespace decePubClient.Services if (!current.IsSuccessStatusCode) return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken); if (await current.DefaultReadFromJsonAsync(cancellationToken) is not { } latest) - return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); + return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest); var form = FormOf(latest); change(form); diff --git a/Services/MediaService.cs b/Services/MediaService.cs index ca8ba37..2e7e068 100644 --- a/Services/MediaService.cs +++ b/Services/MediaService.cs @@ -98,7 +98,7 @@ namespace decePubClient.Services { var attachment = await response.MastodonReadFromJsonAsync(cancellationToken); return attachment?.Id is null - ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) + ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest) : new WebResult { Data = attachment }; } } diff --git a/Services/OverlayService.cs b/Services/OverlayService.cs index abbfbe2..2886406 100644 --- a/Services/OverlayService.cs +++ b/Services/OverlayService.cs @@ -29,7 +29,7 @@ namespace decePubClient.Services public async Task CloseTop(CancellationToken cancellationToken = default) { - Entry top; + var top = default(Entry); lock (open) top = open.LastOrDefault(); if (top is null) @@ -40,7 +40,7 @@ namespace decePubClient.Services public async Task CloseAll(CancellationToken cancellationToken = default) { - Entry[] all; + var all = default(Entry[]); lock (open) { all = [.. open]; diff --git a/Services/PushService.cs b/Services/PushService.cs index fd6beb8..c1f72bd 100644 --- a/Services/PushService.cs +++ b/Services/PushService.cs @@ -112,7 +112,7 @@ namespace decePubClient.Services if (personaIds.Count > 0 && key is null) { await Save(device, cancellationToken); - return result.Invalidate(localizer["This server does not offer push notifications."], (int)HttpStatusCode.NotImplemented); + return result.Invalidate(localizer["This server does not offer push notifications."], (int)HttpStatusCode.BadRequest); } foreach (var personaId in personaIds) @@ -252,7 +252,7 @@ namespace decePubClient.Services { if (isNew) await Js("unsubscribe", false, [entry.Slot], cancellationToken); - return new WebResult().Invalidate(localizer["This browser did not subscribe: {0}", browser?.Error ?? "?"], (int)HttpStatusCode.ServiceUnavailable); + return new WebResult().Invalidate(localizer["This browser did not subscribe: {0}", browser?.Error ?? "?"], (int)HttpStatusCode.BadRequest); } var result = await Tell(device, entry.PersonaId, browser, cancellationToken); diff --git a/Services/StatusService.cs b/Services/StatusService.cs index 5504ef0..e6c5470 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -332,7 +332,7 @@ namespace decePubClient.Services var status = await response.MastodonReadFromJsonAsync(cancellationToken); return status is null - ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) + ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest) : new WebResult { Data = status }; } diff --git a/Services/StreamingService.cs b/Services/StreamingService.cs index 945df5b..032ba9a 100644 --- a/Services/StreamingService.cs +++ b/Services/StreamingService.cs @@ -183,7 +183,7 @@ namespace decePubClient.Services while (socket.State == WebSocketState.Open) { var text = new StringBuilder(); - WebSocketReceiveResult received; + var received = default(WebSocketReceiveResult); do { received = await socket.ReceiveAsync(buffer, cancellationToken); @@ -229,7 +229,7 @@ namespace decePubClient.Services /// Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens /// the socket for them too. - public async Task Stop() + public async Task Stop(CancellationToken cancellationToken = default) { current = null; if (running is not null) diff --git a/Services/TimelineService.cs b/Services/TimelineService.cs index 8a8ed73..22e888b 100644 --- a/Services/TimelineService.cs +++ b/Services/TimelineService.cs @@ -53,7 +53,7 @@ namespace decePubClient.Services catch (Exception ex) { await logger.ProcessError(ex, $"{nameof(TimelineService)}.{nameof(Load)}:{query.Type}", cancellationToken); - return new WebResult().Invalidate(localizer["The feed could not be read."], (int)HttpStatusCode.BadGateway, exception: ex); + return new WebResult().Invalidate(localizer["The feed could not be read."], (int)HttpStatusCode.BadRequest, exception: ex); } } diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..6f123d5 --- /dev/null +++ b/nuget.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + +