House rules: numbers through one formatter, no made-up server errors, and the rules fail the build
Build / Build (push) Successful in 33s
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:
1 parent
697b2c8897
commit
3827d903a3
37 files changed
+165
-111
No files matched your search
@@ -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).
|
||||||
|
|||||||
@@ -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 : "")" />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; } = "×";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user