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
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<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` |
@@ -881,7 +883,7 @@ List<FeedPost> 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 `<br/>` 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).
+1 -1
View File
@@ -34,7 +34,7 @@
}
@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"])"
aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" />
+2 -2
View File
@@ -148,7 +148,7 @@
@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>
<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 />
<ErrorViewer AdditionalError="@dialogError" />
}
@@ -182,7 +182,7 @@
{
<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 />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
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>
<DStat>
<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>
</DStat>
<DStat>
+1 -1
View File
@@ -162,7 +162,7 @@
}
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>
<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 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="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>
@option.Title
@@ -813,7 +813,7 @@
<i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i>
}
</span>
<span>@share.ToString("0")%</span>
<span>@share.ToWholePercent()</span>
</span>
</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
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<NerdRow> 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);
+1 -1
View File
@@ -36,7 +36,7 @@
[Parameter] public bool CloseOnContentClick { 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>
[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; }
/// <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; }
+1 -1
View File
@@ -10,7 +10,7 @@ public abstract class DComponentBase : ComponentBase
{
[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);
}
+2 -2
View File
@@ -7,7 +7,7 @@
<div class="@Class">
@if (Src is not null)
{
<img src="@Src" alt="@Alt" />
<img src="@Src" 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; }
/// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary>
[Parameter] public RenderFragment ChildContent { 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
Class (max-w-60), or it fills the container. *@
<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
</figure>
@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.
[Parameter] public string Alt { get; set; } = "";
[Parameter] public string Alt { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
}
@@ -5,19 +5,16 @@
duration-* in Class (default 10s). *@
<span @attributes="AdditionalAttributes" class="@Cx("text-rotate", Class)">
<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
</span>
</span>
@code {
[Parameter] public IEnumerable<string> Items { get; set; }
[Parameter] public IEnumerable<string> Items { get; set; } = [];
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Centre the lines horizontally.</summary>
[Parameter] public bool Centered { get; set; }
+5 -8
View File
@@ -7,19 +7,16 @@
<div @attributes="InputAttributes" class="@Cx("filter", CssClass)">
<input type="radio" name="@GroupName" class="@Cx("btn", "filter-reset", SizeClass)" aria-label="@ResetText"
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"
@onchange="() => CurrentValue = option" />
}
<input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())"
checked="@IsSelected(option)" disabled="@Disabled"
@onchange="() => CurrentValue = option" />
}
</div>
@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>
[Parameter] public Func<TValue, string> TextOf { 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>
[Parameter] public bool ChoicesDisabled { get; set; }
/// <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;
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["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);
+7 -5
View File
@@ -1,5 +1,7 @@
using System.Globalization;
using decePubClient.Extensions;
namespace decePubClient.Components.Nerd;
/// <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,
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"
};
/// <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>
<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 />
<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 />
<ErrorViewer />
<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>
<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 />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@@ -107,7 +107,7 @@
<DataAnnotationsValidator />
<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>
<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 />
<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"
@@ -135,7 +135,7 @@
<DataAnnotationsValidator />
<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>
<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 />
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
<ErrorViewer />
+2 -2
View File
@@ -65,7 +65,7 @@
{
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<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 />
@if (disableWithRecoveryCode)
{
@@ -133,7 +133,7 @@
{
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<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 />
<ErrorViewer />
<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
{
>= 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>
<Prefix><i class="ion-md-key"></i></Prefix>
</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"]" />
<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>
+1 -1
View File
@@ -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);
}
}
+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"
maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code />
</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 />
<b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1">
<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>
</DInput>
</label>
+2 -2
View File
@@ -60,13 +60,13 @@
<label class="flex flex-col gap-1">
<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>
</DInput>
</label>
<label class="flex flex-col gap-1">
<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>
</DInput>
</label>
+2 -2
View File
@@ -35,7 +35,7 @@
<label class="flex flex-col gap-1">
<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"
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>
</DInput>
</label>
@@ -43,7 +43,7 @@
<label class="flex flex-col gap-1">
<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"
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>
</DInput>
</label>
+1 -1
View File
@@ -41,7 +41,7 @@
<b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1">
<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>
</DInput>
</label>
+2 -2
View File
@@ -279,7 +279,7 @@ namespace decePubClient.Services
var account = await response.MastodonReadFromJsonAsync<Account>(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<Account>(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 };
}
+3 -3
View File
@@ -77,7 +77,7 @@ namespace decePubClient.Services
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(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<ViewTotpSetup>(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<ViewTotpEnabled>(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 };
+8 -8
View File
@@ -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<HttpResponseMessage> SendAnonymousRequest(Func<HttpRequestMessage> 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);
+1 -1
View File
@@ -65,7 +65,7 @@ namespace decePubClient.Services
if (!current.IsSuccessStatusCode)
return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken);
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);
change(form);
+1 -1
View File
@@ -98,7 +98,7 @@ namespace decePubClient.Services
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(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 };
}
}
+2 -2
View File
@@ -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];
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -332,7 +332,7 @@ namespace decePubClient.Services
var status = await response.MastodonReadFromJsonAsync<Status>(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 };
}
+2 -2
View File
@@ -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
/// <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>
public async Task Stop()
public async Task Stop(CancellationToken cancellationToken = default)
{
current = null;
if (running is not null)
+1 -1
View File
@@ -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);
}
}
+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>