House rules: numbers through one formatter, no made-up server errors, and the rules fail the build
Build / Build (push) Successful in 33s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-10 01:26:39 +01:00
1 parent 697b2c8897
commit 3827d903a3
37 files changed
+165 -111

No files matched your search

+25 -18
View File
@@ -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 the app on that persona's notifications, `pushsubscriptionchange` subscribes again with the same key. They control no
page and have no fetch handler. 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, The owner's rules for every .NET project (`~/RiderProjects/HouseRules/RULES.md`, repo `Arasaka/HouseRules`) apply
so follow them in every change. 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 | | Area | Rule |
|---|---| |---|---|
| All | `var` everywhere; uninitialised locals are `var x = default(T);` | | All | `var` everywhere; uninitialised locals are `var x = default(T);` (HR0001) |
| All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields | | All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields (HR0004) |
| All | Every async method takes a `CancellationToken` as its last parameter | | 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 | | 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 | | 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 | | Components | No `@{ }` blocks in markup: locals become `@code` members (HR0102) |
| Components | `[Inject]` in `@code`, never `@inject` | | Components | `[Inject]` in `@code`, never `@inject` (HR0101) |
| Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state | | Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state (HR0104) |
| Components | Every text/number input declares `autocomplete` | | 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<WebResult>` for mutations, `Task<List<T>>` (`[]` on error) for lists, `Task<T>` with a default for values | | Services | `Task<WebResult>` for mutations, `Task<List<T>>` (`[]` on error) for lists, `Task<T>` with a default for values |
| Localization | Every user-facing string through `Localizer`, its key in **both** `AllStrings.resx` and `AllStrings.it.resx` | | Localization | Every user-facing string through `Localizer`, its key in **both** `AllStrings.resx` and `AllStrings.it.resx` |
@@ -881,7 +883,7 @@ List<FeedPost> posts = []; // DO
@(posts?.Count ?? 0) // DON'T @(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: **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 `<br/>` or `\n`.
**Autocomplete values:** **Autocomplete values:**
- `autocomplete="off"` for search, filter and free text; - `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); - `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. - inside `Components/Daisy`, attributes pass through from the caller.
**Localization:** **Localization:**
- Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly. - 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 - 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. 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. - 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. - Language names in the language picker stay in their own language.
**Formatting.** Date formats come from `SUtility.dateTimeFormat` / `timeFormat` / `dateTimeFormatExt`. Relative times **Formatting.** Numbers go through `NumbersExtensions` (`Extensions/NumbersExtensions.cs`, HR0011): `ToDefaultCount`,
and sizes come from `GetPassedTime` / `GetFileSize` (`Extensions/GenericExtensions.cs`). No inline format strings for `Compact` (999, 1.2k), `ToDefaultPercent`, `ToWholePercent`, `ToSignedPercent`, `ToReadout` (nerd tips), `ToFixed`,
display. 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 **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). `pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case).
+1 -1
View File
@@ -34,7 +34,7 @@
} }
@if (AskPassword) @if (AskPassword)
{ {
<DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200" <DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@(Staff ? Localizer["Your password"] : Localizer["Current password"])" placeholder="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])" aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" /> data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" />
+2 -2
View File
@@ -148,7 +148,7 @@
@if (ticket is null) @if (ticket is null)
{ {
<p class="text-sm">@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:"]</p> <p class="text-sm">@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:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200" <DInput TValue="string" type="password" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password /> placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" /> <ErrorViewer AdditionalError="@dialogError" />
} }
@@ -182,7 +182,7 @@
{ {
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p> <p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
} }
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200" <DInput TValue="string" type="password" @bind-Value="password" Immediate="true" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password /> placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253" <DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host /> placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
+1 -1
View File
@@ -43,7 +43,7 @@
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x> <DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
<DStat> <DStat>
<Heading>@Localizer["Deliveries that arrived"]</Heading> <Heading>@Localizer["Deliveries that arrived"]</Heading>
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent> <ChildContent>@(overview.DeliverySuccess is { } success ? (success * 100).ToDefaultPercent() : "—")</ChildContent>
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc> <Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
</DStat> </DStat>
<DStat> <DStat>
+1 -1
View File
@@ -162,7 +162,7 @@
} }
else else
{ {
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span> <span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToDefaultCount()]</span>
} }
</span> </span>
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" /> <DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
+7 -7
View File
@@ -804,7 +804,7 @@
var option = poll.Options[i]; var option = poll.Options[i];
var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0; var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1"> <div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div> <div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToCssNumber())%"></div>
<span class="relative flex justify-between gap-2"> <span class="relative flex justify-between gap-2">
<span> <span>
@option.Title @option.Title
@@ -813,7 +813,7 @@
<i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i> <i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i>
} }
</span> </span>
<span>@share.ToString("0")%</span> <span>@share.ToWholePercent()</span>
</span> </span>
</div> </div>
} }
@@ -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 // 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 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; : null;
static bool IsWebLink(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; 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["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["software"], $"{server.Software} {server.Version}".Trim());
yield return NerdRow.Of(Localizer["Users"], server.Users is { } users 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); : null);
yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts); 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); yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null);
@@ -1553,7 +1553,7 @@
_ => Localizer["Near you"] _ => Localizer["Near you"]
}); });
yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}"); 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) if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo)
{ {
yield return NerdRow.Of(Localizer["network"], geo.Network); yield return NerdRow.Of(Localizer["network"], geo.Network);
@@ -1561,7 +1561,7 @@
yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt); 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) 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<NerdRow> MediaRows(MediaAttachment media) static IEnumerable<NerdRow> MediaRows(MediaAttachment media)
@@ -1570,7 +1570,7 @@
yield return NerdRow.Of("size", media.Meta?.Original?.Size); yield return NerdRow.Of("size", media.Meta?.Original?.Size);
yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect); yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect);
yield return NerdRow.Of("length", media.Meta?.Length); 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("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("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); yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null);
+1 -1
View File
@@ -36,7 +36,7 @@
[Parameter] public bool CloseOnContentClick { get; set; } [Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; } [Parameter] public string TriggerClass { get; set; }
/// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary> /// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary>
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; } [Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; } = new Dictionary<string, object>();
[Parameter] public string ContentClass { get; set; } [Parameter] public string ContentClass { get; set; }
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary> /// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
[Parameter] public bool SheetOnNarrow { get; set; } [Parameter] public bool SheetOnNarrow { get; set; }
+1 -1
View File
@@ -10,7 +10,7 @@ public abstract class DComponentBase : ComponentBase
{ {
[Parameter] public string Class { get; set; } [Parameter] public string Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();
protected static string Cx(params string[] classes) => DCx.Join(classes); protected static string Cx(params string[] classes) => DCx.Join(classes);
} }
+2 -2
View File
@@ -7,7 +7,7 @@
<div class="@Class"> <div class="@Class">
@if (Src is not null) @if (Src is not null)
{ {
<img src="@Src" alt="@Alt" /> <img src="@Src" alt="@(Alt ?? "")" />
} }
else else
{ {
@@ -19,7 +19,7 @@
@code { @code {
[Parameter] public string Src { get; set; } [Parameter] public string Src { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image. // "" 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; }
/// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary> /// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary>
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DAvatarStatus Status { get; set; } [Parameter] public DAvatarStatus Status { get; set; }
@@ -4,19 +4,16 @@
across it shows the others. Up to 10 images of the same size, from Images or as <img> children; cap its width in across it shows the others. Up to 10 images of the same size, from Images or as <img> children; cap its width in
Class (max-w-60), or it fills the container. *@ Class (max-w-60), or it fills the container. *@
<figure @attributes="AdditionalAttributes" class="@Cx("hover-gallery", Class)"> <figure @attributes="AdditionalAttributes" class="@Cx("hover-gallery", Class)">
@if (Images is not null) @foreach (var src in Images.Take(10))
{ {
foreach (var src in Images.Take(10)) <img src="@src" alt="@(Alt ?? "")" />
{
<img src="@src" alt="@Alt" />
}
} }
@ChildContent @ChildContent
</figure> </figure>
@code { @code {
[Parameter] public IEnumerable<string> Images { get; set; } [Parameter] public IEnumerable<string> Images { get; set; } = [];
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image. // "" 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; } [Parameter] public RenderFragment ChildContent { get; set; }
} }
@@ -5,19 +5,16 @@
duration-* in Class (default 10s). *@ duration-* in Class (default 10s). *@
<span @attributes="AdditionalAttributes" class="@Cx("text-rotate", Class)"> <span @attributes="AdditionalAttributes" class="@Cx("text-rotate", Class)">
<span class="@(Centered ? "justify-items-center" : null)"> <span class="@(Centered ? "justify-items-center" : null)">
@if (Items is not null) @foreach (var item in Items.Take(6))
{ {
foreach (var item in Items.Take(6)) <span>@item</span>
{
<span>@item</span>
}
} }
@ChildContent @ChildContent
</span> </span>
</span> </span>
@code { @code {
[Parameter] public IEnumerable<string> Items { get; set; } [Parameter] public IEnumerable<string> Items { get; set; } = [];
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Centre the lines horizontally.</summary> /// <summary>Centre the lines horizontally.</summary>
[Parameter] public bool Centered { get; set; } [Parameter] public bool Centered { get; set; }
+5 -8
View File
@@ -7,19 +7,16 @@
<div @attributes="InputAttributes" class="@Cx("filter", CssClass)"> <div @attributes="InputAttributes" class="@Cx("filter", CssClass)">
<input type="radio" name="@GroupName" class="@Cx("btn", "filter-reset", SizeClass)" aria-label="@ResetText" <input type="radio" name="@GroupName" class="@Cx("btn", "filter-reset", SizeClass)" aria-label="@ResetText"
disabled="@Disabled" @onchange="() => CurrentValue = default" /> disabled="@Disabled" @onchange="() => CurrentValue = default" />
@if (Options is not null) @foreach (var option in Options)
{ {
foreach (var option in Options) <input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())"
{ checked="@IsSelected(option)" disabled="@Disabled"
<input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())" @onchange="() => CurrentValue = option" />
checked="@IsSelected(option)" disabled="@Disabled"
@onchange="() => CurrentValue = option" />
}
} }
</div> </div>
@code { @code {
[Parameter] public IEnumerable<TValue> Options { get; set; } [Parameter] public IEnumerable<TValue> Options { get; set; } = [];
/// <summary>An option's button text; ToString() when null.</summary> /// <summary>An option's button text; ToString() when null.</summary>
[Parameter] public Func<TValue, string> TextOf { get; set; } [Parameter] public Func<TValue, string> TextOf { get; set; }
[Parameter] public string ResetText { get; set; } = "×"; [Parameter] public string ResetText { get; set; } = "×";
+1 -1
View File
@@ -103,7 +103,7 @@
/// <summary>The choices can't be made now (the caller is busy with the last one).</summary> /// <summary>The choices can't be made now (the caller is busy with the last one).</summary>
[Parameter] public bool ChoicesDisabled { get; set; } [Parameter] public bool ChoicesDisabled { get; set; }
/// <summary>Passed to the dropdown: its class, a data- hook.</summary> /// <summary>Passed to the dropdown: its class, a data- hook.</summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();
bool isOpen; bool isOpen;
string query; string query;
+2 -2
View File
@@ -251,9 +251,9 @@
yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°"); 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["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°");
yield return NerdRow.Of(Localizer["Earth–Sun distance"], 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"], 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["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["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); yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null);
+7 -5
View File
@@ -1,5 +1,7 @@
using System.Globalization; using System.Globalization;
using decePubClient.Extensions;
namespace decePubClient.Components.Nerd; namespace decePubClient.Components.Nerd;
/// <summary>One line of a NerdTip; a row without a value is not shown.</summary> /// <summary>One line of a NerdTip; a row without a value is not shown.</summary>
@@ -9,7 +11,7 @@ public sealed record NerdRow(string Label, string Value)
{ {
null => null, null => null,
DateTime time => Time(time), DateTime time => Time(time),
double number => number.ToString("0.###", CultureInfo.InvariantCulture), double number => number.ToReadout(),
bool flag => flag ? "yes" : "no", bool flag => flag ? "yes" : "no",
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() _ => value.ToString()
@@ -27,8 +29,8 @@ public sealed record NerdRow(string Label, string Value)
span = span.Duration(); span = span.Duration();
return sign + (span.TotalSeconds switch return sign + (span.TotalSeconds switch
{ {
< 1 => $"{span.TotalMilliseconds:0} ms", < 1 => $"{span.TotalMilliseconds.ToFixed(0)} ms",
< 60 => $"{span.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s", < 60 => $"{span.TotalSeconds.ToFixed(1)} s",
< 3600 => $"{span.Minutes} min {span.Seconds} s", < 3600 => $"{span.Minutes} min {span.Seconds} s",
< 86400 => $"{(int)span.TotalHours} h {span.Minutes} min", < 86400 => $"{(int)span.TotalHours} h {span.Minutes} min",
_ => $"{(int)span.TotalDays} d {span.Hours} h" _ => $"{(int)span.TotalDays} d {span.Hours} h"
@@ -40,8 +42,8 @@ public sealed record NerdRow(string Label, string Value)
{ {
null => null, null => null,
< 1024 => $"{bytes} B", < 1024 => $"{bytes} B",
< 1024 * 1024 => $"{(bytes.Value / 1024d).ToString("0.0", CultureInfo.InvariantCulture)} KiB", < 1024 * 1024 => $"{(bytes.Value / 1024d).ToFixed(1)} KiB",
_ => $"{(bytes.Value / 1048576d).ToString("0.0", CultureInfo.InvariantCulture)} MiB" _ => $"{(bytes.Value / 1048576d).ToFixed(1)} MiB"
}; };
/// <summary>The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km).</summary> /// <summary>The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km).</summary>
+5 -5
View File
@@ -58,9 +58,9 @@
{ {
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change> <EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none" <DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" inputmode="email" autocapitalize="none"
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new /> spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password" <DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-email-password /> placeholder="@Localizer["Current password"]" data-account-email-password />
<ErrorViewer /> <ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2"> <div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@@ -75,7 +75,7 @@
{ {
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove> <EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password" <DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-email-remove-password /> placeholder="@Localizer["Current password"]" data-account-email-remove-password />
<ErrorViewer /> <ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2"> <div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@@ -107,7 +107,7 @@
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b> <b class="text-sm">@Localizer["Change the password"]</b>
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p> <p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password" <DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-current /> placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password" <DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
@@ -135,7 +135,7 @@
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b> <b class="text-sm text-error">@Localizer["Delete this login"]</b>
<p class="text-sm">@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."]</p> <p class="text-sm">@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."]</p>
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password" <DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-account-delete-password /> placeholder="@Localizer["Current password"]" data-account-delete-password />
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood /> <DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
<ErrorViewer /> <ErrorViewer />
+2 -2
View File
@@ -65,7 +65,7 @@
{ {
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable> <EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password" <DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-totp-disable-password /> placeholder="@Localizer["Current password"]" data-totp-disable-password />
@if (disableWithRecoveryCode) @if (disableWithRecoveryCode)
{ {
@@ -133,7 +133,7 @@
{ {
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup> <EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password" <DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true"
placeholder="@Localizer["Current password"]" data-totp-setup-password /> placeholder="@Localizer["Current password"]" data-totp-setup-password />
<ErrorViewer /> <ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2"> <div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<HouseRulesFormatHelpers>NumbersExtensions</HouseRulesFormatHelpers>
<HouseRulesAutocompleteOmit>Login.razor,Join.razor,Setup.razor,PasswordRecovery.razor,AccountEditor.razor,TotpEditor.razor,Groups.razor,ActDialog.razor,BackupsPanel.razor</HouseRulesAutocompleteOmit>
<WarningsAsErrors>$(WarningsAsErrors);HR0001;HR0002;HR0003;HR0004;HR0005;HR0006;HR0007;HR0008;HR0009;HR0010;HR0011;HR0101;HR0102;HR0103;HR0104;HR0105;HR0106;HR0201</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arasaka.HouseRules.Analyzers" Version="1.0.4" PrivateAssets="all" />
</ItemGroup>
</Project>
-9
View File
@@ -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 public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
{ {
>= 1_048_576 => localizer["{0}MB", size / 1_048_576], >= 1_048_576 => localizer["{0}MB", size / 1_048_576],
+33
View File
@@ -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);
}
+1 -1
View File
@@ -57,7 +57,7 @@
placeholder="@Localizer["Invitation code"]" data-group-code> placeholder="@Localizer["Invitation code"]" data-group-code>
<Prefix><i class="ion-md-key"></i></Prefix> <Prefix><i class="ion-md-key"></i></Prefix>
</DInput> </DInput>
<DInput TValue="string" type="password" @bind-Value="joining.InvitationPassword" Immediate="true" autocomplete="current-password" maxlength="128" <DInput TValue="string" type="password" @bind-Value="joining.InvitationPassword" Immediate="true" maxlength="128"
placeholder="@Localizer["Invitation password, if it has one"]" /> placeholder="@Localizer["Invitation password, if it has one"]" />
<DButton Type="submit" Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(joining.InvitationCode))" data-group-join-submit> <DButton Type="submit" Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(joining.InvitationCode))" data-group-join-submit>
<i class="ion-md-log-in"></i><span>@Localizer["Join"]</span> <i class="ion-md-log-in"></i><span>@Localizer["Join"]</span>
+1 -1
View File
@@ -692,7 +692,7 @@
if (RequestStats.Latest(1).FirstOrDefault() is { } last) if (RequestStats.Latest(1).FirstOrDefault() is { } last)
{ {
yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}"); 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); yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining);
} }
} }
+2 -2
View File
@@ -29,13 +29,13 @@
<DInput TValue="string" class="w-full font-mono text-xs" @bind-Value="form.InvitationCode" autocomplete="off" autocapitalize="none" spellcheck="false" <DInput TValue="string" class="w-full font-mono text-xs" @bind-Value="form.InvitationCode" autocomplete="off" autocapitalize="none" spellcheck="false"
maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code /> maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code />
</label> </label>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.InvitationPassword" autocomplete="off" <DInput TValue="string" Type="password" class="w-full" @bind-Value="form.InvitationPassword"
placeholder="@Localizer["Invitation password, if it has one"]" data-join-code-password /> placeholder="@Localizer["Invitation password, if it has one"]" data-join-code-password />
<b class="text-sm">@Localizer["Your login"]</b> <b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span> <span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-join-username> <DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocapitalize="none" spellcheck="false" data-join-username>
<Prefix><i class="ion-md-person"></i></Prefix> <Prefix><i class="ion-md-person"></i></Prefix>
</DInput> </DInput>
</label> </label>
+2 -2
View File
@@ -60,13 +60,13 @@
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span> <span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false"> <DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix> <Prefix><i class="ion-md-person"></i></Prefix>
</DInput> </DInput>
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span> <span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password"> <DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password">
<Prefix><i class="ion-md-key"></i></Prefix> <Prefix><i class="ion-md-key"></i></Prefix>
</DInput> </DInput>
</label> </label>
+2 -2
View File
@@ -35,7 +35,7 @@
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span> <span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName" <DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username> Immediate="true" disabled="@request.IsUsernameDisabled" autocapitalize="none" spellcheck="false" data-recovery-username>
<Prefix><i class="ion-md-person"></i></Prefix> <Prefix><i class="ion-md-person"></i></Prefix>
</DInput> </DInput>
</label> </label>
@@ -43,7 +43,7 @@
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Email address"]</span> <span class="text-sm">@Localizer["Email address"]</span>
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email" <DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email> Immediate="true" disabled="@request.IsEmailDisabled" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
<Prefix><i class="ion-md-mail"></i></Prefix> <Prefix><i class="ion-md-mail"></i></Prefix>
</DInput> </DInput>
</label> </label>
+1 -1
View File
@@ -41,7 +41,7 @@
<b class="text-sm">@Localizer["Your login"]</b> <b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span> <span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-setup-username> <DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocapitalize="none" spellcheck="false" data-setup-username>
<Prefix><i class="ion-md-person"></i></Prefix> <Prefix><i class="ion-md-person"></i></Prefix>
</DInput> </DInput>
</label> </label>
+2 -2
View File
@@ -279,7 +279,7 @@ namespace decePubClient.Services
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken); var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
return account?.Id is null 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 }; : new WebResult { Data = account };
} }
@@ -293,7 +293,7 @@ namespace decePubClient.Services
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken); var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
return account?.Id is null 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 }; : new WebResult { Data = account };
} }
+3 -3
View File
@@ -77,7 +77,7 @@ namespace decePubClient.Services
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken); var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
if (string.IsNullOrEmpty(jwtUser?.Token)) 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); await authStateProvider.SetSession(jwtUser, cancellationToken);
return new WebResult { Data = jwtUser }; return new WebResult { Data = jwtUser };
@@ -262,7 +262,7 @@ namespace decePubClient.Services
var setup = await response.DefaultReadFromJsonAsync<ViewTotpSetup>(cancellationToken); var setup = await response.DefaultReadFromJsonAsync<ViewTotpSetup>(cancellationToken);
if (string.IsNullOrEmpty(setup?.Secret)) 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 }; return new WebResult { Data = setup };
} }
@@ -282,7 +282,7 @@ namespace decePubClient.Services
var enabled = await response.DefaultReadFromJsonAsync<ViewTotpEnabled>(cancellationToken); var enabled = await response.DefaultReadFromJsonAsync<ViewTotpEnabled>(cancellationToken);
if (string.IsNullOrEmpty(enabled?.Session?.Token)) 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); await ContinueSession(enabled.Session, nameof(TotpEnable), cancellationToken);
return new WebResult { Data = enabled.RecoveryCodes }; return new WebResult { Data = enabled.RecoveryCodes };
+8 -8
View File
@@ -159,7 +159,7 @@ namespace decePubClient.Services
if (string.IsNullOrEmpty(token?.AccessToken)) if (string.IsNullOrEmpty(token?.AccessToken))
{ {
response.Dispose(); 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); return (response, token.AccessToken);
@@ -205,7 +205,7 @@ namespace decePubClient.Services
{ {
await logger.ProcessError(ex, where, cancellationToken); await logger.ProcessError(ex, where, cancellationToken);
return SUtility.ClassifiedFailure(FailureCodes.ClientError, 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; var delay = attempt < RetryRampUpDelaysMs.Length ? RetryRampUpDelaysMs[attempt] : RetryIndefiniteDelayMs;
@@ -216,7 +216,7 @@ namespace decePubClient.Services
if (transportException is not null) if (transportException is not null)
return await DescribeUnreachable(transportException, attempt + 1, cancellationToken); return await DescribeUnreachable(transportException, attempt + 1, cancellationToken);
return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted, 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); 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)) if (!await statusService.IsOnline(cancellationToken))
return SUtility.ClassifiedFailure(FailureCodes.DeviceOffline, 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 }) if (exception is TaskCanceledException { InnerException: TimeoutException })
return SUtility.ClassifiedFailure(FailureCodes.Timeout, 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) if (attempts > 1)
return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted, 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, 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<HttpResponseMessage> SendAnonymousRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken) => Task<HttpResponseMessage> SendAnonymousRequest(Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken) =>
@@ -403,7 +403,7 @@ namespace decePubClient.Services
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout); timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
var started = Stopwatch.GetTimestamp(); var started = Stopwatch.GetTimestamp();
HttpResponseMessage response; var response = default(HttpResponseMessage);
try try
{ {
response = await httpClient.SendAsync(request, timeout.Token); response = await httpClient.SendAsync(request, timeout.Token);
+1 -1
View File
@@ -65,7 +65,7 @@ namespace decePubClient.Services
if (!current.IsSuccessStatusCode) if (!current.IsSuccessStatusCode)
return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken); return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken);
if (await current.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) is not { } latest) if (await current.DefaultReadFromJsonAsync<ViewServerSettings>(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); var form = FormOf(latest);
change(form); change(form);
+1 -1
View File
@@ -98,7 +98,7 @@ namespace decePubClient.Services
{ {
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken); var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null 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 }; : new WebResult { Data = attachment };
} }
} }
+2 -2
View File
@@ -29,7 +29,7 @@ namespace decePubClient.Services
public async Task CloseTop(CancellationToken cancellationToken = default) public async Task CloseTop(CancellationToken cancellationToken = default)
{ {
Entry top; var top = default(Entry);
lock (open) lock (open)
top = open.LastOrDefault(); top = open.LastOrDefault();
if (top is null) if (top is null)
@@ -40,7 +40,7 @@ namespace decePubClient.Services
public async Task CloseAll(CancellationToken cancellationToken = default) public async Task CloseAll(CancellationToken cancellationToken = default)
{ {
Entry[] all; var all = default(Entry[]);
lock (open) lock (open)
{ {
all = [.. open]; all = [.. open];
+2 -2
View File
@@ -112,7 +112,7 @@ namespace decePubClient.Services
if (personaIds.Count > 0 && key is null) if (personaIds.Count > 0 && key is null)
{ {
await Save(device, cancellationToken); 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) foreach (var personaId in personaIds)
@@ -252,7 +252,7 @@ namespace decePubClient.Services
{ {
if (isNew) if (isNew)
await Js("unsubscribe", false, [entry.Slot], cancellationToken); 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); var result = await Tell(device, entry.PersonaId, browser, cancellationToken);
+1 -1
View File
@@ -332,7 +332,7 @@ namespace decePubClient.Services
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken); var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return status is null 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 }; : new WebResult { Data = status };
} }
+2 -2
View File
@@ -183,7 +183,7 @@ namespace decePubClient.Services
while (socket.State == WebSocketState.Open) while (socket.State == WebSocketState.Open)
{ {
var text = new StringBuilder(); var text = new StringBuilder();
WebSocketReceiveResult received; var received = default(WebSocketReceiveResult);
do do
{ {
received = await socket.ReceiveAsync(buffer, cancellationToken); received = await socket.ReceiveAsync(buffer, cancellationToken);
@@ -229,7 +229,7 @@ namespace decePubClient.Services
/// <summary>Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens /// <summary>Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens
/// the socket for them too.</summary> /// the socket for them too.</summary>
public async Task Stop() public async Task Stop(CancellationToken cancellationToken = default)
{ {
current = null; current = null;
if (running is not null) if (running is not null)
+1 -1
View File
@@ -53,7 +53,7 @@ namespace decePubClient.Services
catch (Exception ex) catch (Exception ex)
{ {
await logger.ProcessError(ex, $"{nameof(TimelineService)}.{nameof(Load)}:{query.Type}", cancellationToken); 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);
} }
} }
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="nget" value="https://nget.thepra.dev/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nget">
<package pattern="Arasaka.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>