Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c88980374 | ||
|
|
b8d7858acb | ||
|
|
b731d8c8c0 | ||
|
|
7d4b35f0d7 | ||
|
|
fd2f2e7c65 | ||
|
|
e4414f1ac9 | ||
|
|
5796d62406 | ||
|
|
4da6a398d7 | ||
|
|
356ea7b430 | ||
|
|
48df316256 | ||
|
|
dc2190e271 | ||
|
|
70cb389537 | ||
|
|
9d662f702b | ||
|
|
e94e32d198 | ||
|
|
e88f4afc45 | ||
|
|
eade4e399a | ||
|
|
a3461c3ece | ||
|
|
ed3bf57e5b | ||
|
|
b66d7cf545 | ||
|
|
47ff60f57b | ||
|
|
18331531b8 | ||
|
|
f1ac5eaad2 | ||
|
|
5522fe4fbb | ||
|
|
7b1bfa71c0 | ||
|
|
da8f4ffcab | ||
|
|
89cef25def | ||
|
|
765220059d | ||
|
|
dd38002682 | ||
|
|
1c9773bab0 | ||
|
|
931b7fcff1 | ||
|
|
8f15d0d5c1 | ||
|
|
071bcce4b0 | ||
|
|
b3bb2a28c3 | ||
|
|
d2ab5e7b40 | ||
|
|
0b237b1951 | ||
|
|
ffd4acd3d0 | ||
|
|
63f2517f5b | ||
|
|
f1cb90ac03 | ||
|
|
6731077191 | ||
|
|
87f3f2402a | ||
|
|
38336f9869 | ||
|
|
63d83810d7 | ||
|
|
88e55e671c | ||
|
|
b7ff917d8c | ||
|
|
24748168c0 | ||
|
|
511edf3591 | ||
|
|
658683f816 | ||
|
|
a0f07abf8c | ||
|
|
bb68ff8d5e | ||
|
|
03824babbc | ||
|
|
23dbc68fb4 | ||
|
|
5a7e1804a9 | ||
|
|
2f29872745 | ||
|
|
da98addf76 | ||
|
|
a5178a67b1 | ||
|
|
f9de55ffd5 | ||
|
|
228708c328 | ||
|
|
0bbab720a5 | ||
|
|
2024798732 | ||
|
|
5e8fdc43af | ||
|
|
6f8472bc9f | ||
|
|
61631bb0a7 | ||
|
|
d7d836ba39 | ||
|
|
03f4f2ad53 | ||
|
|
5c11727f58 | ||
|
|
a4d9831691 | ||
|
|
963565b316 | ||
|
|
6d5cf17723 | ||
|
|
9a752d9288 | ||
|
|
e2491de16e | ||
|
|
011bafb6af | ||
|
|
0f68e74e38 | ||
|
|
69d0a641fe |
No files matched your search
@@ -55,6 +55,9 @@ jobs:
|
|||||||
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
||||||
done
|
done
|
||||||
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
|
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
|
||||||
|
for f in appsettings.Development.json appsettings.Pasture.json; do
|
||||||
|
[ ! -e "$W/$f" ] || { echo "::error::publish output holds $f, which points the app at a test server"; exit 1; }
|
||||||
|
done
|
||||||
echo "publish OK, $(du -sh "$W" | cut -f1)"
|
echo "publish OK, $(du -sh "$W" | cut -f1)"
|
||||||
|
|
||||||
- name: Snapshot the live directory
|
- name: Snapshot the live directory
|
||||||
|
|||||||
@@ -367,3 +367,4 @@ FodyWeavers.xsd
|
|||||||
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
|
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
|
||||||
.tools/
|
.tools/
|
||||||
wwwroot/css/app.css
|
wwwroot/css/app.css
|
||||||
|
|
||||||
@@ -14,8 +14,6 @@ columns: navigation, the feed with a tab per kind of feed, and a 3D globe that f
|
|||||||
in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970`
|
in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970`
|
||||||
(`wwwroot/appsettings.Development.json`, never published).
|
(`wwwroot/appsettings.Development.json`, never published).
|
||||||
|
|
||||||
`Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API.
|
|
||||||
|
|
||||||
## Sibling repo dependency
|
## Sibling repo dependency
|
||||||
|
|
||||||
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
|
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
|
||||||
@@ -43,7 +41,23 @@ dotnet run # dev server: https://loc
|
|||||||
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
|
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
|
||||||
```
|
```
|
||||||
|
|
||||||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There are no tests and no linter.
|
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
|
||||||
|
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
|
||||||
|
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
|
||||||
|
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
|
||||||
|
ever ships.
|
||||||
|
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
|
||||||
|
127.0.0.1:6443"`), and must accept the pasture's CA.
|
||||||
|
|
||||||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||||||
|
|
||||||
@@ -67,7 +81,7 @@ without network access. NuGet restore still uses nuget.org.
|
|||||||
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
|
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
|
||||||
The deploy workflow checks for `css/app.css.gz`.
|
The deploy workflow checks for `css/app.css.gz`.
|
||||||
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
|
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
|
||||||
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/`, `wwwroot/vendor/`, `Docs/`), and only
|
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/` and `wwwroot/vendor/`), and only
|
||||||
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
|
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
|
||||||
every variant as a literal, e.g. in a switch arm.
|
every variant as a literal, e.g. in a switch arm.
|
||||||
|
|
||||||
@@ -87,6 +101,14 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
- picking a hue leaves grey.
|
- picking a hue leaves grey.
|
||||||
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
||||||
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
||||||
|
The other sections of Settings belong to the login or the persona (see **Settings** under PrivaPub wiring).
|
||||||
|
- **The theme is the login's** while `PageSettings.SyncWithLogin` is on (the default; Settings → General turns it off
|
||||||
|
for this device). `CascadingState` takes the login's theme (`ViewUserSettings`; never its language, which would reload
|
||||||
|
the app) at sign-in from the `JwtUser` (`SignedIn`, which Login, Setup and Join call instead of `LoadSession`) and at
|
||||||
|
start-up from `/clientapi/user/settings`, once `savedAt` says a client saved one; while none did, it gives the login
|
||||||
|
this device's. `UpdatePageSettings` saves them to `/clientapi/user/update/settings` 1.5 s after the last change, one
|
||||||
|
save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them
|
||||||
|
back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown.
|
||||||
- `Styles/neo.css` holds the neumorphic layer:
|
- `Styles/neo.css` holds the neumorphic layer:
|
||||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||||
old `neomorph is-nxsmall` classes);
|
old `neomorph is-nxsmall` classes);
|
||||||
@@ -95,9 +117,63 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||||
shadow with `shadow-none!`.
|
shadow with `shadow-none!`.
|
||||||
|
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
|
||||||
|
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
|
||||||
|
file input's own button (Choose File, Browse…) is raised like the app's buttons.
|
||||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||||
every collapse disappears.
|
every collapse disappears.
|
||||||
|
|
||||||
|
## Phones
|
||||||
|
|
||||||
|
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
|
||||||
|
- **Width and pointer are separate.**
|
||||||
|
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||||
|
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||||
|
`pointer-coarse:` at a call site.
|
||||||
|
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
|
||||||
|
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||||
|
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||||
|
daisyUI's own size variables:
|
||||||
|
- buttons, tabs, inputs, selects and file inputs are 44px tall;
|
||||||
|
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
|
||||||
|
the padding box);
|
||||||
|
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
||||||
|
- field text is 16px, so iOS doesn't zoom into a focused field.
|
||||||
|
|
||||||
|
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
||||||
|
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
||||||
|
its arrow with `::after` (NerdTip takes minimum sizes).
|
||||||
|
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
|
||||||
|
- **Overlays on a phone rise from the bottom.**
|
||||||
|
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
||||||
|
close button (`ShowClose`, named by `CloseText`).
|
||||||
|
- `DDropdown SheetOnNarrow` turns its content into a bottom sheet.
|
||||||
|
- `Components/PhoneSheet.razor` shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a
|
||||||
|
sheet on a phone.
|
||||||
|
- All are capped by `dvh`, and the app itself is `h-dvh`.
|
||||||
|
- **The Back button closes the newest open overlay** instead of leaving the page.
|
||||||
|
- Overlays register through `IDOverlayHost` (`Components/Daisy`, cascaded from `MainLayout`):
|
||||||
|
- `DModal` and `DDropdown` through the cascade;
|
||||||
|
- the nav menu, `PhoneSheet` and the full-screen globe through `Services/OverlayService.cs`.
|
||||||
|
- `main.js` marks a real Back press (a capture-phase `popstate`), since Blazor can't tell it from the app's own
|
||||||
|
navigation, which closes every overlay and goes on.
|
||||||
|
- No history entries are pushed.
|
||||||
|
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
||||||
|
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
|
||||||
|
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
|
||||||
|
server's thumbnail.
|
||||||
|
- **On a touch screen:**
|
||||||
|
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
||||||
|
pinned to the globe with a mouse only;
|
||||||
|
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||||
|
- pictures open in `Components/MediaViewer.razor`.
|
||||||
|
- **The feed on a phone:**
|
||||||
|
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
|
||||||
|
the nerd stats' Details are in More, and the author's picture is in the header;
|
||||||
|
- a new post starts from a floating button;
|
||||||
|
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
||||||
|
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||||
|
|
||||||
## daisyUI Blazor components (`Components/Daisy/`)
|
## daisyUI Blazor components (`Components/Daisy/`)
|
||||||
|
|
||||||
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
||||||
@@ -144,7 +220,7 @@ supplies:
|
|||||||
- `AfterRenderAsyncJobs`, run once after the next render;
|
- `AfterRenderAsyncJobs`, run once after the next render;
|
||||||
- `WhenOnline` / `StartOnlinePolling`.
|
- `WhenOnline` / `StartOnlinePolling`.
|
||||||
|
|
||||||
`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component.
|
Never redeclare the base's members in a component.
|
||||||
|
|
||||||
**UI feedback.**
|
**UI feedback.**
|
||||||
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
|
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
|
||||||
@@ -153,6 +229,7 @@ supplies:
|
|||||||
messages.
|
messages.
|
||||||
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
||||||
`AdditionalError` and `AdditionalErrors`.
|
`AdditionalError` and `AdditionalErrors`.
|
||||||
|
- `ConfirmService` asks before what can't be undone (see **Phones**).
|
||||||
|
|
||||||
**HTTP (`Services/IHttpService.cs`).**
|
**HTTP (`Services/IHttpService.cs`).**
|
||||||
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
||||||
@@ -162,10 +239,12 @@ supplies:
|
|||||||
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
||||||
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
||||||
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
||||||
- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` /
|
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||||
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub
|
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
||||||
redirects it).
|
not carry a JWT: PrivaPub redirects them).
|
||||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||||
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
|
`HttpClient`'s own is off.
|
||||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||||
@@ -179,12 +258,254 @@ supplies:
|
|||||||
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
|
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
|
||||||
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
||||||
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
||||||
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
|
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
|
||||||
|
forgets the session.
|
||||||
|
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
|
||||||
|
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
|
||||||
|
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
|
||||||
|
persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub
|
||||||
|
answers with (`ContinueSession`), drop the stored persona tokens and exchange one for the current persona at once
|
||||||
|
(the others when next used), so the device stays signed in.
|
||||||
|
- **Two-factor sign-in** (TOTP, `/clientapi/user/totp/*`): with it on, a right password is answered with a 428
|
||||||
|
(`AuthService.NeedsOneTimeCode`). Login keeps the name and the password in the form, out of sight, and sends it again
|
||||||
|
with `LoginForm.OneTimeCode`: the authenticator app's six digits (`DOtp`, `data-login-code`) or, after "Use a recovery
|
||||||
|
code instead", a recovery code like `abcde-fghjk` (a text field, `data-login-recovery`). A wrong code is a 400, like a
|
||||||
|
wrong password, with PrivaPub's message. It is turned on and off in Settings (`TotpEditor`, see **Settings**).
|
||||||
|
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
|
||||||
|
- **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
|
||||||
|
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
|
||||||
|
closes for good. Login offers it while it waits.
|
||||||
|
- **Invitations** (`Pages/Join.razor` at `/join?code=`): a group's invitation makes a login whose first persona joins
|
||||||
|
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
|
||||||
|
- Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
|
||||||
|
persona is sent from Login to Settings to make one.
|
||||||
|
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
|
||||||
|
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
|
||||||
|
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
|
||||||
|
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
|
||||||
|
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
|
||||||
|
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
|
||||||
|
All of it goes with no credentials.
|
||||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||||
threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads),
|
threads, mute, block, poll votes, downvotes, who may quote the persona's posts and revoking a quote of one, emoji
|
||||||
`GroupService`, `InstanceService` and `ProvenanceService`.
|
reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||||||
|
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||||||
|
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
||||||
|
block, mute, follow requests, the persona's followers and following and removing a follower, `/api/v2/search`, whose
|
||||||
|
handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
|
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
||||||
|
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
|
||||||
|
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
|
||||||
|
(`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
|
||||||
|
`InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
|
||||||
|
`InstanceAdminController`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
|
||||||
|
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
|
||||||
|
reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
|
||||||
|
`ProvenanceService`.
|
||||||
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
||||||
|
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||||
|
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
|
||||||
|
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
|
||||||
|
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
|
||||||
|
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
|
||||||
|
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
||||||
|
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
|
||||||
|
post's downvotes next to its favourites with the persona's own as a toggle (`data-downvote`, in More on a phone as
|
||||||
|
`data-downvote-phone`; `StatusAction.Downvote`/`Undownvote`, PrivaPub's `/statuses/:id/downvote|undownvote`, answered
|
||||||
|
with the post, its `privapub.votes.downvoted` set and its favourite dropped). PrivaPub sends votes on every post, so
|
||||||
|
the card offers the toggle where they mean something (`Content.CanDownvote`: a threadiverse server's post, a Page, or
|
||||||
|
one with downvotes; never the persona's own). It also shows blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
||||||
|
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
||||||
|
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
|
||||||
|
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
|
||||||
|
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
|
||||||
|
- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from
|
||||||
|
Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long
|
||||||
|
description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules
|
||||||
|
(`data-about-rules`, `data-about-rule`), privacy policy (`data-about-privacy`), terms of service (`data-about-terms`,
|
||||||
|
none while PrivaPub answers 404) and the domain blocks the administrator published (`data-about-blocks`,
|
||||||
|
`data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized
|
||||||
|
HTML in `post-content doc-content` (`Styles/feed.css` spaces their headings). Login's heading names the server
|
||||||
|
(`data-login-title`, `/api/v2/instance` read without credentials and kept for the session).
|
||||||
|
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
||||||
|
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
||||||
|
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
||||||
|
persona pins and unpins its own public and unlisted posts from the card's menu.
|
||||||
|
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
|
||||||
|
linking the new account's profile.
|
||||||
|
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
||||||
|
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
|
||||||
|
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
|
||||||
|
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
|
||||||
|
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
|
||||||
|
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
|
||||||
|
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
|
||||||
|
lists them to the persona alone (`hide_collections`), so another profile keeps plain counts. A follower is removed
|
||||||
|
there (`remove_from_followers`, `data-follower-remove`, asked first). The follow requests waiting for the persona show
|
||||||
|
above its posts (`Components/FollowRequestsPanel.razor`, `data-follow-request`, accepted or refused; hidden while none
|
||||||
|
waits), and the Notifications tab opens the same panel in a sheet while some wait (`data-follow-requests-open`).
|
||||||
|
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
|
||||||
|
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
|
||||||
|
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
||||||
|
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
|
||||||
|
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
|
||||||
|
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
|
||||||
|
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out.
|
||||||
|
Only a group's owner and moderators see who is in it.
|
||||||
|
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
|
||||||
|
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
|
||||||
|
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
|
||||||
|
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
|
||||||
|
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
|
||||||
|
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
|
||||||
|
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
|
||||||
|
its media (blur). Another's post goes into a filter from its card's menu ("Filter this post", `data-filter-post`,
|
||||||
|
`Components/FilterPostDialog.razor`: `POST /api/v2/filters/:id/statuses`, a filter that holds it already says so), and
|
||||||
|
the card reads the post again so the filter applies at once. On /filters the posts a filter holds are read by id when
|
||||||
|
it opens (`TimelineService.Read`, `/api/v1/statuses?id[]=`) and show their author and words, linking their thread
|
||||||
|
(`data-filter-status`), or say "Post no longer available" (`data-filter-status-missing`).
|
||||||
|
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
|
||||||
|
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
||||||
|
post opened in its thread shows whole.
|
||||||
|
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
||||||
|
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
|
||||||
|
a poll instead of media.
|
||||||
|
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
|
||||||
|
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
|
||||||
|
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
|
||||||
|
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||||
|
- the ids are kept, so a post that fails never uploads its media again;
|
||||||
|
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||||
|
- a description changed after the upload is PUT before the post;
|
||||||
|
- a file taken out before posting is deleted from PrivaPub (`MediaService.Discard`, `DELETE /api/v1/media/:id`,
|
||||||
|
`data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
|
||||||
|
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
||||||
|
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
||||||
|
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||||
|
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
|
||||||
|
(`media_attributes`). Its visibility and poll stay.
|
||||||
|
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
|
||||||
|
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
||||||
|
(`/api/v2/search`, never resolving), once typing pauses.
|
||||||
|
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
||||||
|
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
|
||||||
|
- **Threads** (`Pages/Thread.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
||||||
|
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
|
||||||
|
and adds what was found, in the thread's order.
|
||||||
|
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
|
||||||
|
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
|
||||||
|
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
|
||||||
|
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
|
||||||
|
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
|
||||||
|
`data-quote-policy-choice`: anyone, its followers or nobody, `PUT /api/v1/statuses/:id/interaction_policy` with
|
||||||
|
`quote_approval_policy`; the policy is read back from `quote_approval.automatic`, `StatusService.QuotePolicyOf`). A
|
||||||
|
post by someone else quoting one of them has "Revoke quote" in its menu (`data-revoke-quote`, asked first; `POST
|
||||||
|
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
|
||||||
|
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
|
||||||
|
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
|
||||||
|
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
|
||||||
|
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
|
||||||
|
such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
|
||||||
|
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
|
||||||
|
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
|
||||||
|
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
|
||||||
|
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
|
||||||
|
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
|
||||||
|
everywhere", see **One sign-in**); or the whole of it
|
||||||
|
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
|
||||||
|
signs out), the persona's profile (`ProfileEditor`:
|
||||||
|
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
|
||||||
|
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||||
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||||
|
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
|
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
|
||||||
|
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
|
||||||
|
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
|
||||||
|
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
|
||||||
|
with progress, a report and Stop. Imported posts are delivered to no one.
|
||||||
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked
|
||||||
|
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
|
||||||
|
log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
|
||||||
|
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
|
||||||
|
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators:
|
||||||
|
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until
|
||||||
|
then they have no section.
|
||||||
|
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
|
||||||
|
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
|
||||||
|
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
|
||||||
|
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
|
||||||
|
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
|
||||||
|
`AboutService` read the instance again.
|
||||||
|
- **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description
|
||||||
|
(`data-instance-short-description`), Markdown description (`data-instance-description`), contact address
|
||||||
|
(`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`;
|
||||||
|
picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
|
||||||
|
`data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy
|
||||||
|
policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the
|
||||||
|
weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the
|
||||||
|
configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The
|
||||||
|
thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`,
|
||||||
|
asked first) is saved as soon as it is picked, streamed from the file as multipart `file`.
|
||||||
|
- **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its
|
||||||
|
`data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`,
|
||||||
|
`data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
|
||||||
|
(`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id.
|
||||||
|
- **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`,
|
||||||
|
`data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions;
|
||||||
|
`data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`,
|
||||||
|
`-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`);
|
||||||
|
`data-announcement-delete` asks first.
|
||||||
|
- **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and
|
||||||
|
`/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars
|
||||||
|
part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a
|
||||||
|
block, since blocking a server again changes its block. `data-server-block-save`.
|
||||||
|
- **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source`
|
||||||
|
configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from
|
||||||
|
`Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers
|
||||||
|
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
|
||||||
|
- **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows,
|
||||||
|
inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the
|
||||||
|
configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`,
|
||||||
|
`data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
|
||||||
|
- **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time
|
||||||
|
(`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who
|
||||||
|
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
|
||||||
|
(`data-log-note`). `data-log-reload`.
|
||||||
|
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
|
||||||
|
texts, a removed rule's too.
|
||||||
|
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
|
||||||
|
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
|
||||||
|
ledger's events and the server's own days. Counted per server, never per account.
|
||||||
|
- A server's name opens `HostDetailDialog` (`data-statistics-host-open`, `/hosts/{host}?days=`): what it says of itself
|
||||||
|
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
|
||||||
|
ledger's `channel:…:…:outcome` keys like the servers table; `live` for a day not rolled up yet), its last events, and
|
||||||
|
"Describe again" (`data-host-describe`, `POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
|
||||||
|
- Events (`data-statistics-events`, `EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
|
||||||
|
time, and "Load more" asks for those `before` the last one's time.
|
||||||
|
- The server's days (`data-statistics-server`, `/server?days=`): its client API's requests by status class, the
|
||||||
|
documents it served, and its ledger written, dropped and failed.
|
||||||
|
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
|
||||||
|
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
|
||||||
|
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
||||||
|
to disk.
|
||||||
|
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
|
||||||
|
has, and carries on from what PrivaPub says after a refused or lost piece.
|
||||||
|
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
|
||||||
|
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
|
||||||
|
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
|
||||||
|
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
||||||
|
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
|
||||||
|
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
|
||||||
|
links there while requests wait. A notification is dismissed from its line (`data-notification-dismiss`,
|
||||||
|
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
|
||||||
|
one alone), and the page takes that item out (`Content.OnDismissed`).
|
||||||
|
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||||
|
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||||
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
|
it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too.
|
||||||
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
@@ -195,35 +516,85 @@ supplies:
|
|||||||
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
|
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
|
||||||
since the API may be reached under another name.
|
since the API may be reached under another name.
|
||||||
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
||||||
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
`GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll,
|
||||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
|
||||||
|
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
|
||||||
|
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
|
||||||
|
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
|
||||||
|
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
|
||||||
|
- **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones
|
||||||
|
shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`,
|
||||||
|
`-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to
|
||||||
|
(`data-announcement-reaction="{emoji}"` toggles the persona's own, `data-announcement-react` offers more,
|
||||||
|
`data-announcement-react-emoji`; `PUT`/`DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They
|
||||||
|
come live on the user stream (`announcement`, `announcement.reaction`, `announcement.delete`):
|
||||||
|
`StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it
|
||||||
|
alone on a polled feed), and raises what it tells as `Announced`; the user stream's posts and notifications are
|
||||||
|
the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the
|
||||||
|
feed's cards.
|
||||||
|
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
|
||||||
|
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
|
||||||
|
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
|
||||||
|
card shows from outside it must replace one of those objects or be added to the key; mutating one in place leaves the
|
||||||
|
card as it was. Without these a feed took seven minutes to reach a thousand cards and held 900 MB at two thousand.
|
||||||
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
||||||
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. CDN-fronted
|
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||||
posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server
|
time; `wake()` before anything that animates).
|
||||||
was before the CDN. The posts'
|
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||||
places reach it in one call per feed change; cards are matched by `data-post-id`.
|
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
|
||||||
|
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
|
||||||
|
- **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's
|
||||||
|
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||||
|
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||||
|
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||||
|
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
|
||||||
|
top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
|
||||||
|
the points.
|
||||||
|
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
|
||||||
|
a table). The globe has no tooltip. What it draws:
|
||||||
|
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
|
||||||
|
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
|
||||||
|
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
|
||||||
|
- the tropics, the polar circles and the CDN ring;
|
||||||
|
- the view's centre and spread, and rings around the posts in view;
|
||||||
|
- a small orrery in the top left corner, re-anchored to the camera before each render.
|
||||||
|
|
||||||
|
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
|
||||||
|
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
|
||||||
|
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
|
||||||
|
already made, so an idle globe still draws nothing.
|
||||||
|
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
|
||||||
|
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
|
||||||
|
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
|
||||||
|
classes, only the ring and the legend remain.
|
||||||
|
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
|
||||||
|
gets everything.
|
||||||
|
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||||
|
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||||
|
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||||
|
Earth). The label renderer owns `display`, so labels hide through `visibility`.
|
||||||
|
- CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring
|
||||||
|
where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by
|
||||||
|
`data-post-id`.
|
||||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera,
|
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||||
points and frame rate.
|
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
|
||||||
|
|
||||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||||
`ErrorsResource`.
|
`ErrorsResource`.
|
||||||
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
|
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
|
||||||
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
|
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`, and by the
|
||||||
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
|
D fields' parse errors (`DInputBase`, `DCalendar`) through the static `ErrorMessages.InvalidValue` / `InvalidDate`. A new
|
||||||
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||||
|
|
||||||
**Client storage** comes in two layers:
|
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`
|
||||||
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
|
and the server cache `ServerInfos`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
||||||
server cache `ServerInfos`, keyed by `nameof(Type)`.
|
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
|
||||||
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
|
still holds it.
|
||||||
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
|
|
||||||
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
|
|
||||||
the mock feed's `Message` store out).
|
|
||||||
|
|
||||||
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
|
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
|
||||||
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
|
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
|
||||||
@@ -233,10 +604,12 @@ it: the root's name, its policies as claims, and the current persona. Pages that
|
|||||||
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
|
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
|
||||||
synchronous `IJSInProcessRuntime`.
|
synchronous `IJSInProcessRuntime`.
|
||||||
- Each helper reports its errors back through `window.logFromJs`.
|
- Each helper reports its errors back through `window.logFromJs`.
|
||||||
- The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `<script>` tags in `wwwroot/index.html`; the feed's
|
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
|
||||||
|
`wwwroot/index.html`; the feed's
|
||||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
||||||
|
|
||||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
|
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
|
||||||
|
published worker names its cache after the assets manifest's version, so each publish drops the previous cache.
|
||||||
|
|
||||||
## Development rules
|
## Development rules
|
||||||
|
|
||||||
|
|||||||
+63
-2
@@ -3,6 +3,7 @@
|
|||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
using PrivaPub.ClientModels.Group;
|
using PrivaPub.ClientModels.Group;
|
||||||
using PrivaPub.ClientModels.Post;
|
using PrivaPub.ClientModels.Post;
|
||||||
using PrivaPub.ClientModels.User;
|
using PrivaPub.ClientModels.User;
|
||||||
@@ -22,22 +23,82 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(PageSettings))]
|
[JsonSerializable(typeof(PageSettings))]
|
||||||
[JsonSerializable(typeof(AuthData))]
|
[JsonSerializable(typeof(AuthData))]
|
||||||
[JsonSerializable(typeof(FeedFilter))]
|
[JsonSerializable(typeof(FeedFilter))]
|
||||||
|
[JsonSerializable(typeof(PublicActionNotices))]
|
||||||
[JsonSerializable(typeof(ServerInfo))]
|
[JsonSerializable(typeof(ServerInfo))]
|
||||||
[JsonSerializable(typeof(List<ServerInfo>))]
|
[JsonSerializable(typeof(List<ServerInfo>))]
|
||||||
[JsonSerializable(typeof(UploadMedia))]
|
[JsonSerializable(typeof(UploadMedia))]
|
||||||
[JsonSerializable(typeof(ClientLogs))]
|
|
||||||
[JsonSerializable(typeof(ClientLogException))]
|
|
||||||
[JsonSerializable(typeof(ValidationErrors))]
|
[JsonSerializable(typeof(ValidationErrors))]
|
||||||
[JsonSerializable(typeof(WebResult))]
|
[JsonSerializable(typeof(WebResult))]
|
||||||
[JsonSerializable(typeof(LoginForm))]
|
[JsonSerializable(typeof(LoginForm))]
|
||||||
|
[JsonSerializable(typeof(SetupForm))]
|
||||||
|
[JsonSerializable(typeof(InvitationLoginForm))]
|
||||||
|
[JsonSerializable(typeof(ViewServerSetup))]
|
||||||
|
[JsonSerializable(typeof(UserPasswordForm))]
|
||||||
|
[JsonSerializable(typeof(RemoveSelfForm))]
|
||||||
|
[JsonSerializable(typeof(PasswordRecoveryForm))]
|
||||||
|
[JsonSerializable(typeof(NewPasswordForm))]
|
||||||
|
[JsonSerializable(typeof(ViewAccount))]
|
||||||
|
[JsonSerializable(typeof(EmailChangeForm))]
|
||||||
|
[JsonSerializable(typeof(EmailCodeForm))]
|
||||||
|
[JsonSerializable(typeof(PasswordForm))]
|
||||||
|
[JsonSerializable(typeof(TotpCodeForm))]
|
||||||
|
[JsonSerializable(typeof(TotpDisableForm))]
|
||||||
|
[JsonSerializable(typeof(ViewTotpSetup))]
|
||||||
|
[JsonSerializable(typeof(ViewTotpEnabled))]
|
||||||
|
[JsonSerializable(typeof(ViewUserSettings))]
|
||||||
|
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
|
||||||
|
[JsonSerializable(typeof(string))]
|
||||||
|
[JsonSerializable(typeof(bool))]
|
||||||
[JsonSerializable(typeof(JwtUser))]
|
[JsonSerializable(typeof(JwtUser))]
|
||||||
[JsonSerializable(typeof(ViewAvatar))]
|
[JsonSerializable(typeof(ViewAvatar))]
|
||||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||||
|
[JsonSerializable(typeof(InsertAvatarForm))]
|
||||||
[JsonSerializable(typeof(ViewGroup))]
|
[JsonSerializable(typeof(ViewGroup))]
|
||||||
[JsonSerializable(typeof(List<ViewGroup>))]
|
[JsonSerializable(typeof(List<ViewGroup>))]
|
||||||
|
[JsonSerializable(typeof(ViewGroupMember))]
|
||||||
|
[JsonSerializable(typeof(List<ViewGroupMember>))]
|
||||||
|
[JsonSerializable(typeof(InsertGroupForm))]
|
||||||
|
[JsonSerializable(typeof(UpdateGroupForm))]
|
||||||
|
[JsonSerializable(typeof(JoinGroupForm))]
|
||||||
|
[JsonSerializable(typeof(GroupMembershipForm))]
|
||||||
[JsonSerializable(typeof(ViewPost))]
|
[JsonSerializable(typeof(ViewPost))]
|
||||||
[JsonSerializable(typeof(List<ViewPost>))]
|
[JsonSerializable(typeof(List<ViewPost>))]
|
||||||
[JsonSerializable(typeof(InsertPostForm))]
|
[JsonSerializable(typeof(InsertPostForm))]
|
||||||
|
[JsonSerializable(typeof(DeletePostForm))]
|
||||||
|
[JsonSerializable(typeof(ViewReport))]
|
||||||
|
[JsonSerializable(typeof(List<ViewReport>))]
|
||||||
|
[JsonSerializable(typeof(DomainBlockForm))]
|
||||||
|
[JsonSerializable(typeof(ViewDomainBlock))]
|
||||||
|
[JsonSerializable(typeof(List<ViewDomainBlock>))]
|
||||||
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
|
[JsonSerializable(typeof(ViewCrawler))]
|
||||||
|
[JsonSerializable(typeof(ViewHostDetail))]
|
||||||
|
[JsonSerializable(typeof(ViewHostInstance))]
|
||||||
|
[JsonSerializable(typeof(ViewHostDay))]
|
||||||
|
[JsonSerializable(typeof(ViewInteractionEvent))]
|
||||||
|
[JsonSerializable(typeof(List<ViewInteractionEvent>))]
|
||||||
|
[JsonSerializable(typeof(ViewServerDay))]
|
||||||
|
[JsonSerializable(typeof(List<ViewServerDay>))]
|
||||||
|
[JsonSerializable(typeof(ViewBackups))]
|
||||||
|
[JsonSerializable(typeof(ViewBackup))]
|
||||||
|
[JsonSerializable(typeof(BackupPasswordForm))]
|
||||||
|
[JsonSerializable(typeof(RestoreBackupForm))]
|
||||||
|
[JsonSerializable(typeof(ViewBackupTicket))]
|
||||||
|
[JsonSerializable(typeof(ViewBackupUpload))]
|
||||||
|
[JsonSerializable(typeof(ViewPersonaArchive))]
|
||||||
|
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
|
||||||
|
[JsonSerializable(typeof(ViewArchiveTicket))]
|
||||||
|
[JsonSerializable(typeof(ViewArchiveUpload))]
|
||||||
|
[JsonSerializable(typeof(ViewServerSettings))]
|
||||||
|
[JsonSerializable(typeof(ServerSettingsForm))]
|
||||||
|
[JsonSerializable(typeof(ViewAnnouncement))]
|
||||||
|
[JsonSerializable(typeof(List<ViewAnnouncement>))]
|
||||||
|
[JsonSerializable(typeof(AnnouncementForm))]
|
||||||
|
[JsonSerializable(typeof(ViewRelay))]
|
||||||
|
[JsonSerializable(typeof(List<ViewRelay>))]
|
||||||
|
[JsonSerializable(typeof(RelayForm))]
|
||||||
|
[JsonSerializable(typeof(ViewAdminActionsPage))]
|
||||||
public partial class ClientJsonContext : JsonSerializerContext
|
public partial class ClientJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's own followers or the accounts it follows (/api/v1/accounts/:id/followers, /following), 40 a page by the
|
||||||
|
Link header, each opening its profile. PrivaPub lists them to the persona alone (hide_collections), so only the
|
||||||
|
persona's own profile opens this. A follower can be removed (remove_from_followers), asked first. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||||
|
data-account-list="@(Followers ? "followers" : "following")">
|
||||||
|
<Heading><span class="capitalize">@(Followers ? Localizer["followers"] : Localizer["following"])</span></Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (accounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3" data-account-list-empty>@Localizer["Nobody yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-account="@account.Acct">
|
||||||
|
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if (Followers)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => RemoveFollower(account)"
|
||||||
|
data-follower-remove>
|
||||||
|
<i class="ion-md-person text-error" aria-hidden="true"></i><span>@Localizer["Remove from followers"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (maxId is not null)
|
||||||
|
{
|
||||||
|
<div class="flex justify-center mt-3">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-account-list-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The persona's own account id.</summary>
|
||||||
|
[Parameter, EditorRequired] public string AccountId { get; set; }
|
||||||
|
/// <summary>Its followers, rather than the accounts it follows.</summary>
|
||||||
|
[Parameter] public bool Followers { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>A follower was removed.</summary>
|
||||||
|
[Parameter] public EventCallback<Account> OnRemoved { get; set; }
|
||||||
|
|
||||||
|
List<Account> accounts = [];
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string maxId;
|
||||||
|
bool isLoadingMore;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
accounts = [];
|
||||||
|
maxId = null;
|
||||||
|
await LoadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadPage()
|
||||||
|
{
|
||||||
|
var result = Followers ? await AccountService.Followers(AccountId, maxId) : await AccountService.Following(AccountId, maxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
var known = accounts.Select(a => a.Id).ToHashSet();
|
||||||
|
accounts.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||||
|
maxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMore()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
await LoadPage();
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveFollower(Account account)
|
||||||
|
{
|
||||||
|
var name = account.Name ?? account.Acct;
|
||||||
|
if (!await Confirm.Ask(Localizer["Remove {0} from your followers?", name], Localizer["They no longer see your followers-only posts, and may ask to follow you again."],
|
||||||
|
Localizer["Remove from followers"], true))
|
||||||
|
return;
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.RemoveFromFollowers(account.Id);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.Remove(account);
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} removed from your followers.", name]);
|
||||||
|
await OnRemoved.InvokeAsync(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
|
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
|
||||||
|
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
|
||||||
|
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
|
||||||
|
with it, and every persona's stream hears it go. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-announcements>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
|
||||||
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (announcements.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var announcement in announcements)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@switch (StateOf(announcement))
|
||||||
|
{
|
||||||
|
case "shown":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
|
||||||
|
break;
|
||||||
|
case "ended":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@if (announcement.AllDay)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||||
|
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
|
||||||
|
</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
|
||||||
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
|
||||||
|
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@When(announcement)</p>
|
||||||
|
}
|
||||||
|
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
|
||||||
|
@foreach (var reaction in announcement.Reactions)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
|
||||||
|
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
|
||||||
|
</DBadge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editing is not null)
|
||||||
|
{
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||||
|
data-announcement-dialog>
|
||||||
|
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
|
||||||
|
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
|
||||||
|
data-announcement-text />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["From"]</span>
|
||||||
|
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Until"]</span>
|
||||||
|
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
|
||||||
|
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
|
||||||
|
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
|
||||||
|
@if (isSaving)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const string LocalInput = "yyyy-MM-ddTHH:mm";
|
||||||
|
|
||||||
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
List<ViewAnnouncement> announcements = [];
|
||||||
|
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
|
||||||
|
AnnouncementForm editing;
|
||||||
|
string startsAt;
|
||||||
|
string endsAt;
|
||||||
|
string error;
|
||||||
|
bool isSaving;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
|
||||||
|
|
||||||
|
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
|
||||||
|
|
||||||
|
string When(ViewAnnouncement announcement)
|
||||||
|
{
|
||||||
|
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
|
||||||
|
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
|
||||||
|
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
|
||||||
|
return (starts, ends) switch
|
||||||
|
{
|
||||||
|
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
|
||||||
|
({ } from, null) => Localizer["From {0}", from],
|
||||||
|
_ => Localizer["until {0}", ends]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Closed(bool open)
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void New()
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
editing = new AnnouncementForm();
|
||||||
|
startsAt = null;
|
||||||
|
endsAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Edit(ViewAnnouncement announcement)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
editing = new AnnouncementForm
|
||||||
|
{
|
||||||
|
Id = announcement.Id,
|
||||||
|
Text = announcement.Text,
|
||||||
|
StartsAt = announcement.StartsAt,
|
||||||
|
EndsAt = announcement.EndsAt,
|
||||||
|
AllDay = announcement.AllDay,
|
||||||
|
IsPublished = announcement.IsPublished
|
||||||
|
};
|
||||||
|
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||||||
|
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
|
||||||
|
static DateTime? Utc(string local) =>
|
||||||
|
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
editing.StartsAt = Utc(startsAt);
|
||||||
|
editing.EndsAt = Utc(endsAt);
|
||||||
|
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
|
||||||
|
{
|
||||||
|
error = Localizer["An announcement can't end before it starts."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.SaveAnnouncement(editing);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
error = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = (ViewAnnouncement)result.Data;
|
||||||
|
var at = announcements.FindIndex(a => a.Id == saved.Id);
|
||||||
|
if (at < 0)
|
||||||
|
announcements.Insert(0, saved);
|
||||||
|
else
|
||||||
|
announcements[at] = saved;
|
||||||
|
editing = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete(ViewAnnouncement announcement)
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
busy.Add(announcement.Id);
|
||||||
|
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
|
||||||
|
busy.Remove(announcement.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
announcements.Remove(announcement);
|
||||||
|
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
|
||||||
|
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
|
||||||
|
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
|
||||||
|
and everyone signs in again. A restore never undoes what protects. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-backups>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (restarting is not null)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
|
||||||
|
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
|
||||||
|
<i class="ion-md-archive"></i> @Localizer["Back up now"]
|
||||||
|
</DButton>
|
||||||
|
@if (backups.Running is { } running)
|
||||||
|
{
|
||||||
|
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (backups.Waiting is { } waiting)
|
||||||
|
{
|
||||||
|
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
|
||||||
|
<span>
|
||||||
|
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
|
||||||
|
@if (waiting.Error is { Length: > 0 } error)
|
||||||
|
{
|
||||||
|
<br /><span class="text-xs">@error</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backups.LastRestore is { } last)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
|
||||||
|
@if (last.Abandoned)
|
||||||
|
{
|
||||||
|
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
|
||||||
|
<p class="text-xs">@last.Error</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<b>@Localizer["Restored {0}", last.Backup]</b>
|
||||||
|
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
|
||||||
|
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
|
||||||
|
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
|
||||||
|
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs" data-last-restore-tombstoned>
|
||||||
|
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
|
||||||
|
data-backup-upload />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (uploading)
|
||||||
|
{
|
||||||
|
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (backups.Backups.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var backup in backups.Backups)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
|
||||||
|
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
|
||||||
|
@(backup.DbOnly
|
||||||
|
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
|
||||||
|
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
|
||||||
|
@if (backup.MediaMissing > 0)
|
||||||
|
{
|
||||||
|
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
|
||||||
|
}
|
||||||
|
@if (!backup.Consistent)
|
||||||
|
{
|
||||||
|
<span>; @Localizer["not read at one instant"]</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (backup.RootsDeletedSince > 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
|
||||||
|
OnClick="() => AskRestore(backup)" data-backup-restore>
|
||||||
|
<i class="ion-md-refresh"></i> @Localizer["Restore"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
|
||||||
|
<i class="ion-md-trash"></i> @Localizer["Delete"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
|
||||||
|
CloseText="@Localizer["Close"]" data-backup-download-dialog>
|
||||||
|
<Heading>@Localizer["Download a backup"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@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"
|
||||||
|
placeholder="@Localizer["Password"]" data-backup-password />
|
||||||
|
<ErrorViewer AdditionalError="@dialogError" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
|
||||||
|
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
|
||||||
|
@if (ticket is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
|
||||||
|
@Localizer["Get the link"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
|
||||||
|
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
|
||||||
|
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
|
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
|
||||||
|
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
|
||||||
|
@if (restoring?.RootsDeletedSince > 0)
|
||||||
|
{
|
||||||
|
<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"
|
||||||
|
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 />
|
||||||
|
<ErrorViewer AdditionalError="@dialogError" />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error"
|
||||||
|
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
|
||||||
|
OnClick="Restore" data-backup-confirm>
|
||||||
|
@Localizer["Restore"]
|
||||||
|
</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
|
[Inject] AppConfiguration Configuration { get; set; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
const string UploadInput = "input[data-backup-upload]";
|
||||||
|
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
ViewBackups backups = new();
|
||||||
|
bool busy;
|
||||||
|
string restarting;
|
||||||
|
ViewBackup downloading;
|
||||||
|
ViewBackup restoring;
|
||||||
|
string password;
|
||||||
|
string typedHost;
|
||||||
|
string ticket;
|
||||||
|
long ticketSize;
|
||||||
|
string dialogError;
|
||||||
|
string pickedName;
|
||||||
|
bool uploading;
|
||||||
|
long uploaded;
|
||||||
|
long uploadSize;
|
||||||
|
double uploadedPercent;
|
||||||
|
IJSObjectReference transfer;
|
||||||
|
DotNetObjectReference<BackupsPanel> self;
|
||||||
|
CancellationTokenSource polling;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
backups = await AdminService.Backups() ?? backups;
|
||||||
|
if (backups.Running is not null || backups.Waiting is not null)
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
|
||||||
|
void Poll()
|
||||||
|
{
|
||||||
|
if (polling is not null)
|
||||||
|
return;
|
||||||
|
polling = new();
|
||||||
|
var token = polling.Token;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(PollEvery);
|
||||||
|
while (await timer.WaitForNextTickAsync(token))
|
||||||
|
{
|
||||||
|
var read = await AdminService.Backups(token);
|
||||||
|
if (read is not null)
|
||||||
|
backups = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
polling = null;
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DColor KindColor(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"nightly" => DColor.Neutral,
|
||||||
|
"manual" => DColor.Primary,
|
||||||
|
"pre-deploy" => DColor.Info,
|
||||||
|
"pre-restore" => DColor.Warning,
|
||||||
|
"uploaded" => DColor.Accent,
|
||||||
|
_ => DColor.Neutral
|
||||||
|
};
|
||||||
|
|
||||||
|
string KindName(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"nightly" => Localizer["Nightly"],
|
||||||
|
"manual" => Localizer["Made by hand"],
|
||||||
|
"pre-deploy" => Localizer["Before a deploy"],
|
||||||
|
"pre-restore" => Localizer["Before a restore"],
|
||||||
|
"uploaded" => Localizer["Uploaded"],
|
||||||
|
_ => kind
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task BackUp()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.BackUp();
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.Running = "backup";
|
||||||
|
Toast.AddResponseSuccess(Localizer["The backup has started."]);
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete(ViewBackup backup)
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
|
||||||
|
return;
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.DeleteBackup(backup.Id);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskDownload(ViewBackup backup)
|
||||||
|
{
|
||||||
|
(password, ticket, dialogError) = (null, null, null);
|
||||||
|
downloading = backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
|
||||||
|
|
||||||
|
async Task Download()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.BackupTicket(downloading.Id, password);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
dialogError = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var granted = (ViewBackupTicket)result.Data;
|
||||||
|
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
|
||||||
|
ticketSize = granted.Bytes;
|
||||||
|
password = null;
|
||||||
|
dialogError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskRestore(ViewBackup backup)
|
||||||
|
{
|
||||||
|
(password, typedHost, dialogError) = (null, null, null);
|
||||||
|
restoring = backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
|
||||||
|
|
||||||
|
async Task Restore()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var asked = restoring;
|
||||||
|
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
dialogError = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseRestore();
|
||||||
|
restarting = asked.Id;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||||
|
|
||||||
|
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||||
|
|
||||||
|
async Task Upload()
|
||||||
|
{
|
||||||
|
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||||
|
self ??= DotNetObjectReference.Create(this);
|
||||||
|
var started = await AdminService.StartBackupUpload();
|
||||||
|
if (started.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload = (ViewBackupUpload)started.Data;
|
||||||
|
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||||
|
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
|
||||||
|
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||||
|
if (!sent.Ok)
|
||||||
|
{
|
||||||
|
uploading = false;
|
||||||
|
await AdminService.CancelBackupUpload(upload.Id);
|
||||||
|
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finished = await AdminService.FinishBackupUpload(upload.Id);
|
||||||
|
uploading = false;
|
||||||
|
if (finished.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(finished);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup = (ViewBackup)finished.Data;
|
||||||
|
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
|
||||||
|
pickedName = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public Task Progress(long received, long total)
|
||||||
|
{
|
||||||
|
(uploaded, uploadSize) = (received, total);
|
||||||
|
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||||
|
return InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UploadOutcome
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
polling?.Cancel();
|
||||||
|
self?.Dispose();
|
||||||
|
if (transfer is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transfer.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The federation ledger's events, newest first (ViewInteractionEvent): what was received, delivered or fetched and how
|
||||||
|
it ended, never who. Channels and outcomes show as the ledger keys them, like the statistics' counters. In the
|
||||||
|
statistics' Events section and in a server's detail. *@
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-events-table data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["When"]</th>
|
||||||
|
@if (ShowHost)
|
||||||
|
{
|
||||||
|
<th>@Localizer["server"]</th>
|
||||||
|
}
|
||||||
|
<th>@Localizer["Channel"]</th>
|
||||||
|
<th>@Localizer["Activity"]</th>
|
||||||
|
<th>@Localizer["Outcome"]</th>
|
||||||
|
<th>@Localizer["Reason"]</th>
|
||||||
|
<th class="text-right">HTTP</th>
|
||||||
|
<th class="text-right">ms</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var happened in Events)
|
||||||
|
{
|
||||||
|
<tr data-statistics-event="@happened.Channel" data-outcome="@happened.Outcome">
|
||||||
|
<td class="whitespace-nowrap" title="@NerdRow.Time(happened.At)">@happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</td>
|
||||||
|
@if (ShowHost)
|
||||||
|
{
|
||||||
|
<td class="max-w-40 truncate" title="@happened.Host">@happened.Host</td>
|
||||||
|
}
|
||||||
|
<td>@happened.Channel</td>
|
||||||
|
<td class="max-w-48 truncate" title="@happened.Object">@happened.Activity</td>
|
||||||
|
<td>
|
||||||
|
<DBadge Size="DSize.Xs" Color="@OutcomeColor(happened.Outcome)">@happened.Outcome</DBadge>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-48 truncate" title="@happened.Reason">@happened.Reason</td>
|
||||||
|
<td class="text-right tabular-nums">@happened.Status</td>
|
||||||
|
<td class="text-right tabular-nums">@happened.LatencyMs</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public List<ViewInteractionEvent> Events { get; set; } = [];
|
||||||
|
/// <summary>A column for the server, where the events are of many.</summary>
|
||||||
|
[Parameter] public bool ShowHost { get; set; } = true;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
static DColor OutcomeColor(string outcome) => outcome switch
|
||||||
|
{
|
||||||
|
"ok" or "accepted" => DColor.Success,
|
||||||
|
"queued" or "deferred" or "retry" => DColor.Warning,
|
||||||
|
"failed" or "dead" or "refused" or "rejected" or "dropped" => DColor.Error,
|
||||||
|
_ => DColor.None
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* One server of the federation statistics (/clientapi/admin/statistics/hosts/{host}), opened from its row: what it says
|
||||||
|
of itself and what PrivaPub knows of its deliveries (as nerd rows, by their field names), its days with the main
|
||||||
|
counters (newest first; a live day is today's, or one not rolled up yet), and its latest events. "Describe again"
|
||||||
|
asks PrivaPub to read its NodeInfo and instance description anew. Counted per server, never per account. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" BoxClass="md:w-11/12 md:max-w-4xl"
|
||||||
|
data-host-detail="@HostName">
|
||||||
|
<Heading><span class="break-all">@HostName</span></Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-4 mt-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isDescribing)" OnClick="Describe" data-host-describe>
|
||||||
|
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Describe again"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (detail is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (detail.Instance is { } instance)
|
||||||
|
{
|
||||||
|
<NerdRows Rows="InstanceRows(instance)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-72 rounded-xl neo-inset neo-2xs" data-host-days data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["Day"]</th>
|
||||||
|
<th class="text-right">@Localizer["Received"]</th>
|
||||||
|
<th class="text-right">@Localizer["In"]</th>
|
||||||
|
<th class="text-right">@Localizer["Out"]</th>
|
||||||
|
<th class="text-right">@Localizer["Failures"]</th>
|
||||||
|
<th class="text-right">@Localizer["Accounts"]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var day in detail.Days.OrderByDescending(d => d.Day))
|
||||||
|
{
|
||||||
|
<tr data-host-day="@NerdRow.Time(day.Day)">
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
@day.Day.ToString(SUtility.dateTimeFormat)
|
||||||
|
@if (day.Live)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Info">@Localizer["live"]</DBadge>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "recv")</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "in")</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "out")</td>
|
||||||
|
<td class="text-right tabular-nums">@Failures(day.Counters)</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Accounts</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
|
||||||
|
@if (detail.Events.Count > 0)
|
||||||
|
{
|
||||||
|
<b class="text-sm">@Localizer["Last events"]</b>
|
||||||
|
<EventsTable Events="detail.Events" ShowHost="false" />
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@detail.Attribution</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string HostName { get; set; }
|
||||||
|
/// <summary>How many days back its days go.</summary>
|
||||||
|
[Parameter] public int Days { get; set; } = 30;
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
ViewHostDetail detail;
|
||||||
|
bool isDescribing;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => detail = await AdminService.Host(HostName, Days);
|
||||||
|
|
||||||
|
async Task Describe()
|
||||||
|
{
|
||||||
|
isDescribing = true;
|
||||||
|
var result = await AdminService.Describe(HostName);
|
||||||
|
isDescribing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["PrivaPub will read what {0} says of itself again.", HostName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent).</summary>
|
||||||
|
static long Channel(Dictionary<string, long> counters, string channel) =>
|
||||||
|
counters.Where(c => c.Key.StartsWith(channel + ":", StringComparison.Ordinal)).Sum(c => c.Value);
|
||||||
|
|
||||||
|
// the deliveries out that failed, are retried or were given up, as the servers table counts them
|
||||||
|
static long Failures(Dictionary<string, long> counters) =>
|
||||||
|
counters.Where(c => c.Key.Split(':') is { Length: >= 4 } parts && parts[0] == "out" && parts[3] is "retry" or "dead" or "failed").Sum(c => c.Value);
|
||||||
|
|
||||||
|
static IEnumerable<NerdRow> InstanceRows(ViewHostInstance instance)
|
||||||
|
{
|
||||||
|
yield return NerdRow.Of("software", $"{instance.Software} {instance.SoftwareVersion}".Trim());
|
||||||
|
yield return NerdRow.Of("nodeName", instance.NodeName);
|
||||||
|
yield return NerdRow.Of("title", instance.Title);
|
||||||
|
yield return NerdRow.Of("nodeDescription", instance.NodeDescription);
|
||||||
|
yield return NerdRow.Of("openRegistrations", instance.OpenRegistrations);
|
||||||
|
yield return NerdRow.Of("registrationMode", instance.RegistrationMode);
|
||||||
|
yield return NerdRow.Of("usersTotal", instance.UsersTotal);
|
||||||
|
yield return NerdRow.Of("usersActiveMonth", instance.UsersActiveMonth);
|
||||||
|
yield return NerdRow.Of("localPosts", instance.LocalPosts);
|
||||||
|
yield return NerdRow.Of("languages", instance.Languages.Count > 0 ? string.Join(", ", instance.Languages) : null);
|
||||||
|
yield return NerdRow.Of("seen", instance.Seen);
|
||||||
|
yield return NerdRow.Of("firstSeenAt", instance.FirstSeenAt);
|
||||||
|
yield return NerdRow.Of("lastSeenAt", instance.LastSeenAt);
|
||||||
|
yield return NerdRow.Of("describedAt", instance.DescribedAt);
|
||||||
|
yield return NerdRow.Of("descriptionError", instance.DescriptionError);
|
||||||
|
yield return NerdRow.Of("lastSuccessAt", instance.LastSuccessAt);
|
||||||
|
yield return NerdRow.Of("lastFailureAt", instance.LastFailureAt);
|
||||||
|
yield return NerdRow.Of("lastError", instance.LastError);
|
||||||
|
yield return NerdRow.Of("consecutiveFailures", instance.ConsecutiveFailures > 0 ? instance.ConsecutiveFailures : null);
|
||||||
|
yield return NerdRow.Of("unavailableUntil", instance.UnavailableUntil);
|
||||||
|
yield return NerdRow.Of("place", string.Join(", ", new[] { instance.City, instance.Country }.Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } place ? place : null);
|
||||||
|
yield return NerdRow.Of("asn", instance.Asn is { } asn ? $"AS{asn} {instance.AsnOrg}".Trim() : null);
|
||||||
|
yield return NerdRow.Of("cdn", instance.Cdn);
|
||||||
|
yield return NerdRow.Of("instanceApi", instance.InstanceApi);
|
||||||
|
yield return NerdRow.Of("sourceUrl", instance.SourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
|
@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown
|
||||||
|
description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the
|
||||||
|
local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its
|
||||||
|
weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts
|
||||||
|
from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays.
|
||||||
|
Registrations are the configuration's and only shown. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-instance>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (settings is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Thumbnail"]</span>
|
||||||
|
<img src="@settings.ThumbnailUrl" alt="@Localizer["Thumbnail"]" class="w-full max-w-sm aspect-[40/21] object-cover rounded-lg neo-inset neo-2xs"
|
||||||
|
data-instance-thumbnail-image="@(settings.ThumbnailIsDefault ? "default" : "uploaded")" />
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="PickThumbnail" disabled="@(IsDefaultDisabled || isUploading)"
|
||||||
|
aria-label="@Localizer["Thumbnail"]" data-instance-thumbnail />
|
||||||
|
@if (isUploading)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
@if (!settings.ThumbnailIsDefault)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isUploading" OnClick="RemoveThumbnail" data-instance-thumbnail-remove>
|
||||||
|
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Back to the default picture"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]</p>
|
||||||
|
<ErrorViewer AdditionalErrors="thumbnailErrors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Server name"]</span>
|
||||||
|
<DInput TValue="string" class="w-full" @bind-Value="form.Title" Immediate="true" autocomplete="off" maxlength="64" data-instance-title />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Short description"]</span>
|
||||||
|
<DTextarea TValue="string" class="w-full" @bind-Value="form.ShortDescription" Immediate="true" rows="2" autocomplete="off" maxlength="500"
|
||||||
|
data-instance-short-description />
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Plain text, as the instance API, NodeInfo and the server's own actor give it."]</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Description (Markdown)"]</span>
|
||||||
|
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.Description" Immediate="true" rows="8" autocomplete="off"
|
||||||
|
maxlength="@ServerSettingsForm.MaxText" data-instance-description />
|
||||||
|
@if (settings.DescriptionUpdatedAt is { } described)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" title="@described.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", described.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<legend class="text-sm font-semibold px-1">@Localizer["Contact"]</legend>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Contact address"]</span>
|
||||||
|
<DInput TValue="string" Type="email" class="w-full" @bind-Value="form.ContactEmail" Immediate="true" autocomplete="off" maxlength="254"
|
||||||
|
data-instance-email>
|
||||||
|
<Prefix><i class="ion-md-mail" aria-hidden="true"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm">@Localizer["Contact persona"]</span>
|
||||||
|
@if (form.ContactAccountId is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-raised neo-2xs" data-instance-contact="@form.ContactAccountId">
|
||||||
|
<i class="ion-md-person text-lg" aria-hidden="true"></i>
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate">@(contactName ?? form.ContactAccountId)</b>
|
||||||
|
@if (contactHandle is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70 truncate">@contactHandle</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="RemoveContact" data-instance-contact-remove>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["No contact persona"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["None: the server names nobody."]</p>
|
||||||
|
}
|
||||||
|
@* its form attribute names no form, so Enter in the search never saves the settings *@
|
||||||
|
<DInput TValue="string" class="w-full" Value="contactQuery" ValueChanged="SearchContact" ValueExpression="() => contactQuery" Immediate="true"
|
||||||
|
autocomplete="off" form="instance-contact-search" placeholder="@Localizer["Search this server's personas"]"
|
||||||
|
aria-label="@Localizer["Search this server's personas"]" data-instance-contact-search>
|
||||||
|
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<div class="flex flex-wrap gap-2" data-instance-contact-options>
|
||||||
|
@if (contactQuery is { Length: > 0 })
|
||||||
|
{
|
||||||
|
@foreach (var account in contactMatches)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving" OnClick="() => PickContact(account.Id, account.Name, account.Acct)"
|
||||||
|
data-instance-contact-option="@account.Id">
|
||||||
|
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@if (contactMatches.Count == 0 && !isSearching)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@Localizer["No persona of this server matches."]</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var persona in CascadingState.Personas)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving"
|
||||||
|
OnClick="() => PickContact(persona.Id, persona.Name ?? persona.UserName, persona.Handle ?? persona.UserName)" data-instance-contact-option="@persona.Id">
|
||||||
|
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["The contact persona is shown to everyone as the one who runs this server."]</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Privacy policy (Markdown)"]</span>
|
||||||
|
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.PrivacyPolicy" Immediate="true" rows="8" autocomplete="off"
|
||||||
|
maxlength="@ServerSettingsForm.MaxText" data-instance-privacy />
|
||||||
|
@if (settings.PrivacyPolicyUpdatedAt is { } policyAt)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" title="@policyAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", policyAt.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm font-semibold">@Localizer["Terms of service (Markdown)"]</span>
|
||||||
|
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.TermsOfService" Immediate="true" rows="8" autocomplete="off"
|
||||||
|
maxlength="@ServerSettingsForm.MaxText" placeholder="@Localizer["None"]" data-instance-terms />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm">@Localizer["In effect from"]</span>
|
||||||
|
<DInput TValue="string" Type="date" class="w-auto" @bind-Value="termsDate" autocomplete="off" data-instance-terms-date />
|
||||||
|
</label>
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Empty, the server has no terms. Without a date, new terms take effect today."]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<DToggle LabelText="@Localizer["Publish the weekly activity"]" @bind-Value="form.PublishActivity" data-instance-publish-activity />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm" data-instance-registrations="@settings.RegistrationMode">
|
||||||
|
<b>@Localizer["Sign-ups"]</b>
|
||||||
|
@(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"])
|
||||||
|
<span class="text-xs opacity-70">@Localizer["(set in the server's configuration, Registrations:Mode)"]</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ErrorViewer />
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
@if (settings.UpdatedAt is { } savedAt)
|
||||||
|
{
|
||||||
|
<span class="me-auto text-xs opacity-70" title="@savedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)" data-instance-saved>
|
||||||
|
@Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy]
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<DButton Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-instance-reset>@Localizer["Discard changes"]</DButton>
|
||||||
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-instance-save>
|
||||||
|
@if (isSaving)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
ViewServerSettings settings;
|
||||||
|
ServerSettingsForm form = new();
|
||||||
|
// the terms' date as the date input holds it (yyyy-MM-dd), empty for none
|
||||||
|
string termsDate;
|
||||||
|
// the contact persona as shown: what the settings name, or the one just picked
|
||||||
|
string contactName;
|
||||||
|
string contactHandle;
|
||||||
|
string contactQuery;
|
||||||
|
List<Account> contactMatches = [];
|
||||||
|
int searching;
|
||||||
|
bool isSearching;
|
||||||
|
bool isSaving;
|
||||||
|
bool isUploading;
|
||||||
|
List<string> pictureTypes = [];
|
||||||
|
List<string> thumbnailErrors = [];
|
||||||
|
|
||||||
|
string PictureTypes => string.Join(',', pictureTypes);
|
||||||
|
|
||||||
|
// what PrivaPub takes for a picture now, within its hard cap
|
||||||
|
long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0
|
||||||
|
? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes)
|
||||||
|
: ServerMediaLimitsForm.HardMaxImageBytes;
|
||||||
|
|
||||||
|
string SavedBy => settings?.UpdatedBy switch
|
||||||
|
{
|
||||||
|
null => Localizer["someone"],
|
||||||
|
"cli" => Localizer["the command line"],
|
||||||
|
var name => name
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))];
|
||||||
|
settings = await InstanceAdminService.Settings();
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Retry()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
if (settings is null)
|
||||||
|
return;
|
||||||
|
form = InstanceAdminService.FormOf(settings);
|
||||||
|
termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName;
|
||||||
|
contactHandle = settings.ContactAccount?.Handle;
|
||||||
|
contactQuery = null;
|
||||||
|
contactMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SearchContact(string query)
|
||||||
|
{
|
||||||
|
contactQuery = query;
|
||||||
|
var lookup = ++searching;
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
contactMatches = [];
|
||||||
|
isSearching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true;
|
||||||
|
await Task.Delay(300);
|
||||||
|
if (lookup != searching)
|
||||||
|
return;
|
||||||
|
var found = await AccountService.Suggest(query.Trim(), false);
|
||||||
|
if (lookup != searching)
|
||||||
|
return;
|
||||||
|
// the server's own personas only: a local account's acct has no host
|
||||||
|
contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))];
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PickContact(string accountId, string name, string handle)
|
||||||
|
{
|
||||||
|
form.ContactAccountId = accountId;
|
||||||
|
contactName = name;
|
||||||
|
contactHandle = handle;
|
||||||
|
contactQuery = null;
|
||||||
|
contactMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveContact()
|
||||||
|
{
|
||||||
|
form.ContactAccountId = null;
|
||||||
|
contactName = null;
|
||||||
|
contactHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task PickThumbnail(InputFileChangeEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
thumbnailErrors = [];
|
||||||
|
var file = eventArgs.File;
|
||||||
|
if (!pictureTypes.Contains(file.ContentType))
|
||||||
|
{
|
||||||
|
thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.Size > MaximumThumbnail)
|
||||||
|
{
|
||||||
|
thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
|
||||||
|
MaximumThumbnail.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail);
|
||||||
|
isUploading = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the form keeps what is being written; only the picture and the dates come from the answer
|
||||||
|
settings = (ViewServerSettings)result.Data;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Thumbnail saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveThumbnail()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."],
|
||||||
|
Localizer["Remove"], true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
var result = await InstanceAdminService.RemoveThumbnail();
|
||||||
|
isUploading = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = (ViewServerSettings)result.Data;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Thumbnail removed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the value written here when it differs from the one shown, else the one PrivaPub has now
|
||||||
|
static string Changed(string written, string shown, string latest) => written == shown ? latest : written;
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
var effective = default(DateTime?);
|
||||||
|
if (termsDate is { Length: > 0 })
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||||
|
out var on))
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
effective = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only what was changed here: what another administrator saved meanwhile to the rest stays
|
||||||
|
var shown = InstanceAdminService.FormOf(settings);
|
||||||
|
var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.SaveSettings(latest =>
|
||||||
|
{
|
||||||
|
latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title);
|
||||||
|
latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription);
|
||||||
|
latest.Description = Changed(form.Description, shown.Description, latest.Description);
|
||||||
|
latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail);
|
||||||
|
latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId);
|
||||||
|
latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy);
|
||||||
|
latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService);
|
||||||
|
latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt;
|
||||||
|
latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity;
|
||||||
|
});
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = (ViewServerSettings)result.Data;
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["The server's settings are saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The media limits the administrator sets over the configuration's (Media:*, /clientapi/admin/settings): what a login's
|
||||||
|
media may take in all, how big a picture and a video may be, and how long a sound or a video may last. Left empty, a
|
||||||
|
limit is the configuration's; beside each, the configured value and the one in force now, and PrivaPub's hard caps
|
||||||
|
are the maxima (40 MB, 99 MB, four hours). Saving starts from the settings PrivaPub has now
|
||||||
|
(InstanceAdminService.SaveSettings), so what Instance or Rules saved stays. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-media-limits>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (settings is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload."]</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="quota">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Media a login may keep in all"]</span>
|
||||||
|
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="quotaMb" Immediate="true" autocomplete="off" min="0" step="1"
|
||||||
|
placeholder="@Localizer["as configured"]" data-media-quota>
|
||||||
|
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["Configured: {0}", Quota(settings.Media.ConfiguredQuotaBytesPerRoot)] ·
|
||||||
|
<span data-media-effective>@Localizer["In force: {0}", Quota(settings.Media.EffectiveQuotaBytesPerRoot)]</span> ·
|
||||||
|
@Localizer["0: no quota"]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="image">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest picture"]</span>
|
||||||
|
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="imageMb" Immediate="true" autocomplete="off" min="1"
|
||||||
|
max="@(settings.Media.HardMaxImageBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-image>
|
||||||
|
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxImageBytes.GetFileSize(Localizer)] ·
|
||||||
|
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxImageBytes.GetFileSize(Localizer)]</span> ·
|
||||||
|
@Localizer["At most {0}", settings.Media.HardMaxImageBytes.GetFileSize(Localizer)]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="video">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest video or sound"]</span>
|
||||||
|
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="videoMb" Immediate="true" autocomplete="off" min="1"
|
||||||
|
max="@(settings.Media.HardMaxVideoBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-video>
|
||||||
|
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxVideoBytes.GetFileSize(Localizer)] ·
|
||||||
|
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxVideoBytes.GetFileSize(Localizer)]</span> ·
|
||||||
|
@Localizer["At most {0}", settings.Media.HardMaxVideoBytes.GetFileSize(Localizer)]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="seconds">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Longest video or sound"]</span>
|
||||||
|
<DInput TValue="int?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="seconds" Immediate="true" autocomplete="off" min="1"
|
||||||
|
max="@settings.Media.HardMaxSeconds" step="1" placeholder="@Localizer["as configured"]" data-media-seconds>
|
||||||
|
<Suffix><span class="text-xs opacity-70">@Localizer["seconds"]</span></Suffix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["Configured: {0}", Length(settings.Media.ConfiguredMaxSeconds)] ·
|
||||||
|
<span data-media-effective>@Localizer["In force: {0}", Length(settings.Media.EffectiveMaxSeconds)]</span> ·
|
||||||
|
@Localizer["At most {0}", Length(settings.Media.HardMaxSeconds)]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalErrors="errors" />
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="UseConfigured" data-media-use-configured>
|
||||||
|
@Localizer["Use the configured values"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-media-reset>@Localizer["Discard changes"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" OnClick="Save" data-media-save>
|
||||||
|
@if (isSaving)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Save the limits"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const long Megabyte = 1024 * 1024;
|
||||||
|
|
||||||
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||||
|
|
||||||
|
ViewServerSettings settings;
|
||||||
|
// the overrides as being written, in megabytes and seconds; null: the configured value
|
||||||
|
long? quotaMb;
|
||||||
|
long? imageMb;
|
||||||
|
long? videoMb;
|
||||||
|
int? seconds;
|
||||||
|
List<string> errors = [];
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
settings = await InstanceAdminService.Settings();
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Retry()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
errors = [];
|
||||||
|
if (settings is null)
|
||||||
|
return;
|
||||||
|
quotaMb = Megabytes(settings.Media.QuotaBytesPerRoot);
|
||||||
|
imageMb = Megabytes(settings.Media.MaxImageBytes);
|
||||||
|
videoMb = Megabytes(settings.Media.MaxVideoBytes);
|
||||||
|
seconds = settings.Media.MaxSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UseConfigured()
|
||||||
|
{
|
||||||
|
errors = [];
|
||||||
|
quotaMb = null;
|
||||||
|
imageMb = null;
|
||||||
|
videoMb = null;
|
||||||
|
seconds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Quota(long bytes) => bytes <= 0 ? Localizer["no quota"] : bytes.GetFileSize(Localizer);
|
||||||
|
|
||||||
|
static string Length(int value) => NerdRow.Duration(TimeSpan.FromSeconds(value));
|
||||||
|
|
||||||
|
static long? Megabytes(long? bytes) => bytes is { } value ? (long)Math.Round(value / (double)Megabyte) : null;
|
||||||
|
|
||||||
|
// what was set stays to the byte while its megabytes are left as they were shown
|
||||||
|
static long? Bytes(long? megabytes, long? before) =>
|
||||||
|
megabytes is not { } value ? null : before is { } kept && Megabytes(kept) == value ? kept : value * Megabyte;
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
errors = [];
|
||||||
|
var media = settings.Media;
|
||||||
|
if (quotaMb is < 0)
|
||||||
|
errors.Add(Localizer["The quota can't be below 0."]);
|
||||||
|
if (imageMb is { } image && (image < 1 || image * Megabyte > media.HardMaxImageBytes))
|
||||||
|
errors.Add(Localizer["A picture's limit goes from 1 MB to {0}.", media.HardMaxImageBytes.GetFileSize(Localizer)]);
|
||||||
|
if (videoMb is { } video && (video < 1 || video * Megabyte > media.HardMaxVideoBytes))
|
||||||
|
errors.Add(Localizer["A video's limit goes from 1 MB to {0}.", media.HardMaxVideoBytes.GetFileSize(Localizer)]);
|
||||||
|
if (seconds is { } length && (length < 1 || length > media.HardMaxSeconds))
|
||||||
|
errors.Add(Localizer["A length goes from 1 second to {0}.", Length(media.HardMaxSeconds)]);
|
||||||
|
if (errors.Count > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// only the limits changed here: what another administrator saved meanwhile to the others stays
|
||||||
|
var quotaChanged = quotaMb != Megabytes(media.QuotaBytesPerRoot);
|
||||||
|
var imageChanged = imageMb != Megabytes(media.MaxImageBytes);
|
||||||
|
var videoChanged = videoMb != Megabytes(media.MaxVideoBytes);
|
||||||
|
var secondsChanged = seconds != media.MaxSeconds;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.SaveSettings(latest => latest.Media = new ServerMediaLimitsForm
|
||||||
|
{
|
||||||
|
QuotaBytesPerRoot = quotaChanged ? Bytes(quotaMb, media.QuotaBytesPerRoot) : latest.Media.QuotaBytesPerRoot,
|
||||||
|
MaxImageBytes = imageChanged ? Bytes(imageMb, media.MaxImageBytes) : latest.Media.MaxImageBytes,
|
||||||
|
MaxVideoBytes = videoChanged ? Bytes(videoMb, media.MaxVideoBytes) : latest.Media.MaxVideoBytes,
|
||||||
|
MaxSeconds = secondsChanged ? seconds : latest.Media.MaxSeconds
|
||||||
|
});
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = (ViewServerSettings)result.Data;
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["The media limits are saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The staff's log (/clientapi/moderator/log), newest first, 50 at a time: who acted (to administrators every login's
|
||||||
|
name; to a moderator only its own; "the command line" for PrivaPub's CLI; nobody when an act on a persona of the
|
||||||
|
acting login would tie the two), what was done, to what, and its note. Never an IP address. A moderator who isn't an
|
||||||
|
administrator reads what isn't about a login. *@
|
||||||
|
<div class="flex flex-col gap-2 w-full" data-log>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-log-reload>
|
||||||
|
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (failed)
|
||||||
|
{
|
||||||
|
<p class="text-sm text-error">@Localizer["The log could not be read."]</p>
|
||||||
|
}
|
||||||
|
else if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-log-empty>@Localizer["Nothing was done yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="flex flex-col gap-2">
|
||||||
|
@foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
<li class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-log-entry="@entry.Id" data-log-action="@entry.Action">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="text-sm">@ActionName(entry.Action)</b>
|
||||||
|
@if (entry.TargetLabel is { Length: > 0 } label)
|
||||||
|
{
|
||||||
|
<span class="text-sm break-all min-w-0" title="@($"{entry.TargetKind} {entry.TargetId}")" data-log-target>@label</span>
|
||||||
|
}
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="text-xs opacity-70" title="@entry.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@entry.At.GetPassedTime(Localizer)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span data-log-by="@(entry.ByYou ? "you" : entry.By ?? "none")">@By(entry)</span>
|
||||||
|
<code class="opacity-60">@entry.Action</code>
|
||||||
|
@if (entry.ReportId is { Length: > 0 } reportId)
|
||||||
|
{
|
||||||
|
<span class="opacity-70">@Localizer["Report: {0}", reportId]</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (entry.Note is { Length: > 0 } note)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-80 break-words" data-log-note>@note</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (next is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-log-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const int PageSize = 50;
|
||||||
|
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewAdminAction> entries = [];
|
||||||
|
// the next page's `before`; null on the last
|
||||||
|
string next;
|
||||||
|
bool failed;
|
||||||
|
bool isLoadingMore;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
var page = await AdminService.Log(null, PageSize);
|
||||||
|
failed = page is null;
|
||||||
|
entries = page?.Items ?? [];
|
||||||
|
next = page?.Next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Reload()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task More()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
var page = await AdminService.Log(next, PageSize);
|
||||||
|
isLoadingMore = false;
|
||||||
|
if (page is null)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(Localizer["The log could not be read."]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var known = entries.Select(e => e.Id).ToHashSet();
|
||||||
|
entries.AddRange(page.Items.Where(e => known.Add(e.Id)));
|
||||||
|
next = page.Next;
|
||||||
|
}
|
||||||
|
|
||||||
|
string By(ViewAdminAction entry) => entry switch
|
||||||
|
{
|
||||||
|
{ ByYou: true } => Localizer["by you"],
|
||||||
|
{ By: "cli" } => Localizer["by the command line"],
|
||||||
|
{ By: { Length: > 0 } name } => Localizer["by {0}", name],
|
||||||
|
_ => Localizer["by someone not named"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionName(string action) => action switch
|
||||||
|
{
|
||||||
|
"domain_block.insert" => Localizer["Server blocked"],
|
||||||
|
"domain_block.delete" => Localizer["Server unblocked"],
|
||||||
|
"backup.create" => Localizer["Backup made"],
|
||||||
|
"backup.delete" => Localizer["Backup deleted"],
|
||||||
|
"backup.restore" => Localizer["Backup restored"],
|
||||||
|
"settings.update" => Localizer["Settings saved"],
|
||||||
|
"settings.thumbnail" => Localizer["Thumbnail changed"],
|
||||||
|
"rule.insert" => Localizer["Rule added"],
|
||||||
|
"rule.update" => Localizer["Rule changed"],
|
||||||
|
"rule.delete" => Localizer["Rule removed"],
|
||||||
|
"announcement.insert" => Localizer["Announcement written"],
|
||||||
|
"announcement.update" => Localizer["Announcement changed"],
|
||||||
|
"announcement.delete" => Localizer["Announcement deleted"],
|
||||||
|
"relay.insert" => Localizer["Relay added"],
|
||||||
|
"relay.delete" => Localizer["Relay removed"],
|
||||||
|
"report.resolve" => Localizer["Report resolved"],
|
||||||
|
"root.promote" => Localizer["Login made administrator"],
|
||||||
|
"root.demote" => Localizer["Login no longer administrator"],
|
||||||
|
"root.create" => Localizer["Login made"],
|
||||||
|
"root.delete" => Localizer["Login deleted"],
|
||||||
|
"root.reset_totp" => Localizer["Two-factor sign-in reset"],
|
||||||
|
"persona.move" => Localizer["Persona moved to another login"],
|
||||||
|
_ => action
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The relays the server's actor subscribes to (/clientapi/admin/relays): the configuration's (Federation:Relays, removed
|
||||||
|
only there) and the ones added here, each with its subscription's state. PrivaPub answers an addition or a removal at
|
||||||
|
once (202) and subscribes or unsubscribes in the background, since reaching a relay takes a fetch, so the panel reads
|
||||||
|
the list again a few seconds later. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-relays>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" Type="url" @bind-Value="address" Immediate="true" autocomplete="off" maxlength="2048"
|
||||||
|
placeholder="@Localizer["https://relay.example/actor"]" aria-label="@Localizer["The relay's address"]" data-relay-address>
|
||||||
|
<Prefix><i class="ion-md-radio" aria-hidden="true"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(address)" OnClick="Add" data-relay-add>
|
||||||
|
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add the relay"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here."]</p>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (relays.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-relays-empty>@Localizer["The server subscribes to no relay."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var relay in relays)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-relay="@relay.Address" data-relay-state="@relay.State"
|
||||||
|
data-relay-source="@relay.Source">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<b class="flex-1 min-w-0 break-all text-sm">@relay.Address</b>
|
||||||
|
@switch (relay.State)
|
||||||
|
{
|
||||||
|
case "accepted":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Subscribed"]</DBadge>
|
||||||
|
break;
|
||||||
|
case "pending":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Waiting for the relay"]</DBadge>
|
||||||
|
break;
|
||||||
|
case "rejected":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Refused by the relay"]</DBadge>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Not asked yet"]</DBadge>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@if (relay.Source == "configuration")
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" title="@Localizer["Named by the configuration (Federation:Relays): removed only there."]">
|
||||||
|
@Localizer["Configured"]
|
||||||
|
</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(relay)" data-relay-remove>
|
||||||
|
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (relay.ActorURI is { Length: > 0 } actor && actor != relay.Address)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70 break-all">@Localizer["Its actor: {0}", actor]</p>
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@if (relay.RequestedAt is { } requested)
|
||||||
|
{
|
||||||
|
<span title="@requested.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Asked {0} ago", requested.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
@if (relay.AnsweredAt is { } answered)
|
||||||
|
{
|
||||||
|
<span title="@answered.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"> · @Localizer["answered {0} ago", answered.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (relay.Source == "configuration")
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Named by the configuration (Federation:Relays): removed only there."]</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// when the list is read again after a change: subscribing to a relay is a fetch, then its answer
|
||||||
|
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
|
||||||
|
|
||||||
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewRelay> relays = [];
|
||||||
|
string address;
|
||||||
|
bool isSaving;
|
||||||
|
CancellationTokenSource rereading;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => relays = await InstanceAdminService.Relays();
|
||||||
|
|
||||||
|
async Task Add()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.AddRelay(address);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relays = (List<ViewRelay>)result.Data;
|
||||||
|
address = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["The relay is added: the server subscribes to it now."]);
|
||||||
|
await ReadAgainSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Remove(ViewRelay relay)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.RemoveRelay(relay.Address);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relays = (List<ViewRelay>)result.Data;
|
||||||
|
Toast.AddResponseSuccess(Localizer["The relay is removed: the server unsubscribes from it now."]);
|
||||||
|
await ReadAgainSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the background subscription answers later: the list is read again, the last change's timer replacing the one before
|
||||||
|
async Task ReadAgainSoon()
|
||||||
|
{
|
||||||
|
if (rereading is not null)
|
||||||
|
{
|
||||||
|
await rereading.CancelAsync();
|
||||||
|
rereading.Dispose();
|
||||||
|
}
|
||||||
|
rereading = new();
|
||||||
|
_ = ReadAgain(rereading.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ReadAgain(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var pause in Rereads)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(pause, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var read = await InstanceAdminService.Relays(cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
// an empty answer to a list that had relays is a failed read: a change here showed its own list at once
|
||||||
|
if (read.Count == 0 && relays.Count > 0)
|
||||||
|
continue;
|
||||||
|
relays = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (rereading is not null)
|
||||||
|
{
|
||||||
|
await rereading.CancelAsync();
|
||||||
|
rereading.Dispose();
|
||||||
|
}
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
|
||||||
|
server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub
|
||||||
|
keeps them, a removed rule's too); resolved here. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-reports>
|
||||||
|
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (reports.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var report in reports)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
|
||||||
|
@if (report.Forwarded)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
|
||||||
|
@report.CreatedAt.GetPassedTime(Localizer)
|
||||||
|
</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
@if (!report.IsResolved)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
|
||||||
|
data-report-resolve>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (report.ResolvedAt is { } resolvedAt)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["About"]</b>
|
||||||
|
@if (report.TargetAccountId is { Length: > 0 } targetId)
|
||||||
|
{
|
||||||
|
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="break-all">@report.TargetActorURI</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["From"]</b>
|
||||||
|
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
|
||||||
|
</p>
|
||||||
|
@if (report.RuleIds.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-sm" data-report-rules>
|
||||||
|
<b>@Localizer["Rules it names"]</b>
|
||||||
|
<ol class="list-decimal ps-5">
|
||||||
|
@foreach (var (ruleId, text) in RulesOf(report))
|
||||||
|
{
|
||||||
|
<li class="break-words" data-report-rule-id="@ruleId">
|
||||||
|
@if (text is { Length: > 0 })
|
||||||
|
{
|
||||||
|
@text
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="opacity-70">@Localizer["A rule no longer kept"]</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (report.Comment is { Length: > 0 } comment)
|
||||||
|
{
|
||||||
|
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
|
||||||
|
}
|
||||||
|
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
@foreach (var postId in report.PostIds)
|
||||||
|
{
|
||||||
|
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
|
||||||
|
}
|
||||||
|
@* a report from another server names posts by their ids there when we hold none of them *@
|
||||||
|
@if (report.PostIds.Count == 0)
|
||||||
|
{
|
||||||
|
@foreach (var uri in report.ObjectURIs)
|
||||||
|
{
|
||||||
|
<span class="break-all opacity-70">@uri</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewReport> reports = [];
|
||||||
|
bool resolved;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => reports = await AdminService.Reports(resolved);
|
||||||
|
|
||||||
|
async Task ShowResolved(bool value)
|
||||||
|
{
|
||||||
|
resolved = value;
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// each rule the report names with its text, in the report's order (an empty text: the server no longer has it)
|
||||||
|
static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) =>
|
||||||
|
report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null));
|
||||||
|
|
||||||
|
string CategoryName(string category) => category switch
|
||||||
|
{
|
||||||
|
"spam" => Localizer["Spam"],
|
||||||
|
"legal" => Localizer["Illegal"],
|
||||||
|
"violation" => Localizer["Breaks the rules"],
|
||||||
|
_ => Localizer["Other"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Resolve(ViewReport report)
|
||||||
|
{
|
||||||
|
busy.Add(report.ID);
|
||||||
|
var result = await AdminService.Resolve(report.ID);
|
||||||
|
busy.Remove(report.ID);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.Remove(report);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report resolved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The server's rules (/clientapi/admin/settings): written, explained, put in order and removed here, then saved together.
|
||||||
|
A rule keeps its id while it is listed, so the reports that name it keep their meaning; one removed is kept, greyed
|
||||||
|
below, as old reports name it, and can come back. Saving starts from the settings PrivaPub has now
|
||||||
|
(InstanceAdminService.SaveSettings), so what Instance or Media limits saved stays. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-rules>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (settings is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Everyone reads them on the About page, and a report of a violation names the ones broken."]</p>
|
||||||
|
@if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-rules-empty>@Localizer["The server has no rules."]</p>
|
||||||
|
}
|
||||||
|
<ol class="flex flex-col gap-2">
|
||||||
|
@foreach (var (row, position) in Numbered)
|
||||||
|
{
|
||||||
|
<li @key="row" class="flex flex-col gap-2 p-2 rounded-lg neo-raised neo-2xs" data-rule="@position" data-rule-id="@(row.Id ?? "new")">
|
||||||
|
<div class="flex items-start gap-2 pointer-coarse:gap-2">
|
||||||
|
<span class="mt-2 w-6 flex-none text-right text-sm font-semibold tabular-nums">@(position + 1).</span>
|
||||||
|
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
||||||
|
<DTextarea TValue="string" class="w-full" @bind-Value="row.Text" Immediate="true" rows="2" autocomplete="off" maxlength="500"
|
||||||
|
placeholder="@Localizer["The rule"]" aria-label="@Localizer["The rule"]" data-rule-text />
|
||||||
|
<DInput TValue="string" class="w-full" Size="DSize.Sm" @bind-Value="row.Hint" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
|
placeholder="@Localizer["What it means, if it needs saying (optional)"]" aria-label="@Localizer["Explanation"]" data-rule-hint />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 pointer-coarse:gap-2 flex-none">
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == 0" OnClick="() => Move(position, -1)"
|
||||||
|
aria-label="@Localizer["Move up"]" title="@Localizer["Move up"]" data-rule-up>
|
||||||
|
<i class="ion-md-arrow-up" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == rows.Count - 1" OnClick="() => Move(position, 1)"
|
||||||
|
aria-label="@Localizer["Move down"]" title="@Localizer["Move down"]" data-rule-down>
|
||||||
|
<i class="ion-md-arrow-down" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(row)"
|
||||||
|
aria-label="@Localizer["Remove the rule"]" title="@Localizer["Remove the rule"]" data-rule-delete>
|
||||||
|
<i class="ion-md-trash text-error" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="Add" data-rule-add>
|
||||||
|
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add a rule"]</span>
|
||||||
|
</DButton>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-rule-reset>@Localizer["Discard changes"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || rows.Any(r => string.IsNullOrWhiteSpace(r.Text))" OnClick="Save"
|
||||||
|
data-rule-save>
|
||||||
|
@if (isSaving)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Save the rules"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (rows.Count >= ServerSettingsForm.MaxRules)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["A server has {0} rules at most.", ServerSettingsForm.MaxRules]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Removed.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2 mt-2" data-rules-deleted>
|
||||||
|
<b class="text-sm">@Localizer["Removed rules, kept as old reports name them"]</b>
|
||||||
|
@foreach (var rule in Removed)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-inset neo-2xs opacity-60" data-rule-deleted="@rule.Id">
|
||||||
|
<span class="flex flex-col flex-1 min-w-0">
|
||||||
|
<s class="text-sm break-words">@rule.Text</s>
|
||||||
|
@if (rule.DeletedAt is { } deletedAt)
|
||||||
|
{
|
||||||
|
<span class="text-xs" title="@deletedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Removed {0} ago", deletedAt.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="() => Restore(rule)"
|
||||||
|
data-rule-restore>
|
||||||
|
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Bring it back"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||||
|
|
||||||
|
ViewServerSettings settings;
|
||||||
|
// the rules as being written, in their order: a listed rule keeps its id, a new one has none
|
||||||
|
List<ServerRuleForm> rows = [];
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
IEnumerable<(ServerRuleForm Row, int Position)> Numbered => rows.Select((row, position) => (row, position));
|
||||||
|
|
||||||
|
// the rules the server keeps deleted, unless they are listed again above
|
||||||
|
List<ViewServerRule> Removed => [.. (settings?.Rules ?? []).Where(r => r.IsDeleted && rows.All(row => row.Id != r.Id))];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
settings = await InstanceAdminService.Settings();
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Retry()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset() => rows = settings is null ? [] : InstanceAdminService.FormOf(settings).Rules;
|
||||||
|
|
||||||
|
void Add() => rows.Add(new ServerRuleForm());
|
||||||
|
|
||||||
|
// gone from the list, it is removed when the rules are saved (kept, marked, for the reports naming it)
|
||||||
|
void Remove(ServerRuleForm row) => rows.Remove(row);
|
||||||
|
|
||||||
|
void Move(int position, int by)
|
||||||
|
{
|
||||||
|
var row = rows[position];
|
||||||
|
rows.RemoveAt(position);
|
||||||
|
rows.Insert(position + by, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// listed again with its id, it is the same rule: the reports naming it name it still
|
||||||
|
void Restore(ViewServerRule rule) => rows.Add(new ServerRuleForm { Id = rule.Id, Text = rule.Text, Hint = rule.Hint });
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
var rules = rows.Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text?.Trim(), Hint = string.IsNullOrWhiteSpace(r.Hint) ? null : r.Hint.Trim() }).ToList();
|
||||||
|
isSaving = true;
|
||||||
|
var result = await InstanceAdminService.SaveSettings(latest => latest.Rules = rules);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = (ViewServerSettings)result.Data;
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["The rules are saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
|
||||||
|
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins.
|
||||||
|
A block is published (/api/v1/instance/domain_blocks, the About page) only when asked, since that tells the blocked
|
||||||
|
server and everyone else; published, its name can be partly starred. Blocking a server again changes its block, so
|
||||||
|
Edit fills the form with it. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-server-blocks>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-server-block-domain>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
|
||||||
|
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DToggle LabelText="@Localizer["Publish it"]" Value="form.Published" ValueChanged="Publish" ValueExpression="() => form.Published"
|
||||||
|
data-server-block-published />
|
||||||
|
<DToggle LabelText="@Localizer["Hide part of its name"]" @bind-Value="form.Obfuscate" disabled="@(!form.Published)" data-server-block-obfuscate />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it."]</p>
|
||||||
|
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
|
placeholder="@Localizer["Why, for everyone to read"]" />
|
||||||
|
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
|
placeholder="@Localizer["Why, for the admins only"]" />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
|
||||||
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save>
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-server-block="@block.Domain">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@block.Domain</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@(block.Severity == "Suspend" ? DColor.Error : DColor.Warning)">
|
||||||
|
@(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"])
|
||||||
|
</DBadge>
|
||||||
|
@if (block.RejectMedia)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
|
||||||
|
}
|
||||||
|
@if (block.Published)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Info" data-server-block-public>
|
||||||
|
@(block.Obfuscate ? Localizer["Published, partly hidden"] : Localizer["Published"])
|
||||||
|
</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Edit(block)" data-server-block-edit>
|
||||||
|
@Localizer["Edit"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (block.PublicComment is { Length: > 0 } publicComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs">@publicComment</p>
|
||||||
|
}
|
||||||
|
@if (block.PrivateComment is { Length: > 0 } privateComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70"><i class="ion-md-lock"></i> @privateComment</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewDomainBlock> blocks = [];
|
||||||
|
DomainBlockForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => blocks = await AdminService.ServerBlocks();
|
||||||
|
|
||||||
|
// a block that isn't published can't be partly hidden either
|
||||||
|
void Publish(bool published)
|
||||||
|
{
|
||||||
|
form.Published = published;
|
||||||
|
if (!published)
|
||||||
|
form.Obfuscate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Edit(ViewDomainBlock block) => form = new()
|
||||||
|
{
|
||||||
|
Domain = block.Domain,
|
||||||
|
Suspend = block.Severity == "Suspend",
|
||||||
|
RejectMedia = block.RejectMedia,
|
||||||
|
Published = block.Published,
|
||||||
|
Obfuscate = block.Obfuscate,
|
||||||
|
PublicComment = block.PublicComment,
|
||||||
|
PrivateComment = block.PrivateComment
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Block()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AdminService.BlockServer(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = (ViewDomainBlock)result.Data;
|
||||||
|
blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)];
|
||||||
|
form = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Unblock(ViewDomainBlock block)
|
||||||
|
{
|
||||||
|
busy.Add(block.Domain);
|
||||||
|
var result = await AdminService.UnblockServer(block.Domain);
|
||||||
|
busy.Remove(block.Domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.Remove(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
|
||||||
|
(by traffic, failures, latency or name, 50 a page; a server's name opens its detail, HostDetailDialog), how far the
|
||||||
|
crawler got, the ledger's events (by server, channel and outcome, 100 at a time, older ones from the last one's time)
|
||||||
|
and the server's own days (its client API, the documents it served, its ledger). Counted per server, never per
|
||||||
|
account. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-statistics>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Period"]</span>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
|
||||||
|
@foreach (var period in Periods)
|
||||||
|
{
|
||||||
|
<option value="@period">@Localizer["{0} days", period]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Servers by"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
|
||||||
|
<option value="traffic">@Localizer["Traffic"]</option>
|
||||||
|
<option value="failures">@Localizer["Failures"]</option>
|
||||||
|
<option value="latency">@Localizer["Latency"]</option>
|
||||||
|
<option value="host">@Localizer["Name"]</option>
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (overview is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
|
||||||
|
<ChildContent>@overview.Hosts.Active</ChildContent>
|
||||||
|
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading>@Localizer["Accounts seen"]</Heading>
|
||||||
|
<ChildContent>@overview.Accounts</ChildContent>
|
||||||
|
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
|
||||||
|
</DStat>
|
||||||
|
</DStats>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-xs">
|
||||||
|
@foreach (var (title, counts) in Outcomes)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 min-w-40">
|
||||||
|
<b class="text-sm">@title</b>
|
||||||
|
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
|
||||||
|
{
|
||||||
|
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hosts is not null)
|
||||||
|
{
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["server"]</th>
|
||||||
|
<th>@Localizer["software"]</th>
|
||||||
|
<th class="text-right">@Localizer["In"]</th>
|
||||||
|
<th class="text-right">@Localizer["Out"]</th>
|
||||||
|
<th class="text-right">@Localizer["Refused"]</th>
|
||||||
|
<th class="text-right">@Localizer["Failures"]</th>
|
||||||
|
<th class="text-right">p50</th>
|
||||||
|
<th>@Localizer["Delivery"]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var host in hosts.Hosts)
|
||||||
|
{
|
||||||
|
<tr data-statistics-host="@host.Host">
|
||||||
|
<td class="max-w-48">
|
||||||
|
<button type="button" class="link link-hover block max-w-full truncate text-left pointer-coarse:py-3.5" title="@host.Host"
|
||||||
|
@onclick="() => openHost = host.Host" data-statistics-host-open>@host.Host</button>
|
||||||
|
</td>
|
||||||
|
<td>@host.Software @host.Version</td>
|
||||||
|
<td class="text-right tabular-nums">@host.In</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Out</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Refused</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Failures</td>
|
||||||
|
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
|
||||||
|
<td>
|
||||||
|
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (host.Delivery?.ConsecutiveFailures > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
@if (hosts.Total > PageSize)
|
||||||
|
{
|
||||||
|
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
|
||||||
|
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (openHost is { } detailHost)
|
||||||
|
{
|
||||||
|
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (crawler is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
||||||
|
<b class="text-sm">@Localizer["Crawler"]</b>
|
||||||
|
<span>
|
||||||
|
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
|
||||||
|
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
|
||||||
|
</span>
|
||||||
|
@foreach (var host in crawler.Recent)
|
||||||
|
{
|
||||||
|
<span class="flex flex-wrap gap-2">
|
||||||
|
<span class="truncate max-w-48">@host.Host</span>
|
||||||
|
<span class="opacity-70">@host.Software</span>
|
||||||
|
@if (host.LastCrawledAt is { } crawled)
|
||||||
|
{
|
||||||
|
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
|
||||||
|
}
|
||||||
|
@if (host.CrawlError is { Length: > 0 } crawlError)
|
||||||
|
{
|
||||||
|
<span class="text-error">@crawlError</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2" data-statistics-events>
|
||||||
|
<b class="text-sm">@Localizer["Events"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["server"]</span>
|
||||||
|
<DInput TValue="string" Size="DSize.Sm" @bind-Value="eventsHost" @bind-Value:after="ReloadEvents" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-statistics-events-host />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Channel"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsChannel" ValueChanged="ChangeChannel" ValueExpression="() => eventsChannel"
|
||||||
|
data-statistics-events-channel>
|
||||||
|
<option value="">@Localizer["All"]</option>
|
||||||
|
@foreach (var channel in EventChannels)
|
||||||
|
{
|
||||||
|
<option value="@channel">@channel</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Outcome"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsOutcome" ValueChanged="ChangeOutcome" ValueExpression="() => eventsOutcome"
|
||||||
|
data-statistics-events-outcome>
|
||||||
|
<option value="">@Localizer["All"]</option>
|
||||||
|
@foreach (var outcome in EventOutcomes)
|
||||||
|
{
|
||||||
|
<option value="@outcome">@outcome</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (events.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70" data-statistics-events-empty>@Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EventsTable Events="events" />
|
||||||
|
}
|
||||||
|
@if (hasMoreEvents)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="MoreEvents" data-statistics-events-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2" data-statistics-server>
|
||||||
|
<b class="text-sm">@Localizer["Server days"]</b>
|
||||||
|
@if (serverDays.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["Day"]</th>
|
||||||
|
<th class="text-right">@Localizer["Client API"]</th>
|
||||||
|
<th class="text-right">4xx</th>
|
||||||
|
<th class="text-right">5xx</th>
|
||||||
|
<th class="text-right">@Localizer["Served"]</th>
|
||||||
|
<th class="text-right" title="@Localizer["written / dropped / failed"]">@Localizer["Ledger"]</th>
|
||||||
|
<th class="text-right">@Localizer["Accounts"]</th>
|
||||||
|
<th class="text-right"><span class="capitalize">@Localizer["servers"]</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var day in Enumerable.Reverse(serverDays))
|
||||||
|
{
|
||||||
|
<tr data-statistics-server-day="@NerdRow.Time(day.Day)">
|
||||||
|
<td class="whitespace-nowrap">@day.Day.ToString(SUtility.dateTimeFormat)</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, null)</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, ":4xx")</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, ":5xx")</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Served.Values.Sum()</td>
|
||||||
|
<td class="text-right tabular-nums whitespace-nowrap" title="@Localizer["written / dropped / failed"]">
|
||||||
|
@day.LedgerWritten / @day.LedgerDropped / @day.LedgerFailed
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Accounts</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Hosts</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
const int PageSize = 50;
|
||||||
|
const int EventsPageSize = 100;
|
||||||
|
// the ledger's channels and outcomes, as it keys them
|
||||||
|
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
|
||||||
|
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
|
||||||
|
static readonly int[] Periods = [1, 7, 30, 90];
|
||||||
|
|
||||||
|
ViewStatisticsOverview overview;
|
||||||
|
ViewHostsPage hosts;
|
||||||
|
ViewCrawler crawler;
|
||||||
|
List<ViewInteractionEvent> events = [];
|
||||||
|
List<ViewServerDay> serverDays = [];
|
||||||
|
// the server whose detail is open
|
||||||
|
string openHost;
|
||||||
|
string eventsHost;
|
||||||
|
string eventsChannel;
|
||||||
|
string eventsOutcome;
|
||||||
|
bool hasMoreEvents;
|
||||||
|
bool isLoadingMore;
|
||||||
|
int days = 30;
|
||||||
|
string sort = "traffic";
|
||||||
|
int page = 1;
|
||||||
|
|
||||||
|
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
|
||||||
|
[
|
||||||
|
(Localizer["Received"], overview.Inbound),
|
||||||
|
(Localizer["Delivered"], overview.Outbound),
|
||||||
|
(Localizer["Channels"], overview.Channels)
|
||||||
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
await LoadDays();
|
||||||
|
crawler = await AdminService.Crawler();
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// what the period chosen shows
|
||||||
|
async Task LoadDays()
|
||||||
|
{
|
||||||
|
overview = await AdminService.Overview(days);
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
serverDays = await AdminService.ServerDays(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the client API's requests of a day, those of one status class (":4xx") or all
|
||||||
|
static long ClientRequests(ViewServerDay day, string statusClass) =>
|
||||||
|
day.Client.Where(c => c.Key.EndsWith("xx", StringComparison.Ordinal) && (statusClass is null || c.Key.EndsWith(statusClass, StringComparison.Ordinal)))
|
||||||
|
.Sum(c => c.Value);
|
||||||
|
|
||||||
|
async Task ReloadEvents()
|
||||||
|
{
|
||||||
|
events = [];
|
||||||
|
hasMoreEvents = false;
|
||||||
|
await LoadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadEvents()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
var page = await AdminService.Events(eventsHost?.Trim(), eventsChannel, eventsOutcome, null, events.LastOrDefault()?.At, EventsPageSize);
|
||||||
|
isLoadingMore = false;
|
||||||
|
events.AddRange(page);
|
||||||
|
hasMoreEvents = page.Count == EventsPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreEvents() => await LoadEvents();
|
||||||
|
|
||||||
|
async Task ChangeChannel(string value)
|
||||||
|
{
|
||||||
|
eventsChannel = value;
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeOutcome(string value)
|
||||||
|
{
|
||||||
|
eventsOutcome = value;
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
||||||
|
|
||||||
|
async Task ChangeDays(int value)
|
||||||
|
{
|
||||||
|
days = value;
|
||||||
|
page = 1;
|
||||||
|
await LoadDays();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeSort(string value)
|
||||||
|
{
|
||||||
|
sort = value;
|
||||||
|
page = 1;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangePage(int value)
|
||||||
|
{
|
||||||
|
page = value;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
|
||||||
|
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
|
||||||
|
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
|
||||||
|
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
|
||||||
|
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
|
||||||
|
@if (Current is { } current)
|
||||||
|
{
|
||||||
|
<section class="flex-none flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" aria-label="@Localizer["Announcements"]"
|
||||||
|
data-announcement-banner data-announcement="@current.Id">
|
||||||
|
<div class="flex items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<i class="ion-md-megaphone text-lg text-primary" aria-hidden="true"></i>
|
||||||
|
<b class="text-sm">@Localizer["Announcement"]</b>
|
||||||
|
<span class="text-xs opacity-70" title="@current.PublishedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||||
|
@current.PublishedAt.GetPassedTime(Localizer)
|
||||||
|
</span>
|
||||||
|
@if (ShownCount > 1)
|
||||||
|
{
|
||||||
|
<span class="inline-flex items-center gap-1 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index == 0" OnClick="() => index--" aria-label="@Localizer["Previous announcement"]"
|
||||||
|
title="@Localizer["Previous announcement"]" data-announcement-previous>
|
||||||
|
<i class="ion-md-arrow-back" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
<span class="text-xs tabular-nums" data-announcement-position>@Localizer["{0} of {1}", index + 1, ShownCount]</span>
|
||||||
|
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index >= ShownCount - 1" OnClick="() => index++" aria-label="@Localizer["Next announcement"]"
|
||||||
|
title="@Localizer["Next announcement"]" data-announcement-next>
|
||||||
|
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Circle" Disabled="IsDefaultDisabled || isActing" OnClick="Dismiss"
|
||||||
|
aria-label="@Localizer["Dismiss"]" title="@Localizer["Dismiss"]" data-announcement-dismiss>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (current.StartsAt is not null || current.EndsAt is not null)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
|
||||||
|
}
|
||||||
|
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
|
||||||
|
@foreach (var reaction in current.Reactions)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
|
||||||
|
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
|
||||||
|
@if (reaction.Url is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@reaction.Name</span>
|
||||||
|
}
|
||||||
|
<span class="tabular-nums">@reaction.Count</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
|
||||||
|
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
|
||||||
|
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
|
||||||
|
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var emoji in QuickReactions)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DDropdown>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
|
||||||
|
|
||||||
|
[Inject] AnnouncementService AnnouncementService { get; set; }
|
||||||
|
[Inject] StreamingService Streaming { get; set; }
|
||||||
|
|
||||||
|
List<Announcement> announcements = [];
|
||||||
|
int index;
|
||||||
|
bool isActing;
|
||||||
|
bool isReactionsOpen;
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
// what the persona has not dismissed, oldest first as the server lists them
|
||||||
|
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
|
||||||
|
|
||||||
|
int ShownCount => announcements.Count(a => a.Read != true);
|
||||||
|
|
||||||
|
// the one in view, or none
|
||||||
|
Announcement Current => Shown is [_, ..] shown ? shown[Math.Clamp(index, 0, shown.Count - 1)] : null;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Streaming.Announced += OnAnnounced;
|
||||||
|
|
||||||
|
// another persona reads its own: its dismissals and reactions
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
announcements = await AnnouncementService.List();
|
||||||
|
index = 0;
|
||||||
|
await Streaming.HearAnnouncements(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
string When(Announcement announcement)
|
||||||
|
{
|
||||||
|
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
|
||||||
|
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
|
||||||
|
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
|
||||||
|
return (starts, ends) switch
|
||||||
|
{
|
||||||
|
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
|
||||||
|
({ } from, null) => Localizer["From {0}", from],
|
||||||
|
_ => Localizer["until {0}", ends]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Dismiss()
|
||||||
|
{
|
||||||
|
if (Current is not { } announcement)
|
||||||
|
return;
|
||||||
|
isActing = true;
|
||||||
|
var result = await AnnouncementService.Dismiss(announcement.Id);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
announcement.Read = true;
|
||||||
|
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the counts are read again afterwards: the stream may have told of this very reaction already
|
||||||
|
async Task React(string emoji, bool add)
|
||||||
|
{
|
||||||
|
if (Current is not { } announcement)
|
||||||
|
return;
|
||||||
|
isActing = true;
|
||||||
|
var result = await AnnouncementService.React(announcement.Id, emoji, add);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
isActing = false;
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var position = index;
|
||||||
|
announcements = await AnnouncementService.List();
|
||||||
|
index = Math.Clamp(position, 0, Math.Max(0, ShownCount - 1));
|
||||||
|
isActing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnAnnounced(AnnouncementMessage message) => InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
switch (message.Event)
|
||||||
|
{
|
||||||
|
case "announcement" when message.Announcement is { } changed:
|
||||||
|
var at = announcements.FindIndex(a => a.Id == changed.Id);
|
||||||
|
if (at < 0)
|
||||||
|
announcements.Add(changed);
|
||||||
|
else
|
||||||
|
announcements[at] = changed;
|
||||||
|
break;
|
||||||
|
case "announcement.delete" when message.DeletedId is { } id:
|
||||||
|
announcements.RemoveAll(a => a.Id == id);
|
||||||
|
break;
|
||||||
|
case "announcement.reaction" when message.Reaction is { } reaction && announcements.Find(a => a.Id == reaction.AnnouncementId) is { } reacted:
|
||||||
|
var known = reacted.Reactions.Find(r => r.Name == reaction.Name);
|
||||||
|
reacted.Reactions = reaction.Count <= 0
|
||||||
|
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
|
||||||
|
: known is null
|
||||||
|
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
|
||||||
|
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Streaming.Announced -= OnAnnounced;
|
||||||
|
await Streaming.HearAnnouncements(false);
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
+475
-60
@@ -1,8 +1,14 @@
|
|||||||
|
@using System.Text.RegularExpressions
|
||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
|
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
|
||||||
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
||||||
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. *@
|
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
|
||||||
|
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
|
||||||
|
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
|
||||||
|
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
|
||||||
|
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
|
||||||
|
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
|
||||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@@ -13,38 +19,128 @@
|
|||||||
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
||||||
</DInput>
|
</DInput>
|
||||||
}
|
}
|
||||||
<div class="relative">
|
<div class="flex flex-col gap-1">
|
||||||
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
|
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||||
placeholder="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
|
placeholder="@Placeholder" rows="3" data-compose />
|
||||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
@* under the words, not over their last line *@
|
||||||
|
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||||
|
@foreach (var account in suggestedAccounts)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||||
|
@* alt="": the handle is next to it. *@
|
||||||
|
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="truncate">@("@" + account.Acct)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@foreach (var tag in suggestedTags)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Quoting is not null)
|
||||||
|
{
|
||||||
|
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (form.HasPoll)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
|
||||||
|
@for (var i = 0; i < form.PollOptions.Count; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
|
||||||
|
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
|
||||||
|
@if (form.PollOptions.Count > 2)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if (form.PollOptions.Count < 4)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
|
||||||
|
}
|
||||||
|
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
|
||||||
|
@foreach (var (seconds, label) in PollLengths)
|
||||||
|
{
|
||||||
|
<option value="@seconds">@label</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
<label class="label text-xs">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
|
||||||
|
<span>@Localizer["Several choices"]</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (form.Media.Count > 0)
|
@if (form.Media.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2" data-compose-media>
|
||||||
@foreach (var media in form.Media)
|
@foreach (var media in form.Media)
|
||||||
{
|
{
|
||||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
|
||||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
@if (media.Preview is { Length: > 0 } preview)
|
||||||
{
|
{
|
||||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
|
||||||
}
|
}
|
||||||
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
<p class="inline-flex items-center gap-2 text-xs">
|
||||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
@switch (media.State)
|
||||||
|
{
|
||||||
|
case UploadState.Uploading:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Processing:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Failed:
|
||||||
|
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
|
||||||
|
@if (media.File is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
|
||||||
|
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
||||||
|
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||||
</div>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]"
|
||||||
|
data-upload-remove>
|
||||||
<i class="ion-md-trash text-base text-error"></i>
|
<i class="ion-md-trash text-base text-error"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (AskingAltText)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
|
||||||
|
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
|
||||||
|
</div>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (form.IsLocated)
|
@if (form.IsLocated)
|
||||||
@@ -69,34 +165,47 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if (!form.IsLocated && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
{
|
{
|
||||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
|
||||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]"
|
||||||
|
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
|
||||||
<Trigger>
|
<Trigger>
|
||||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
|
<i class="@form.Visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@foreach (var messageType in Enum.GetValues<MessageType>())
|
@foreach (var visibility in Enum.GetValues<Visibility>())
|
||||||
{
|
{
|
||||||
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.Visibility == visibility" OnClick="() => SetVisibility(visibility)">
|
||||||
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
<i class="@visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer[visibility.ToString()]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DDropdown>
|
</DDropdown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
|
{
|
||||||
|
@* the language the post is written in, as each language names itself *@
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-auto" @bind-Value="form.Language" title="@Localizer["Language"]" data-compose-language>
|
||||||
|
@foreach (var (languageCode, languageName) in Languages.All)
|
||||||
|
{
|
||||||
|
<option value="@languageCode">@languageName</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
}
|
||||||
|
|
||||||
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
|
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
|
||||||
<i class="ion-md-warning text-base"></i>
|
<i class="ion-md-warning text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
|
||||||
@if (!form.IsLocated && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||||
{
|
{
|
||||||
@* A label styled as a button opens the hidden file input. *@
|
@* A label styled as a button opens the hidden file input. *@
|
||||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||||
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||||
<i class="ion-md-attach text-base"></i>
|
<i class="ion-md-attach text-base"></i>
|
||||||
@if (form.Media.Count != 0)
|
@if (form.Media.Count != 0)
|
||||||
{
|
{
|
||||||
@@ -107,7 +216,7 @@
|
|||||||
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
|
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
|
||||||
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
|
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
|
@foreach (var mediaType in Enum.GetValues<MediaType>())
|
||||||
{
|
{
|
||||||
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
|
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
|
||||||
}
|
}
|
||||||
@@ -115,12 +224,26 @@
|
|||||||
</DSelect>
|
</DSelect>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (InReplyTo is null && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
|
||||||
|
<i class="ion-md-stats text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
|
||||||
{
|
{
|
||||||
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
||||||
<i class="ion-md-locate text-base"></i>
|
<i class="ion-md-locate text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
|
||||||
|
<i class="ion-md-time text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
||||||
@@ -128,14 +251,28 @@
|
|||||||
{
|
{
|
||||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
}
|
}
|
||||||
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
<span>@SubmitLabel</span>
|
||||||
<i class="ion-md-send"></i>
|
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span>@Localizer["Publish it on"]</span>
|
||||||
|
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
|
||||||
|
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
|
||||||
|
</label>
|
||||||
|
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
@@ -143,48 +280,182 @@
|
|||||||
[Parameter] public Status InReplyTo { get; set; }
|
[Parameter] public Status InReplyTo { get; set; }
|
||||||
/// <summary>The community or circle the post is for.</summary>
|
/// <summary>The community or circle the post is for.</summary>
|
||||||
[Parameter] public string GroupId { get; set; }
|
[Parameter] public string GroupId { get; set; }
|
||||||
|
/// <summary>The post this one quotes (shown under the words).</summary>
|
||||||
|
[Parameter] public Status Quoting { get; set; }
|
||||||
|
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
|
||||||
|
[Parameter] public Status Editing { get; set; }
|
||||||
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
|
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
|
||||||
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
|
||||||
|
|
||||||
ComposeForm form = new();
|
ComposeForm form = new();
|
||||||
List<string> fileErrors = [];
|
List<string> fileErrors = [];
|
||||||
|
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
|
||||||
|
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
|
||||||
|
List<Account> suggestedAccounts = [];
|
||||||
|
List<StatusTag> suggestedTags = [];
|
||||||
|
// the newest lookup: an older one's answer comes too late to show
|
||||||
|
int suggesting;
|
||||||
string error;
|
string error;
|
||||||
bool isPublishing;
|
bool isPublishing;
|
||||||
|
|
||||||
int ContentLength => form.Content?.Length ?? 0;
|
int ContentLength => form.Content?.Length ?? 0;
|
||||||
|
|
||||||
|
// what is being written, once typing pauses, among the accounts and hashtags this server knows
|
||||||
|
async Task Typed()
|
||||||
|
{
|
||||||
|
var typed = Typing.Match(form.Content ?? "");
|
||||||
|
var lookup = ++suggesting;
|
||||||
|
if (!typed.Success || typed.Groups[2].Length < 2)
|
||||||
|
{
|
||||||
|
suggestedAccounts = [];
|
||||||
|
suggestedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Task.Delay(300);
|
||||||
|
if (lookup != suggesting)
|
||||||
|
return;
|
||||||
|
var hashtags = typed.Groups[1].Value == "#";
|
||||||
|
var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
|
||||||
|
if (lookup != suggesting)
|
||||||
|
return;
|
||||||
|
suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
|
||||||
|
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// the mention or hashtag being written, written out
|
||||||
|
void Complete(string written)
|
||||||
|
{
|
||||||
|
var typed = Typing.Match(form.Content ?? "");
|
||||||
|
suggesting++;
|
||||||
|
suggestedAccounts = [];
|
||||||
|
suggestedTags = [];
|
||||||
|
if (!typed.Success)
|
||||||
|
return;
|
||||||
|
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||||
|
focusPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||||
|
: Quoting is not null ? Localizer["Say something about it"]
|
||||||
|
: InReplyTo is null ? Localizer["What's happening?"]
|
||||||
|
: Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct];
|
||||||
|
|
||||||
|
string SubmitLabel => Editing is not null ? Localizer["Save"]
|
||||||
|
: form.IsScheduled ? Localizer["Schedule"]
|
||||||
|
: Quoting is not null ? Localizer["Quote"]
|
||||||
|
: Localizer[InReplyTo is null ? "Post" : "Reply"];
|
||||||
|
|
||||||
|
(int Seconds, string Label)[] PollLengths =>
|
||||||
|
[
|
||||||
|
(300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]),
|
||||||
|
(86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"])
|
||||||
|
];
|
||||||
|
|
||||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||||
|
|
||||||
long MaximumFileSize => form.MediaType switch
|
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
|
||||||
|
MediaConfiguration limits = new();
|
||||||
|
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
|
||||||
|
bool askingAltText;
|
||||||
|
bool altTextConfirmed;
|
||||||
|
|
||||||
|
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
|
||||||
|
|
||||||
|
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
|
||||||
|
|
||||||
|
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
|
||||||
|
|
||||||
|
bool OfKind(string type) => form.MediaType switch
|
||||||
{
|
{
|
||||||
MediaType.Images => 16_777_216,
|
MediaType.Images => type.StartsWith("image/"),
|
||||||
MediaType.Audio => 41_943_040,
|
MediaType.Video => type.StartsWith("video/"),
|
||||||
_ => 99_614_720
|
_ => type.StartsWith("audio/")
|
||||||
};
|
};
|
||||||
|
|
||||||
string AcceptedFileTypes => form.MediaType switch
|
// the server's types of the kind chosen, and the extensions a file picker may know them by
|
||||||
|
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
|
||||||
|
|
||||||
|
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||||
_ => default
|
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override void OnInitialized()
|
// what a file is: the browser's word, else its extension's
|
||||||
|
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
|
||||||
|
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
|
||||||
|
|
||||||
|
// a new post starts in the language the persona chose in Settings, else in the reader's
|
||||||
|
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||||
|
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
|
|
||||||
|
// a new post starts with the visibility the persona chose in Settings
|
||||||
|
Visibility DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => Visibility.FollowersOnly,
|
||||||
|
"unlisted" => Visibility.Unlisted,
|
||||||
|
_ => Visibility.Public
|
||||||
|
};
|
||||||
|
|
||||||
|
DTextarea<string> textarea;
|
||||||
|
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||||
|
bool focusPending = true;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!focusPending)
|
||||||
|
return;
|
||||||
|
focusPending = false;
|
||||||
|
await textarea.Element.FocusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
IsLoading = false;
|
|
||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
|
form.Visibility = DefaultVisibility;
|
||||||
|
form.Language = DefaultLanguage;
|
||||||
|
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||||
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
|
limits = await MediaService.Limits();
|
||||||
|
if (Editing is not null)
|
||||||
|
{
|
||||||
|
var source = await StatusService.Source(Editing.Id);
|
||||||
|
form.Content = source?.Text;
|
||||||
|
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||||
|
form.SpoilerText = source?.SpoilerText;
|
||||||
|
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
|
||||||
|
form.Language = Editing.Language ?? form.Language;
|
||||||
|
form.Sensitive = Editing.Sensitive;
|
||||||
|
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
|
||||||
|
{
|
||||||
|
Id = m.Id,
|
||||||
|
State = UploadState.Ready,
|
||||||
|
Posted = true,
|
||||||
|
AltText = m.Description,
|
||||||
|
SentAltText = m.Description,
|
||||||
|
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
|
||||||
|
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
|
||||||
|
FileName = Path.GetFileName(m.Url ?? string.Empty)
|
||||||
|
})];
|
||||||
|
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
|
||||||
|
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
|
||||||
|
}
|
||||||
|
IsLoading = false;
|
||||||
if (InReplyTo is null)
|
if (InReplyTo is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
form.InReplyToId = InReplyTo.Id;
|
form.InReplyToId = InReplyTo.Id;
|
||||||
form.MessageType = InReplyTo.Visibility switch
|
form.Visibility = InReplyTo.Visibility switch
|
||||||
{
|
{
|
||||||
"direct" => MessageType.Direct,
|
"direct" => Visibility.Direct,
|
||||||
"private" => MessageType.FollowersOnly,
|
"private" => Visibility.FollowersOnly,
|
||||||
"unlisted" => MessageType.Unlisted,
|
"unlisted" => Visibility.Unlisted,
|
||||||
_ => MessageType.Public
|
_ => Visibility.Public
|
||||||
};
|
};
|
||||||
|
// a reply is written in its thread's language, as a rule
|
||||||
|
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
|
||||||
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
|
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
|
||||||
form.SpoilerText = InReplyTo.SpoilerText;
|
form.SpoilerText = InReplyTo.SpoilerText;
|
||||||
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
|
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
|
||||||
@@ -192,12 +463,20 @@
|
|||||||
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
|
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetMessageType(MessageType messageType)
|
void SetVisibility(Visibility visibility)
|
||||||
{
|
{
|
||||||
form.MessageType = messageType;
|
form.Visibility = visibility;
|
||||||
form.IsScopeOptionsOpen = false;
|
form.IsScopeOptionsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a scheduled post starts an hour ahead, which the reader moves as it likes
|
||||||
|
void ToggleScheduled()
|
||||||
|
{
|
||||||
|
form.IsScheduled = !form.IsScheduled;
|
||||||
|
if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt))
|
||||||
|
form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
async Task ToggleLocated()
|
async Task ToggleLocated()
|
||||||
{
|
{
|
||||||
form.IsLocated = !form.IsLocated;
|
form.IsLocated = !form.IsLocated;
|
||||||
@@ -227,40 +506,126 @@
|
|||||||
|
|
||||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||||
{
|
{
|
||||||
if (file.Size > MaximumFileSize)
|
var type = TypeOf(file);
|
||||||
|
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
|
||||||
|
{
|
||||||
|
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.Size > MaximumFileSize(type))
|
||||||
{
|
{
|
||||||
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ReadFile(file) is { } media)
|
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
|
||||||
|
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
|
||||||
form.Media.Add(media);
|
form.Media.Add(media);
|
||||||
else
|
altTextConfirmed = false;
|
||||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
Start(media);
|
||||||
|
if (type.StartsWith("image/"))
|
||||||
|
media.Preview = await Thumbnail(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
async Task<string> Thumbnail(IBrowserFile file)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
using var buffer = new MemoryStream();
|
||||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
await stream.CopyToAsync(buffer);
|
||||||
if (file.ContentType.StartsWith("image"))
|
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
|
||||||
return media;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Start(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel = new();
|
||||||
|
media.State = UploadState.Uploading;
|
||||||
|
media.Error = default;
|
||||||
|
media.Done = Upload(media, media.Cancel.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
|
||||||
|
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (uploaded.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = uploaded.ErrorMessage;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var attachment = (MediaAttachment)uploaded.Data;
|
||||||
|
media.Id = attachment.Id;
|
||||||
|
media.SentAltText = attachment.Description;
|
||||||
|
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var processed = await MediaService.Get(media.Id, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (processed.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = processed.ErrorMessage;
|
||||||
|
}
|
||||||
|
else if (processed.Data is MediaAttachment)
|
||||||
|
media.State = UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
||||||
|
void Retry(UploadMedia media) => Start(media);
|
||||||
|
|
||||||
|
// a file taken out before posting goes from PrivaPub too (DELETE /api/v1/media/:id), unless a post holds it already: an
|
||||||
|
// edit's own media stay its post's. A failure is only logged; the upload stays unattached on PrivaPub.
|
||||||
|
async Task Remove(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
form.Media.Remove(media);
|
||||||
|
if (media.Posted || media.Id is not { } mediaId)
|
||||||
|
return;
|
||||||
|
await MediaService.Discard(mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the reader chose to publish its pictures without descriptions
|
||||||
|
async Task PublishAnyway()
|
||||||
|
{
|
||||||
|
altTextConfirmed = true;
|
||||||
|
askingAltText = false;
|
||||||
|
await Publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media)
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
async Task Publish()
|
async Task Publish()
|
||||||
{
|
{
|
||||||
error = default;
|
error = default;
|
||||||
@@ -269,12 +634,54 @@
|
|||||||
error = Localizer["Missing content, either message or media"];
|
error = Localizer["Missing content, either message or media"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2)
|
||||||
|
{
|
||||||
|
error = Localizer["A poll needs two choices at least."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
|
||||||
|
{
|
||||||
|
askingAltText = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// what is still uploading or processing is waited for
|
||||||
|
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
|
||||||
|
{
|
||||||
|
isPublishing = true;
|
||||||
|
await Task.WhenAll(form.Media.Select(m => m.Done));
|
||||||
|
isPublishing = false;
|
||||||
|
}
|
||||||
|
if (Editing is not null)
|
||||||
|
{
|
||||||
|
isPublishing = true;
|
||||||
|
var edited = await StatusService.Edit(Editing.Id, form);
|
||||||
|
isPublishing = false;
|
||||||
|
if (edited.IsInvalid)
|
||||||
|
{
|
||||||
|
error = edited.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (CascadingState.PersonaId is null)
|
if (CascadingState.PersonaId is null)
|
||||||
{
|
{
|
||||||
error = Localizer["Choose a persona first."];
|
error = Localizer["Choose a persona first."];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.IsScheduled && form.ScheduledAtUtc is not { } at)
|
||||||
|
{
|
||||||
|
error = Localizer["Choose when to publish it."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5))
|
||||||
|
{
|
||||||
|
error = Localizer["A post is scheduled at least five minutes ahead."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isPublishing = true;
|
isPublishing = true;
|
||||||
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||||||
isPublishing = false;
|
isPublishing = false;
|
||||||
@@ -284,9 +691,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.Data is ScheduledStatus scheduled)
|
||||||
|
{
|
||||||
|
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||||
|
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||||
|
await OnPublished.InvokeAsync(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||||
var published = result.Data as FeedPost;
|
var published = result.Data as FeedPost;
|
||||||
form = new() { GroupId = GroupId };
|
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||||
await OnPublished.InvokeAsync(published);
|
await OnPublished.InvokeAsync(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
|
||||||
|
dialog in the middle from md. Closing it in any way answers no. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
|
||||||
|
<Heading>@Confirm.Heading</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (Confirm.Text is { Length: > 0 } text)
|
||||||
|
{
|
||||||
|
<p class="text-sm">@text</p>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
|
||||||
|
@Confirm.ConfirmText
|
||||||
|
</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Confirm.Changed += Refresh;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Refresh() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
void Closed(bool open)
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
Confirm.Answer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Confirm.Changed -= Refresh;
|
||||||
|
}
|
||||||
+886
-63
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,8 @@
|
|||||||
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
||||||
@if (Href is null)
|
@if (Href is null)
|
||||||
{
|
{
|
||||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
|
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
|
||||||
|
@onmousedown:preventDefault="KeepFocus">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,8 @@ else
|
|||||||
[Parameter] public string Type { get; set; } = "button";
|
[Parameter] public string Type { get; set; } = "button";
|
||||||
[Parameter] public string Href { get; set; }
|
[Parameter] public string Href { get; set; }
|
||||||
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
||||||
|
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
|
||||||
|
[Parameter] public bool KeepFocus { get; set; }
|
||||||
|
|
||||||
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
||||||
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
@inherits DComponentBase
|
@inherits DComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||||
too. With Hover the CSS hover rule opens it instead. *@
|
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
|
||||||
|
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||||
|
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||||
@onclick="Toggle">
|
@onclick="Toggle">
|
||||||
@Trigger
|
@Trigger
|
||||||
</div>
|
</div>
|
||||||
@if (IsOpen)
|
@if (IsOpen)
|
||||||
{
|
{
|
||||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
|
||||||
|
aria-hidden="true" @onclick="Close"></div>
|
||||||
}
|
}
|
||||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
|
||||||
|
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +36,21 @@
|
|||||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||||
[Parameter] public string TriggerClass { get; set; }
|
[Parameter] public string TriggerClass { get; set; }
|
||||||
[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>
|
||||||
|
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||||
|
|
||||||
|
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
|
||||||
|
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
|
||||||
|
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
|
||||||
|
|
||||||
|
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||||
|
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||||
|
|
||||||
|
ElementReference trigger;
|
||||||
|
ElementReference content;
|
||||||
|
IDisposable overlay;
|
||||||
|
bool focusContent;
|
||||||
|
bool focusTrigger;
|
||||||
|
|
||||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||||
@@ -50,17 +70,54 @@
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
Task Toggle() => SetOpen(!IsOpen);
|
protected override void OnParametersSet() => TrackOverlay();
|
||||||
|
|
||||||
Task Close() => SetOpen(false);
|
void TrackOverlay()
|
||||||
|
|
||||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
async Task SetOpen(bool isOpen)
|
|
||||||
{
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await SetOpen(false, true);
|
||||||
|
StateHasChanged();
|
||||||
|
}));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => overlay?.Dispose();
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (focusContent)
|
||||||
|
{
|
||||||
|
focusContent = false;
|
||||||
|
await content.FocusAsync();
|
||||||
|
}
|
||||||
|
if (focusTrigger)
|
||||||
|
{
|
||||||
|
focusTrigger = false;
|
||||||
|
await trigger.FocusAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Toggle() => SetOpen(!IsOpen, IsOpen);
|
||||||
|
|
||||||
|
Task Close() => SetOpen(false, true);
|
||||||
|
|
||||||
|
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
|
||||||
|
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
|
||||||
|
|
||||||
|
async Task SetOpen(bool isOpen, bool giveFocusBack)
|
||||||
|
{
|
||||||
|
focusContent = isOpen && !IsOpen;
|
||||||
|
focusTrigger = !isOpen && IsOpen && giveFocusBack;
|
||||||
IsOpen = isOpen;
|
IsOpen = isOpen;
|
||||||
|
TrackOverlay();
|
||||||
await IsOpenChanged.InvokeAsync(isOpen);
|
await IsOpenChanged.InvokeAsync(isOpen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
@inherits DComponentBase
|
@inherits DComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
||||||
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
||||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
|
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
|
||||||
|
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
|
||||||
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
||||||
class="@CssClass" @onkeydown="OnKeyDown">
|
class="@CssClass" @onkeydown="OnKeyDown">
|
||||||
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
||||||
|
@if (Heading is not null || ShowClose)
|
||||||
|
{
|
||||||
|
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||||
@if (Heading is not null)
|
@if (Heading is not null)
|
||||||
{
|
{
|
||||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
}
|
||||||
|
@if (ShowClose)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
|
||||||
|
@onclick="Close" data-modal-close>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@ChildContent
|
@ChildContent
|
||||||
@if (Actions is not null)
|
@if (Actions is not null)
|
||||||
@@ -35,11 +53,17 @@
|
|||||||
[Parameter] public DModalPlacement Placement { get; set; }
|
[Parameter] public DModalPlacement Placement { get; set; }
|
||||||
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
||||||
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
||||||
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
|
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
|
||||||
[Parameter] public string CloseText { get; set; } = "close";
|
[Parameter] public string CloseText { get; set; } = "close";
|
||||||
|
/// <summary>A visible close button beside the heading.</summary>
|
||||||
|
[Parameter] public bool ShowClose { get; set; } = true;
|
||||||
[Parameter] public string BoxClass { get; set; }
|
[Parameter] public string BoxClass { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||||
|
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||||
|
|
||||||
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
IDisposable overlay;
|
||||||
ElementReference box;
|
ElementReference box;
|
||||||
bool wasOpen;
|
bool wasOpen;
|
||||||
bool focusPending;
|
bool focusPending;
|
||||||
@@ -52,6 +76,7 @@
|
|||||||
DModalPlacement.Bottom => "modal-bottom",
|
DModalPlacement.Bottom => "modal-bottom",
|
||||||
DModalPlacement.Start => "modal-start",
|
DModalPlacement.Start => "modal-start",
|
||||||
DModalPlacement.End => "modal-end",
|
DModalPlacement.End => "modal-end",
|
||||||
|
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +85,26 @@
|
|||||||
if (IsOpen && !wasOpen)
|
if (IsOpen && !wasOpen)
|
||||||
focusPending = true;
|
focusPending = true;
|
||||||
wasOpen = IsOpen;
|
wasOpen = IsOpen;
|
||||||
|
TrackOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TrackOverlay()
|
||||||
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await SetOpen(false);
|
||||||
|
StateHasChanged();
|
||||||
|
}));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => overlay?.Dispose();
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!focusPending)
|
if (!focusPending)
|
||||||
@@ -78,6 +121,7 @@
|
|||||||
{
|
{
|
||||||
IsOpen = isOpen;
|
IsOpen = isOpen;
|
||||||
wasOpen = isOpen;
|
wasOpen = isOpen;
|
||||||
|
TrackOverlay();
|
||||||
await IsOpenChanged.InvokeAsync(isOpen);
|
await IsOpenChanged.InvokeAsync(isOpen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,7 @@ public enum DModalPlacement
|
|||||||
Top,
|
Top,
|
||||||
Bottom,
|
Bottom,
|
||||||
Start,
|
Start,
|
||||||
End
|
End,
|
||||||
|
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
|
||||||
|
ResponsiveBottom
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
|
using decePubClient.Resources;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Forms;
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ public abstract class DInputBase<TValue> : InputBase<TValue>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not valid.";
|
validationErrorMessage = string.Format(ErrorMessages.InvalidValue, DisplayName ?? FieldIdentifier.FieldName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
||||||
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
||||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96). *@
|
are WrapperClass (pinned rows need it to have a height, e.g. h-96); it is marked as a strip that scrolls sideways on
|
||||||
|
purpose (data-scroll-x), which the phone tests allow. *@
|
||||||
@if (Scroll)
|
@if (Scroll)
|
||||||
{
|
{
|
||||||
<div class="@Cx("overflow-x-auto", WrapperClass)">
|
<div class="@Cx("overflow-x-auto", WrapperClass)" data-scroll-x>
|
||||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
result = date;
|
result = date;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not a valid date.";
|
validationErrorMessage = string.Format(ErrorMessages.InvalidDate, DisplayName ?? FieldIdentifier.FieldName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
|
|
||||||
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
||||||
value is the content; rows, placeholder and the like pass through. *@
|
value is the content; rows, placeholder and the like pass through. *@
|
||||||
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||||
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>The textarea itself, for focusing it.</summary>
|
||||||
|
public ElementReference Element { get; private set; }
|
||||||
|
|
||||||
[Parameter] public DColor Color { get; set; }
|
[Parameter] public DColor Color { get; set; }
|
||||||
[Parameter] public DSize Size { get; set; }
|
[Parameter] public DSize Size { get; set; }
|
||||||
[Parameter] public bool Ghost { get; set; }
|
[Parameter] public bool Ghost { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace decePubClient.Components.Daisy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
|
||||||
|
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
|
||||||
|
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDOverlayHost
|
||||||
|
{
|
||||||
|
IDisposable Open(Func<Task> close);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
[Parameter] public bool Disabled { get; set; }
|
[Parameter] public bool Disabled { get; set; }
|
||||||
[Parameter] public string Class { get; set; }
|
[Parameter] public string Class { get; set; }
|
||||||
|
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
|
||||||
|
[Parameter] public string AccessibleName { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
var index = i;
|
var index = i;
|
||||||
var tab = tabs[i];
|
var tab = tabs[i];
|
||||||
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
||||||
|
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
|
||||||
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
||||||
@onclick="() => Select(index)">
|
@onclick="() => Select(index)">
|
||||||
@tab.Header
|
@tab.Header
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
||||||
persona's communities and circles, Tag the hashtag being followed. The stats icon shows how the current feed was
|
persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being
|
||||||
|
read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was
|
||||||
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
|
||||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
tabs wait; from md only the selected tab shows its name until xl *@
|
||||||
|
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
|
||||||
|
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||||
|
aria-label="@Localizer["Feeds"]">
|
||||||
@foreach (var type in Types)
|
@foreach (var type in Types)
|
||||||
{
|
{
|
||||||
<DTab Class="gap-1 whitespace-nowrap">
|
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
|
||||||
<Header>
|
<Header>
|
||||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||||
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
|
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
|
||||||
|
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
||||||
|
}
|
||||||
|
else if (type is TimelineType.Direct && UnreadConversations > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadConversations]" data-unread-conversations>@UnreadConversations</DBadge>
|
||||||
|
}
|
||||||
</Header>
|
</Header>
|
||||||
</DTab>
|
</DTab>
|
||||||
}
|
}
|
||||||
@@ -21,10 +33,15 @@
|
|||||||
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||||
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
||||||
</NerdTip>
|
</NerdTip>
|
||||||
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||||
<i class="ion-md-planet text-base"></i>
|
<i class="ion-md-planet text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
@* a phone held sideways has no room for the banner: the globe opens full screen *@
|
||||||
|
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
|
||||||
|
data-globe-full>
|
||||||
|
<i class="ion-md-planet text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||||
<i class="ion-md-create text-base"></i>
|
<i class="ion-md-create text-base"></i>
|
||||||
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
@@ -49,35 +66,116 @@
|
|||||||
</DSelect>
|
</DSelect>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (Filter.TimelineType is TimelineType.Lists)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if (Lists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.ListId" ValueChanged="SelectList" ValueExpression="() => Filter.ListId">
|
||||||
|
<Prefix><i class="ion-md-list"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var list in Lists)
|
||||||
|
{
|
||||||
|
<option value="@list.Id">@list.Title</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
else if (Filter.TimelineType is TimelineType.Tag)
|
else if (Filter.TimelineType is TimelineType.Tag)
|
||||||
{
|
{
|
||||||
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<i class="ion-md-pricetag"></i><b>#@Filter.Tag</b>
|
||||||
|
@if (tag is not null)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts]</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(tag.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isFollowing)"
|
||||||
|
OnClick="ToggleTagFollow" data-tag-follow>
|
||||||
|
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
||||||
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
||||||
|
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
||||||
|
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
|
||||||
|
[Parameter] public int UnreadNotifications { get; set; }
|
||||||
|
/// <summary>How many of the newest direct conversations hold a message the persona has not read.</summary>
|
||||||
|
[Parameter] public int UnreadConversations { get; set; }
|
||||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||||
[Parameter] public bool IsComposing { get; set; }
|
[Parameter] public bool IsComposing { get; set; }
|
||||||
[Parameter] public EventCallback OnCompose { get; set; }
|
[Parameter] public EventCallback OnCompose { get; set; }
|
||||||
|
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
|
||||||
|
[Parameter] public EventCallback OnGlobeFull { get; set; }
|
||||||
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
||||||
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
||||||
|
|
||||||
static readonly TimelineType[] Always =
|
static readonly TimelineType[] Always =
|
||||||
[
|
[
|
||||||
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
||||||
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks
|
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks, TimelineType.Lists
|
||||||
];
|
];
|
||||||
|
|
||||||
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
||||||
|
|
||||||
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
||||||
|
|
||||||
|
ElementReference strip;
|
||||||
|
int revealed = -1;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (ActiveIndex == revealed)
|
||||||
|
return;
|
||||||
|
revealed = ActiveIndex;
|
||||||
|
CascadingState.Status.RevealActive(strip);
|
||||||
|
}
|
||||||
|
|
||||||
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
||||||
|
|
||||||
|
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||||
|
|
||||||
|
Tag tag;
|
||||||
|
string loadedTag;
|
||||||
|
bool isFollowing;
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
// the hashtag being read: its week and whether the persona follows it, read again when it changes
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
var wanted = Filter.TimelineType is TimelineType.Tag ? Filter.Tag : null;
|
||||||
|
if (wanted == loadedTag)
|
||||||
|
return;
|
||||||
|
loadedTag = wanted;
|
||||||
|
tag = wanted is { Length: > 0 } ? await DiscoveryService.Tag(wanted) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleTagFollow()
|
||||||
|
{
|
||||||
|
isFollowing = true;
|
||||||
|
var result = await DiscoveryService.FollowTag(tag.Name, tag.Following != true);
|
||||||
|
isFollowing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = (Tag)result.Data;
|
||||||
|
Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]);
|
||||||
|
}
|
||||||
|
|
||||||
string TabName(TimelineType type) => type switch
|
string TabName(TimelineType type) => type switch
|
||||||
{
|
{
|
||||||
TimelineType.Federation => Localizer["Federated"],
|
TimelineType.Federation => Localizer["Federated"],
|
||||||
@@ -90,6 +188,14 @@
|
|||||||
Filter.TimelineType = Types[index];
|
Filter.TimelineType = Types[index];
|
||||||
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
||||||
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
||||||
|
if (Filter.TimelineType is TimelineType.Lists && Lists.All(l => l.Id != Filter.ListId))
|
||||||
|
Filter.ListId = Lists.FirstOrDefault()?.Id;
|
||||||
|
await OnChanged.InvokeAsync(Filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SelectList(string listId)
|
||||||
|
{
|
||||||
|
Filter.ListId = listId;
|
||||||
await OnChanged.InvokeAsync(Filter);
|
await OnChanged.InvokeAsync(Filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,60 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||||
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
|
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||||
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
|
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
|
||||||
<div class="relative w-full h-full">
|
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
|
||||||
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
|
||||||
|
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
|
||||||
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
|
||||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
|
||||||
<span class="badge badge-ghost badge-xs gap-1">
|
brings the feed back. *@
|
||||||
<i class="ion-md-planet"></i>
|
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||||||
@if (stats is { Error: null } live)
|
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
|
||||||
|
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
|
||||||
|
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
|
||||||
|
<dl id="@($"{uid}-sun")" class="sr-only">
|
||||||
|
@if (sun is not null)
|
||||||
{
|
{
|
||||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
|
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||||
|
{
|
||||||
|
<dt>@row.Label</dt>
|
||||||
|
<dd>@row.Value</dd>
|
||||||
}
|
}
|
||||||
else if (stats?.Error is { Length: > 0 })
|
}
|
||||||
|
</dl>
|
||||||
|
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
|
||||||
|
|
||||||
|
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||||
|
@if (sun is not null)
|
||||||
{
|
{
|
||||||
<span>@Localizer["The globe could not start."]</span>
|
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
|
||||||
|
data-globe-moment>
|
||||||
|
<i class="@SunIcon(sun)" aria-hidden="true"></i>
|
||||||
|
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (error is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Expanded)
|
||||||
|
{
|
||||||
|
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
|
||||||
|
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>@Localizer["loading"]…</span>
|
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
|
||||||
|
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
|
||||||
|
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
|
||||||
</NerdTip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -34,30 +64,104 @@
|
|||||||
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
||||||
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
||||||
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
||||||
|
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
|
||||||
|
[Parameter] public bool Expanded { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||||
|
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
|
||||||
|
ElementReference closeButton;
|
||||||
|
ElementReference expandButton;
|
||||||
|
IDisposable overlay;
|
||||||
|
bool focusClose;
|
||||||
|
bool focusExpand;
|
||||||
|
|
||||||
|
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
|
||||||
|
|
||||||
|
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
|
||||||
|
static readonly string[] InstrumentKeys =
|
||||||
|
[
|
||||||
|
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
|
||||||
|
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
|
||||||
|
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
|
||||||
|
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
|
||||||
|
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
|
||||||
|
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
|
||||||
|
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
|
||||||
|
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
|
||||||
|
];
|
||||||
|
|
||||||
|
async Task Open()
|
||||||
|
{
|
||||||
|
focusClose = true;
|
||||||
|
await ExpandedChanged.InvokeAsync(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Close()
|
||||||
|
{
|
||||||
|
focusExpand = true;
|
||||||
|
await ExpandedChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
|
||||||
|
|
||||||
ElementReference element;
|
ElementReference element;
|
||||||
IJSObjectReference module;
|
IJSObjectReference module;
|
||||||
PeriodicTimer statsTimer;
|
DotNetObjectReference<GlobePanel> reference;
|
||||||
readonly CancellationTokenSource cancellation = new();
|
readonly CancellationTokenSource cancellation = new();
|
||||||
string sentFingerprint;
|
int? sentFingerprint;
|
||||||
GlobeStats stats;
|
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
|
||||||
|
bool postsHandedOver = true;
|
||||||
|
SunStats sun;
|
||||||
|
string error;
|
||||||
|
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
postsHandedOver = true;
|
||||||
|
// full screen, the Back button closes it
|
||||||
|
if (Expanded && overlay is null)
|
||||||
|
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||||
|
else if (!Expanded && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (focusClose && Expanded)
|
||||||
|
{
|
||||||
|
focusClose = false;
|
||||||
|
await closeButton.FocusAsync();
|
||||||
|
}
|
||||||
|
if (focusExpand && !Expanded)
|
||||||
|
{
|
||||||
|
focusExpand = false;
|
||||||
|
await expandButton.FocusAsync(preventScroll: true);
|
||||||
|
}
|
||||||
await base.OnAfterRenderAsync(firstRender);
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||||
await module.InvokeVoidAsync("create", element, ScrollContainerId);
|
reference = DotNetObjectReference.Create(this);
|
||||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
|
||||||
_ = ReadStats(cancellation.Token);
|
{
|
||||||
|
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (postsHandedOver)
|
||||||
|
{
|
||||||
|
postsHandedOver = false;
|
||||||
await SendPosts();
|
await SendPosts();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (JSDisconnectedException)
|
catch (JSDisconnectedException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -75,7 +179,17 @@
|
|||||||
|
|
||||||
var located = Posts.Where(p => p.Location is not null).ToList();
|
var located = Posts.Where(p => p.Location is not null).ToList();
|
||||||
var self = InstanceService.Known(InstanceService.SelfHost);
|
var self = InstanceService.Known(InstanceService.SelfHost);
|
||||||
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}:{p.Location.CdnKey}")) + $"#{self?.Geo?.Latitude}";
|
// a hash, not a string of every place: a feed of thousands is looked at on every page it loads
|
||||||
|
var hash = new HashCode();
|
||||||
|
foreach (var post in located)
|
||||||
|
{
|
||||||
|
hash.Add(post.Key);
|
||||||
|
hash.Add(post.Location.Latitude);
|
||||||
|
hash.Add(post.Location.Longitude);
|
||||||
|
hash.Add(post.Location.CdnKey);
|
||||||
|
}
|
||||||
|
hash.Add(self?.Geo?.Latitude);
|
||||||
|
var fingerprint = hash.ToHashCode();
|
||||||
if (fingerprint == sentFingerprint)
|
if (fingerprint == sentFingerprint)
|
||||||
return;
|
return;
|
||||||
sentFingerprint = fingerprint;
|
sentFingerprint = fingerprint;
|
||||||
@@ -100,15 +214,18 @@
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task ReadStats(CancellationToken cancellationToken)
|
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
|
||||||
|
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task SunChanged()
|
||||||
{
|
{
|
||||||
|
if (module is null)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
|
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
|
||||||
{
|
StateHasChanged();
|
||||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -118,31 +235,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<NerdRow> StatsRows()
|
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public void GlobeFailed(string message)
|
||||||
{
|
{
|
||||||
if (stats is null)
|
error = message;
|
||||||
yield break;
|
StateHasChanged();
|
||||||
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
|
|
||||||
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
|
|
||||||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
|
||||||
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
|
|
||||||
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
|
|
||||||
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
|
|
||||||
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
|
|
||||||
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString());
|
|
||||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
|
||||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
|
|
||||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
|
||||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
|
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||||||
|
{
|
||||||
|
yield return NerdRow.Of(Localizer["moment"], DateTime.Parse(sun.At, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal));
|
||||||
|
yield return NerdRow.Of(Localizer["subsolar point"], $"{Fixed(sun.Latitude)}, {Fixed(sun.Longitude)}");
|
||||||
|
yield return NerdRow.Of(Localizer["declination"], $"{Fixed(sun.Latitude)}°");
|
||||||
|
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)");
|
||||||
|
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)}%)");
|
||||||
|
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);
|
||||||
|
yield return NerdRow.Of(Localizer["seasons"], $"{Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]}");
|
||||||
|
yield return NerdRow.Of(Localizer["next"], sun.NextEvent is { } next ? Localizer["{0} in {1} days", Localizer[EventName(next)], sun.NextEventDays] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Fixed(double? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "–";
|
||||||
|
|
||||||
|
/// <summary>The Sun's moment in the reader's time zone.</summary>
|
||||||
|
static string LocalTime(string at) =>
|
||||||
|
DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time)
|
||||||
|
? time.ToLocalTime().ToString(SUtility.dateTimeFormatExt, CultureInfo.InvariantCulture)
|
||||||
|
: at;
|
||||||
|
|
||||||
|
static string SunIcon(SunStats sun) => sun.Phase switch
|
||||||
|
{
|
||||||
|
"day" => "ion-md-sunny",
|
||||||
|
"golden" => "ion-md-partly-sunny",
|
||||||
|
"civil" or "nautical" or "astronomical" => "ion-md-cloudy-night",
|
||||||
|
"night" => "ion-md-moon",
|
||||||
|
_ => "ion-md-time"
|
||||||
|
};
|
||||||
|
|
||||||
|
string PhaseName(string phase, bool? morning) => phase switch
|
||||||
|
{
|
||||||
|
"day" => Localizer["daylight"],
|
||||||
|
"night" => Localizer["night"],
|
||||||
|
_ => Localizer["{0}, {1}", Localizer[phase switch
|
||||||
|
{
|
||||||
|
"golden" => "golden hour",
|
||||||
|
"civil" => "civil twilight",
|
||||||
|
"nautical" => "nautical twilight",
|
||||||
|
_ => "astronomical twilight"
|
||||||
|
}], Localizer[morning == true ? "morning" : "evening"]]
|
||||||
|
};
|
||||||
|
|
||||||
|
static string SeasonName(string season) => season switch
|
||||||
|
{
|
||||||
|
"spring" => "spring",
|
||||||
|
"summer" => "summer",
|
||||||
|
"autumn" => "autumn",
|
||||||
|
_ => "winter"
|
||||||
|
};
|
||||||
|
|
||||||
|
static string EventName(string next) => next switch
|
||||||
|
{
|
||||||
|
"march-equinox" => "March equinox",
|
||||||
|
"june-solstice" => "June solstice",
|
||||||
|
"september-equinox" => "September equinox",
|
||||||
|
_ => "December solstice"
|
||||||
|
};
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
overlay?.Dispose();
|
||||||
await cancellation.CancelAsync();
|
await cancellation.CancelAsync();
|
||||||
cancellation.Dispose();
|
cancellation.Dispose();
|
||||||
statsTimer?.Dispose();
|
reference?.Dispose();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (module is not null)
|
if (module is not null)
|
||||||
@@ -162,28 +332,30 @@
|
|||||||
|
|
||||||
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||||||
|
|
||||||
sealed class GlobeStats
|
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||||
|
sealed class SunStats
|
||||||
{
|
{
|
||||||
public double? Latitude { get; set; }
|
public string At { get; set; }
|
||||||
public double? Longitude { get; set; }
|
public bool Now { get; set; }
|
||||||
public double? Altitude { get; set; }
|
public double Latitude { get; set; }
|
||||||
public int Visible { get; set; }
|
public double Longitude { get; set; }
|
||||||
public int VisibleLocated { get; set; }
|
public double Distance { get; set; }
|
||||||
public double? CentroidLatitude { get; set; }
|
public double Irradiance { get; set; }
|
||||||
public double? CentroidLongitude { get; set; }
|
public double EclipticLongitude { get; set; }
|
||||||
public double SpreadKm { get; set; }
|
public double Obliquity { get; set; }
|
||||||
public int Points { get; set; }
|
public string Place { get; set; }
|
||||||
public int Posts { get; set; }
|
public double? PlaceLatitude { get; set; }
|
||||||
public int Servers { get; set; }
|
public double? PlaceLongitude { get; set; }
|
||||||
public int CdnGroups { get; set; }
|
public double? Elevation { get; set; }
|
||||||
public int Fps { get; set; }
|
public string Phase { get; set; }
|
||||||
public string Renderer { get; set; }
|
public bool? Morning { get; set; }
|
||||||
public string Texture { get; set; }
|
public string SolarTime { get; set; }
|
||||||
public bool AutoRotate { get; set; }
|
public double? DayLength { get; set; }
|
||||||
public bool Paused { get; set; }
|
public string Season { get; set; }
|
||||||
public string Hovered { get; set; }
|
public string Hemisphere { get; set; }
|
||||||
public int Arcs { get; set; }
|
public string SeasonNorth { get; set; }
|
||||||
public int Rings { get; set; }
|
public string SeasonSouth { get; set; }
|
||||||
public string Error { get; set; }
|
public string NextEvent { get; set; }
|
||||||
|
public int? NextEventDays { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* "Filter this post", from a card's menu: the persona's filters (/api/v2/filters), one of which takes the post (POST
|
||||||
|
/api/v2/filters/:id/statuses); a filter that holds it already says so. Filters are made and changed on /filters. The
|
||||||
|
card reads the post again afterwards, so the filter applies to it at once. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-filter-post-dialog>
|
||||||
|
<Heading>@Localizer["Filter this post"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3">@Localizer["This persona has no filter yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var filter in filters)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start" Disabled="@(IsDefaultDisabled || isSaving || Holds(filter))" OnClick="() => Add(filter)"
|
||||||
|
data-filter-choice="@filter.Id">
|
||||||
|
<i class="ion-md-funnel" aria-hidden="true"></i>
|
||||||
|
<span class="flex-1 min-w-0 truncate text-left">@filter.Title</span>
|
||||||
|
<span class="text-xs opacity-70">@Description(filter)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm justify-start" href="filters" data-filter-post-manage>
|
||||||
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Make a filter"]</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>The post went into a filter.</summary>
|
||||||
|
[Parameter] public EventCallback OnFiltered { get; set; }
|
||||||
|
|
||||||
|
List<Filter> filters = [];
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => filters = await FilterService.All();
|
||||||
|
|
||||||
|
bool Holds(Filter filter) => filter.Statuses.Any(s => s.StatusId == StatusId);
|
||||||
|
|
||||||
|
string Description(Filter filter) => Holds(filter) ? Localizer["Already in it"]
|
||||||
|
: !filter.IsActive ? Localizer["Expired"]
|
||||||
|
: filter.FilterAction switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Hide"],
|
||||||
|
FilterAction.Blur => Localizer["Blur"],
|
||||||
|
_ => Localizer["Warn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Add(Filter filter)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.AddStatus(filter.Id, StatusId);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Added to {0}.", filter.Title]);
|
||||||
|
await OnFiltered.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The accounts waiting for the persona to accept their follow (/api/v1/follow_requests), newest first, each opening its
|
||||||
|
profile, accepted (authorize) or refused (reject) here; "Load more" follows the Link header's next page when PrivaPub
|
||||||
|
sends one. On the persona's own profile (hidden while nobody waits) and in a sheet from the Notifications tab. *@
|
||||||
|
@if (!HideWhenEmpty || IsLoading || requests.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="flex flex-none flex-col gap-2 w-full" data-follow-requests>
|
||||||
|
@if (ShowHeading)
|
||||||
|
{
|
||||||
|
<h2 class="font-semibold text-sm inline-flex items-center gap-2"><i class="ion-md-person-add" aria-hidden="true"></i>@Localizer["Follow requests"]</h2>
|
||||||
|
}
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (requests.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-follow-requests-empty>@Localizer["No follow request is waiting."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in requests)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-follow-request="@account.Acct">
|
||||||
|
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if (answers.TryGetValue(account.Id, out var accepted))
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" data-follow-request-answer="@(accepted ? "accepted" : "refused")">
|
||||||
|
@(accepted ? Localizer["Accepted."] : Localizer["Refused."])
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, true)"
|
||||||
|
data-follow-request-accept>
|
||||||
|
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Accept"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, false)"
|
||||||
|
data-follow-request-reject>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Refuse"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (maxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-follow-requests-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Renders nothing once read with nobody waiting (the persona's own profile).</summary>
|
||||||
|
[Parameter] public bool HideWhenEmpty { get; set; }
|
||||||
|
[Parameter] public bool ShowHeading { get; set; }
|
||||||
|
/// <summary>A request was answered: true when accepted.</summary>
|
||||||
|
[Parameter] public EventCallback<bool> OnAnswered { get; set; }
|
||||||
|
|
||||||
|
List<Account> requests = [];
|
||||||
|
// what was answered here, by account id: accepted (true) or refused
|
||||||
|
Dictionary<string, bool> answers = new();
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string maxId;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isLoadingMore;
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its requests are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
requests = [];
|
||||||
|
answers = new();
|
||||||
|
maxId = null;
|
||||||
|
await LoadPage();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadPage()
|
||||||
|
{
|
||||||
|
var result = await AccountService.FollowRequests(maxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
var known = requests.Select(a => a.Id).ToHashSet();
|
||||||
|
requests.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||||
|
maxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMore()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
await LoadPage();
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(Account account, bool accept)
|
||||||
|
{
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.Answer(account.Id, accept);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
answers[account.Id] = accept;
|
||||||
|
await OnAnswered.InvokeAsync(accept);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
|
||||||
|
have done so, newest first, each opening its profile. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||||
|
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (accounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3">@Localizer["Nobody this server knows of."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||||
|
/// <summary>Who boosted it, rather than who liked it.</summary>
|
||||||
|
[Parameter] public bool Boosts { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
List<Account> accounts = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts);
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
|
||||||
|
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
|
||||||
|
keys), and the original in a new tab for whoever wants the file. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||||
|
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
|
||||||
|
<Heading>
|
||||||
|
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
|
||||||
|
</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
|
||||||
|
<figure class="flex flex-col gap-2 min-h-0">
|
||||||
|
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
|
||||||
|
data-blurhash="@Current.Blurhash" />
|
||||||
|
@if (Current.Description is { Length: > 0 } description)
|
||||||
|
{
|
||||||
|
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
|
||||||
|
}
|
||||||
|
</figure>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
@if (Pictures.Count > 1)
|
||||||
|
{
|
||||||
|
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
|
||||||
|
title="@Localizer["Previous picture"]" data-picture-previous>
|
||||||
|
<i class="ion-md-arrow-back" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
|
||||||
|
<i class="ion-md-open" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["Open original"]</span>
|
||||||
|
</a>
|
||||||
|
@if (Pictures.Count > 1)
|
||||||
|
{
|
||||||
|
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
|
||||||
|
title="@Localizer["Next picture"]" data-picture-next>
|
||||||
|
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The post's pictures, in its order.</summary>
|
||||||
|
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
|
||||||
|
/// <summary>The one opened.</summary>
|
||||||
|
[Parameter] public int Start { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
int index;
|
||||||
|
|
||||||
|
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Previous() => index = Math.Max(0, index - 1);
|
||||||
|
|
||||||
|
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
|
||||||
|
|
||||||
|
void OnKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "ArrowLeft")
|
||||||
|
Previous();
|
||||||
|
else if (e.Key == "ArrowRight")
|
||||||
|
Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
|
||||||
|
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
|
||||||
|
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
|
||||||
|
@if (Heading is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<p class="font-bold opacity-80">@Heading</p>
|
||||||
|
}
|
||||||
|
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||||
|
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||||
|
{
|
||||||
|
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||||
|
<dd class="break-all">@row.Value</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
@if (Pending)
|
||||||
|
{
|
||||||
|
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Heading { get; set; }
|
||||||
|
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
|
||||||
|
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
|
||||||
|
[Parameter] public bool Pending { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
}
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
|
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
|
||||||
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
|
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
|
||||||
|
pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
|
||||||
|
@if (TapOpens)
|
||||||
|
{
|
||||||
|
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
|
||||||
|
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
|
||||||
|
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
|
||||||
|
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
|
||||||
|
@ChildContent
|
||||||
|
</DTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</DTooltip>
|
</DTooltip>
|
||||||
|
}
|
||||||
|
@if (isSheetOpen)
|
||||||
|
{
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
|
||||||
|
<Heading>@Heading</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<NerdRows Rows="Rows" Pending="Pending" />
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
@@ -16,8 +38,19 @@
|
|||||||
[Parameter] public DTooltipPlacement Placement { get; set; }
|
[Parameter] public DTooltipPlacement Placement { get; set; }
|
||||||
[Parameter] public DTooltipAlign Align { get; set; }
|
[Parameter] public DTooltipAlign Align { get; set; }
|
||||||
[Parameter] public string Class { get; set; }
|
[Parameter] public string Class { get; set; }
|
||||||
|
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
|
||||||
|
[Parameter] public bool TapOpens { get; set; } = true;
|
||||||
|
|
||||||
bool opened;
|
bool opened;
|
||||||
|
bool isSheetOpen;
|
||||||
|
|
||||||
|
async Task Tap()
|
||||||
|
{
|
||||||
|
if (CascadingState.Status.CanHover())
|
||||||
|
return;
|
||||||
|
isSheetOpen = true;
|
||||||
|
await Open();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
@@ -29,21 +62,5 @@
|
|||||||
await OnOpen.InvokeAsync();
|
await OnOpen.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
|
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
|
||||||
@if (Heading is { Length: > 0 })
|
|
||||||
{
|
|
||||||
<p class="font-bold opacity-80">@Heading</p>
|
|
||||||
}
|
|
||||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
|
||||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
|
||||||
{
|
|
||||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
|
||||||
<dd class="break-all">@row.Value</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
@if (Pending)
|
|
||||||
{
|
|
||||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
|
||||||
}
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
|
||||||
|
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
|
||||||
|
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
|
||||||
|
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
|
||||||
|
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
|
||||||
|
data-sheet>
|
||||||
|
<div class="md:hidden flex items-center gap-3 mb-3">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@Heading</b>
|
||||||
|
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
|
||||||
|
@onclick="Close" data-sheet-close>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
/// <summary>The sheet's heading on a phone.</summary>
|
||||||
|
[Parameter] public string Heading { get; set; }
|
||||||
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
|
[Parameter] public string Class { get; set; }
|
||||||
|
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
|
||||||
|
IDisposable overlay;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
// open, the Back button closes it
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
overlay?.Dispose();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Close() => OnClose.InvokeAsync();
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
|
||||||
|
(PublicNoticeService), in MainLayout. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
||||||
|
data-public-notice>
|
||||||
|
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<p class="text-sm">
|
||||||
|
@(Notices.Act == PublicAct.Participation
|
||||||
|
? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
|
||||||
|
: Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""])
|
||||||
|
</p>
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] PublicNoticeService Notices { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Notices.Changed += Refresh;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Refresh() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
void Closed(bool open)
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
Notices.Answer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Notices.Changed -= Refresh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Who may quote one of the persona's own public or unlisted posts (PUT /api/v1/statuses/:id/interaction_policy,
|
||||||
|
quote_approval_policy): anyone, its followers or nobody, from the card's menu. The post states its policy in
|
||||||
|
quote_approval.automatic; PrivaPub tells the post's readers of the change. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-quote-policy-dialog>
|
||||||
|
<Heading>@Localizer["Who may quote it"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start" Active="policy == current" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => Choose(policy)"
|
||||||
|
aria-pressed="@(policy == current ? "true" : "false")" data-quote-policy-choice="@StatusService.QuotePolicyValue(policy)">
|
||||||
|
<i class="@PolicyIcon(policy)" aria-hidden="true"></i>
|
||||||
|
<span class="flex-1 text-left">@PolicyName(policy)</span>
|
||||||
|
@if (policy == current)
|
||||||
|
{
|
||||||
|
<i class="ion-md-checkmark text-primary" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public Status Status { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>The post as PrivaPub now describes it, its new policy in it.</summary>
|
||||||
|
[Parameter] public EventCallback<Status> OnChanged { get; set; }
|
||||||
|
|
||||||
|
QuotePolicy current = QuotePolicy.Public;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
current = StatusService.QuotePolicyOf(Status);
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string PolicyName(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => Localizer["Your followers"],
|
||||||
|
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||||
|
_ => Localizer["Anyone"]
|
||||||
|
};
|
||||||
|
|
||||||
|
static string PolicyIcon(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => "ion-md-people",
|
||||||
|
QuotePolicy.Nobody => "ion-md-close-circle",
|
||||||
|
_ => "ion-md-globe"
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Choose(QuotePolicy policy)
|
||||||
|
{
|
||||||
|
if (policy == current)
|
||||||
|
return;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await StatusService.SetQuotePolicy(Status.Id, policy);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (Status)result.Data;
|
||||||
|
current = StatusService.QuotePolicyOf(updated);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
await OnChanged.InvokeAsync(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
|
||||||
|
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A
|
||||||
|
violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the
|
||||||
|
server has rules. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||||
|
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
|
||||||
|
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
|
||||||
|
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
|
||||||
|
@if (choices.Count > 0)
|
||||||
|
{
|
||||||
|
<option value="@ReportCategory.Violation">@Localizer[nameof(ReportCategory.Violation)]</option>
|
||||||
|
}
|
||||||
|
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
|
||||||
|
</DSelect>
|
||||||
|
@if (form.Category is ReportCategory.Violation)
|
||||||
|
{
|
||||||
|
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2" data-report-rules>
|
||||||
|
<legend class="text-sm mb-1">@Localizer["Which of the server's rules does it break?"]</legend>
|
||||||
|
@foreach (var choice in choices)
|
||||||
|
{
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer pointer-coarse:py-2">
|
||||||
|
<DCheckbox @bind-Value="choice.Picked" class="mt-0.5 flex-none" data-report-rule="@choice.Rule.Id" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<span class="text-sm break-words">@choice.Rule.Text</span>
|
||||||
|
@if (choice.Rule.Hint is { Length: > 0 } hint)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70 break-words">@hint</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
|
||||||
|
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
|
||||||
|
@if (Host is { } host)
|
||||||
|
{
|
||||||
|
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] AboutService AboutService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public Account Account { get; set; }
|
||||||
|
/// <summary>The post it was opened from, sent with the report.</summary>
|
||||||
|
[Parameter] public Status Post { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
ReportForm form = new();
|
||||||
|
bool isSending;
|
||||||
|
string error;
|
||||||
|
// the server's rules, each ticked or not, for a violation
|
||||||
|
List<RuleChoice> choices = [];
|
||||||
|
|
||||||
|
// the account's own server, for a remote account
|
||||||
|
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => choices = [.. (await AboutService.Rules()).Select(rule => new RuleChoice { Rule = rule })];
|
||||||
|
|
||||||
|
async Task Send()
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
var ruleIds = form.Category is ReportCategory.Violation ? choices.Where(c => c.Picked).Select(c => c.Rule.Id).ToList() : [];
|
||||||
|
if (form.Category is ReportCategory.Violation && ruleIds.Count == 0)
|
||||||
|
{
|
||||||
|
error = Localizer["Tick the rules it breaks."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, ruleIds,
|
||||||
|
Host is not null && form.Forward);
|
||||||
|
isSending = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]);
|
||||||
|
await OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
|
||||||
|
sealed class RuleChoice
|
||||||
|
{
|
||||||
|
public InstanceRule Rule { get; init; }
|
||||||
|
public bool Picked { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
|
||||||
|
new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
|
||||||
|
device's goes on with a new JWT); its two-factor sign-in (TotpEditor); every session of it ended at once; or the whole
|
||||||
|
of it deleted, every persona with it.
|
||||||
|
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-account>
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
|
||||||
|
<b class="text-sm">@Localizer["Email address"]</b>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else if (account is not null)
|
||||||
|
{
|
||||||
|
@if (account.Email is { Length: > 0 } email)
|
||||||
|
{
|
||||||
|
<p class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="break-all" data-account-email-address>@email</span>
|
||||||
|
@if (account.IsEmailValidated)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-account-email-verified>@Localizer["verified"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-email-unverified>@Localizer["not verified"]</DBadge>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (!account.IsEmailValidated)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Only a verified address can recover a forgotten password."]</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-sm" data-account-email-none>@Localizer["No email address yet: without one, a forgotten password can't be recovered."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail)
|
||||||
|
{
|
||||||
|
<EditForm Model="code" OnValidSubmit="VerifyEmail" class="flex flex-col gap-2" data-account-email-code>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
@if (codeSentence is not null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@codeSentence</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm break-all">@Localizer["Enter the code sent to {0}.", pending]</p>
|
||||||
|
<DOtp @bind-Value="code.Code" Immediate="true" aria-label="@Localizer["Code"]" data-account-email-code-digits />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-verify>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Verify"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isChangingEmail)
|
||||||
|
{
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
placeholder="@Localizer["Current password"]" data-account-email-password />
|
||||||
|
<ErrorViewer />
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-send>
|
||||||
|
<i class="ion-md-mail"></i><span>@Localizer["Send the code"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else if (isRemovingEmail)
|
||||||
|
{
|
||||||
|
<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"
|
||||||
|
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
|
||||||
|
<ErrorViewer />
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-remove-confirm>
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
@if (account.Email is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailRemoval" data-account-email-remove-open>
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailChange" data-account-email-change-open>
|
||||||
|
<i class="ion-md-create"></i><span>@(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"])</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
placeholder="@Localizer["New password"]" data-account-new />
|
||||||
|
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.RepeatedPassword" Immediate="true" autocomplete="new-password"
|
||||||
|
placeholder="@Localizer["Repeat the new password"]" data-account-repeat />
|
||||||
|
</div>
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-password-save>
|
||||||
|
<i class="ion-md-key"></i><span>@Localizer["Change the password"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<TotpEditor />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
|
||||||
|
<b class="text-sm">@Localizer["Sign out everywhere"]</b>
|
||||||
|
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="LogoutEverywhere" data-account-everywhere-confirm>
|
||||||
|
<i class="ion-md-log-out"></i><span>@Localizer["Sign out everywhere"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
|
||||||
|
<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"
|
||||||
|
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 />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || !understood)" data-account-delete-confirm>
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
|
||||||
|
ViewAccount account;
|
||||||
|
EmailChangeForm change = new();
|
||||||
|
EmailCodeForm code = new();
|
||||||
|
PasswordForm emailRemoval = new();
|
||||||
|
UserPasswordForm password = new();
|
||||||
|
RemoveSelfForm removal = new();
|
||||||
|
/// <summary>PrivaPub's answer to the change asked here, the same whether or not the address is free.</summary>
|
||||||
|
string codeSentence;
|
||||||
|
bool isChangingEmail;
|
||||||
|
bool isRemovingEmail;
|
||||||
|
bool understood;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => account = await AuthService.Account();
|
||||||
|
|
||||||
|
void OpenEmailChange()
|
||||||
|
{
|
||||||
|
change = new();
|
||||||
|
isRemovingEmail = false;
|
||||||
|
isChangingEmail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenEmailRemoval()
|
||||||
|
{
|
||||||
|
emailRemoval = new();
|
||||||
|
isChangingEmail = false;
|
||||||
|
isRemovingEmail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseEmailForms()
|
||||||
|
{
|
||||||
|
isChangingEmail = false;
|
||||||
|
isRemovingEmail = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeEmail()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
change.Email = change.Email.Trim();
|
||||||
|
var result = await AuthService.ChangeEmail(change);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeSentence = result.Data is string { Length: > 0 } said
|
||||||
|
? said
|
||||||
|
: Localizer["If that address can be used, a code is on its way to it. It works for one hour."];
|
||||||
|
// as PrivaPub keeps it while it waits for its code
|
||||||
|
account.PendingEmail = change.Email.ToLowerInvariant();
|
||||||
|
change = new();
|
||||||
|
code = new();
|
||||||
|
isChangingEmail = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task VerifyEmail()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.VerifyEmail(code.Code.Trim());
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
// five wrong codes, or an hour, and the address no longer waits
|
||||||
|
account = await AuthService.Account() ?? account;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||||
|
code = new();
|
||||||
|
codeSentence = default;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Email address verified."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveEmail()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Remove the email address?"], Localizer["Without one, a forgotten password can't be recovered."],
|
||||||
|
Localizer["Remove the address"], true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.RemoveEmail(emailRemoval.Password);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||||
|
emailRemoval = new();
|
||||||
|
codeSentence = default;
|
||||||
|
isRemovingEmail = false;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Email address removed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangePassword()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
// the session goes on with the JWT PrivaPub answers with, and the persona with a new token
|
||||||
|
var result = await AuthService.ChangePassword(password);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
password = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Password changed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogoutEverywhere()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Sign out everywhere?"],
|
||||||
|
Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."],
|
||||||
|
Localizer["Sign out everywhere"], true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.LogoutEverywhere();
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CascadingState.SignOut();
|
||||||
|
Navigation.NavigateTo("login");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.DeleteAccount(removal);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CascadingState.SignOut();
|
||||||
|
Navigation.NavigateTo("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's follows, lists, blocks, mutes, blocked servers and bookmarks, exported and imported as the CSV files
|
||||||
|
Mastodon writes, which most fediverse servers read too: a persona moves here from another server, or away, with what
|
||||||
|
it gathered there. An import acts one row at a time, through PrivaPub, and can be stopped. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Export"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Files in Mastodon's format, which most fediverse servers import."]</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || exporting is not null || importing is not null)" OnClick="() => Export(data)"
|
||||||
|
data-export-kind="@data">
|
||||||
|
@if (exporting == data)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-download"></i>
|
||||||
|
}
|
||||||
|
<span>@Name(data)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Import"]</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["What the file holds"]</span>
|
||||||
|
<DSelect TValue="PortableData" Size="DSize.Sm" @bind-Value="importKind" disabled="@(importing is not null)" data-import-kind>
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<option value="@data">@Name(data)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".csv,text/csv" OnChange="Pick" disabled="@(importing is not null)" data-import-file />
|
||||||
|
@if (importing is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || file is null || exporting is not null)" OnClick="Import"
|
||||||
|
data-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i><span>@Localizer["Import"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" OnClick="Stop">@Localizer["Stop"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (report is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-import-report>
|
||||||
|
<DProgress class="w-full max-w-md" Value="@(report.Done + report.Failed.Count)" Max="@Math.Max(1, report.Total)" />
|
||||||
|
<span class="text-sm tabular-nums">
|
||||||
|
@Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]
|
||||||
|
</span>
|
||||||
|
<ErrorViewer AdditionalErrors="report.Failed" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] PortabilityService PortabilityService { get; set; }
|
||||||
|
[Inject] IBlazorDownloadFileService DownloadFile { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
// an export file of 5000 rows stays well under this
|
||||||
|
const long MaximumFileSize = 4 * 1024 * 1024;
|
||||||
|
static readonly PortableData[] Kinds = Enum.GetValues<PortableData>();
|
||||||
|
|
||||||
|
PortableData? exporting;
|
||||||
|
PortableData? importing;
|
||||||
|
PortableData importKind = PortableData.Follows;
|
||||||
|
string file;
|
||||||
|
ImportReport report;
|
||||||
|
CancellationTokenSource stopping;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
string Name(PortableData data) => data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => Localizer["Follows"],
|
||||||
|
PortableData.Lists => Localizer["Lists"],
|
||||||
|
PortableData.Blocks => Localizer["Blocked accounts"],
|
||||||
|
PortableData.Mutes => Localizer["Muted accounts"],
|
||||||
|
PortableData.ServerBlocks => Localizer["Blocked servers"],
|
||||||
|
_ => Localizer["Bookmarks"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Export(PortableData data)
|
||||||
|
{
|
||||||
|
exporting = data;
|
||||||
|
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
|
exporting = null;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
file = null;
|
||||||
|
report = null;
|
||||||
|
if (eventArgs.File.Size > MaximumFileSize)
|
||||||
|
{
|
||||||
|
Toast.AddWarning(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", eventArgs.File.Name,
|
||||||
|
eventArgs.File.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(eventArgs.File.OpenReadStream(MaximumFileSize));
|
||||||
|
file = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessError(ex, $"{nameof(DataTransfer)}.{nameof(Pick)}:{eventArgs.File.Name}");
|
||||||
|
Toast.AddWarning(Localizer["The file \"{0}\" could not be read.", eventArgs.File.Name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
importing = importKind;
|
||||||
|
stopping = new();
|
||||||
|
report = new();
|
||||||
|
var progress = new Progress<ImportReport>(step =>
|
||||||
|
{
|
||||||
|
report = step;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
report = await PortabilityService.Import(importKind, file, progress, stopping.Token);
|
||||||
|
importing = null;
|
||||||
|
stopping.Dispose();
|
||||||
|
stopping = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Stop() => stopping?.Cancel();
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
stopping?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The accounts the current persona muted or blocked (/api/v1/mutes, /api/v1/blocks), and the servers it blocked
|
||||||
|
(/api/v1/domain_blocks), each one undone here. A server blocked hides everything from it and removes the persona's
|
||||||
|
followers there. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-mutes>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Muted accounts"]</h3>
|
||||||
|
@if (mutes.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is muted."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in mutes)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unmute)">
|
||||||
|
@Localizer["Unmute"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (mutesMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreMutes">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked accounts"]</h3>
|
||||||
|
@if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unblock)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (blocksMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreBlocks">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-domain-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked servers"]</h3>
|
||||||
|
<EditForm Model="draft" OnValidSubmit="BlockDomain" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-domain-new>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || string.IsNullOrWhiteSpace(draft.Domain) || busy.Contains(Domain(draft.Domain)))">
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
@if (domains.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var domain in domains)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-domain="@domain">
|
||||||
|
<i class="ion-md-globe opacity-70"></i>
|
||||||
|
<span class="flex-1 min-w-0 truncate">@domain</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(domain))" OnClick="() => UnblockDomain(domain)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
sealed class DomainDraft
|
||||||
|
{
|
||||||
|
public string Domain { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Account> mutes = [];
|
||||||
|
List<Account> blocks = [];
|
||||||
|
List<string> domains = [];
|
||||||
|
string mutesMaxId;
|
||||||
|
string blocksMaxId;
|
||||||
|
DomainDraft draft = new();
|
||||||
|
// the accounts and servers whose undo is on its way
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
mutes = [];
|
||||||
|
blocks = [];
|
||||||
|
mutesMaxId = null;
|
||||||
|
blocksMaxId = null;
|
||||||
|
await Task.WhenAll(MoreMutes(), MoreBlocks());
|
||||||
|
domains = await AccountService.DomainBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreMutes()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Mutes(mutesMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
mutes.AddRange(page.Accounts.Where(a => mutes.All(m => m.Id != a.Id)));
|
||||||
|
mutesMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreBlocks()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Blocks(blocksMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
blocks.AddRange(page.Accounts.Where(a => blocks.All(b => b.Id != a.Id)));
|
||||||
|
blocksMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Undo(Account account, AccountAction action)
|
||||||
|
{
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.Act(account.Id, action);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action is AccountAction.Unmute)
|
||||||
|
mutes.RemoveAll(m => m.Id == account.Id);
|
||||||
|
else
|
||||||
|
blocks.RemoveAll(b => b.Id == account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// what a person types as a server (a URL, a handle's host, any case) as the bare host PrivaPub keys it by
|
||||||
|
static string Domain(string typed)
|
||||||
|
{
|
||||||
|
var text = typed?.Trim().TrimStart('@') ?? "";
|
||||||
|
if (Uri.TryCreate(text.Contains("://") ? text : "https://" + text, UriKind.Absolute, out var uri))
|
||||||
|
text = uri.Host;
|
||||||
|
return text.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task BlockDomain()
|
||||||
|
{
|
||||||
|
var domain = Domain(draft.Domain);
|
||||||
|
if (domain.Length == 0 || domains.Contains(domain))
|
||||||
|
{
|
||||||
|
draft = new();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.BlockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft = new();
|
||||||
|
domains = [.. domains.Append(domain).Order()];
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UnblockDomain(string domain)
|
||||||
|
{
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.UnblockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domains.Remove(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Rendering
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's notification policy (/api/v2/notifications/policy): what notifications from people it does not follow,
|
||||||
|
people who do not follow it, new accounts, private mentions it did not ask for and accounts this server limits become.
|
||||||
|
Filtered ones wait in a request per account (/api/v1/notifications/requests), let in (with that account from then
|
||||||
|
on) or dismissed here. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-4" data-notification-policy>
|
||||||
|
<EditForm Model="policy" OnValidSubmit="Save" class="flex flex-col gap-2">
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["People you don't follow"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowing" data-policy="for_not_following">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["People who don't follow you"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowers" data-policy="for_not_followers">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["New accounts"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNewAccounts" data-policy="for_new_accounts">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["Private mentions you did not ask for"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForPrivateMentions" data-policy="for_private_mentions">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["Accounts this server limits"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForLimitedAccounts" data-policy="for_limited_accounts">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Filtered notifications wait below, one request per account; dropped ones are never kept."]</p>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isSaving)" data-policy-save>
|
||||||
|
@Localizer["Save"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-notification-requests>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Filtered notifications"]</h3>
|
||||||
|
@if (requests.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No filtered notifications."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var request in requests)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0" data-request="@request.Account.Acct">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 min-w-0">
|
||||||
|
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(request.Account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(request.Account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(request.Account.Name, request.Account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + request.Account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<DBadge Size="DSize.Sm">@Localizer["{0} notifications", request.NotificationsCount]</DBadge>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||||
|
OnClick="@(() => Answer(request, true))" data-request-accept>@Localizer["Let in"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||||
|
OnClick="@(() => Answer(request, false))" data-request-dismiss>@Localizer["Dismiss"]</DButton>
|
||||||
|
</div>
|
||||||
|
@if (request.LastStatus?.Content is { Length: > 0 } content)
|
||||||
|
{
|
||||||
|
<div class="post-content text-sm opacity-80 break-words line-clamp-3">@((MarkupString)CustomEmojis.Html(content, request.LastStatus.Emojis))</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
|
|
||||||
|
NotificationPolicy policy = new();
|
||||||
|
|
||||||
|
// what a notification can become, the same for every rule
|
||||||
|
void Actions(RenderTreeBuilder __builder)
|
||||||
|
{
|
||||||
|
<option value="accept">@Localizer["Accept"]</option>
|
||||||
|
<option value="filter">@Localizer["Filter"]</option>
|
||||||
|
<option value="drop">@Localizer["Drop"]</option>
|
||||||
|
}
|
||||||
|
List<NotificationRequest> requests = [];
|
||||||
|
// the requests whose answer is on its way
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
bool isSaving;
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
policy = await TimelineService.NotificationPolicy() ?? new();
|
||||||
|
requests = await TimelineService.NotificationRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await TimelineService.SetNotificationPolicy(policy);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
policy = (NotificationPolicy)result.Data ?? policy;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Notification policy saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(NotificationRequest request, bool accept)
|
||||||
|
{
|
||||||
|
busy.Add(request.Id);
|
||||||
|
var result = await TimelineService.AnswerNotificationRequest(request.Id, accept);
|
||||||
|
busy.Remove(request.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.Remove(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's
|
||||||
|
layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from
|
||||||
|
the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full" data-archive>
|
||||||
|
<section class="flex flex-col gap-2" data-archive-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
|
||||||
|
@if (state.ExportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-archive"></i>
|
||||||
|
}
|
||||||
|
<span>@Localizer["Make the archive"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (state.ExportState == "ready")
|
||||||
|
{
|
||||||
|
@if (link is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Get the link"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||||
|
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (state.ExportState == "failed")
|
||||||
|
{
|
||||||
|
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-archive-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]</p>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
|
||||||
|
@foreach (var part in Parts)
|
||||||
|
{
|
||||||
|
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
|
||||||
|
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
@if (!Importing)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
|
||||||
|
data-archive-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
|
||||||
|
@Localizer["Stop"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (uploading)
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
|
||||||
|
}
|
||||||
|
@if (state.ImportState is not (null or "none"))
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
|
||||||
|
@if (state.ImportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
|
||||||
|
}
|
||||||
|
<span class="text-sm">
|
||||||
|
@(state.ImportState switch
|
||||||
|
{
|
||||||
|
"queued" => Localizer["Waiting to start."],
|
||||||
|
"running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal],
|
||||||
|
"done" => Localizer["Imported."],
|
||||||
|
"stopped" => Localizer["Stopped."],
|
||||||
|
_ => Localizer["The import failed: {0}", state.ImportError ?? ""]
|
||||||
|
})
|
||||||
|
</span>
|
||||||
|
@if (state.ImportFrom is { Length: > 0 } from)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
|
||||||
|
}
|
||||||
|
<ul class="text-xs tabular-nums">
|
||||||
|
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
<li data-archive-count="@what">@count × @CountName(what)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] ArchiveService Archives { get; set; }
|
||||||
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
|
[Inject] AppConfiguration Configuration { get; set; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
const string FileInput = "input[data-archive-file]";
|
||||||
|
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||||
|
static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
||||||
|
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
||||||
|
|
||||||
|
ViewPersonaArchive state = new();
|
||||||
|
string loadedFor;
|
||||||
|
HashSet<string> chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"];
|
||||||
|
bool chosenFlag;
|
||||||
|
bool busy;
|
||||||
|
string link;
|
||||||
|
string pickedName;
|
||||||
|
bool uploading;
|
||||||
|
long uploaded;
|
||||||
|
long uploadSize;
|
||||||
|
double uploadedPercent;
|
||||||
|
IJSObjectReference transfer;
|
||||||
|
DotNetObjectReference<PersonaArchivePanel> self;
|
||||||
|
CancellationTokenSource polling;
|
||||||
|
|
||||||
|
bool Importing => uploading || state.ImportState is "queued" or "running";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// another persona: its own archive
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!IsLoading && loadedFor != CascadingState.PersonaId)
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
loadedFor = CascadingState.PersonaId;
|
||||||
|
link = null;
|
||||||
|
if (loadedFor is null)
|
||||||
|
return;
|
||||||
|
state = await Archives.State(loadedFor) ?? new ViewPersonaArchive();
|
||||||
|
if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running")
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Poll()
|
||||||
|
{
|
||||||
|
if (polling is not null)
|
||||||
|
return;
|
||||||
|
polling = new();
|
||||||
|
var token = polling.Token;
|
||||||
|
var persona = loadedFor;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(PollEvery);
|
||||||
|
while (await timer.WaitForNextTickAsync(token))
|
||||||
|
{
|
||||||
|
if (persona != loadedFor)
|
||||||
|
break;
|
||||||
|
if (await Archives.State(persona, token) is { } read)
|
||||||
|
state = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running"))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
polling = null;
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string PartName(string part) => part switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["Posts and their pictures"],
|
||||||
|
"profile" => Localizer["Profile: name, words, fields, pictures"],
|
||||||
|
"follows" => Localizer["Follows, asked again"],
|
||||||
|
"blocks" => Localizer["Blocks"],
|
||||||
|
"mutes" => Localizer["Mutes"],
|
||||||
|
"domainblocks" => Localizer["Blocked servers"],
|
||||||
|
"lists" => Localizer["Lists"],
|
||||||
|
"bookmarks" => Localizer["Bookmarks"],
|
||||||
|
"filters" => Localizer["Filters"],
|
||||||
|
"tags" => Localizer["Followed hashtags"],
|
||||||
|
"notificationpolicy" => Localizer["Notification policy"],
|
||||||
|
"pins" => Localizer["Pinned posts"],
|
||||||
|
"located" => Localizer["Located posts"],
|
||||||
|
"scheduled" => Localizer["Scheduled posts"],
|
||||||
|
"likes" => Localizer["Likes, given again"],
|
||||||
|
_ => part
|
||||||
|
};
|
||||||
|
|
||||||
|
string CountName(string what) => what switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["posts imported"],
|
||||||
|
"posts already here" => Localizer["posts already here"],
|
||||||
|
"posts deleted here" => Localizer["posts deleted here, left deleted"],
|
||||||
|
"boosts skipped" => Localizer["boosts left out"],
|
||||||
|
"direct or circle posts skipped" => Localizer["private posts left out"],
|
||||||
|
"not the archive's own" => Localizer["posts by others left out"],
|
||||||
|
"not a post" => Localizer["other items left out"],
|
||||||
|
"media" => Localizer["pictures and files imported"],
|
||||||
|
"media not in the archive" => Localizer["pictures not in the archive"],
|
||||||
|
"media refused" => Localizer["pictures refused"],
|
||||||
|
"located posts" => Localizer["located posts imported"],
|
||||||
|
"located posts already here" => Localizer["located posts already here"],
|
||||||
|
"scheduled posts" => Localizer["scheduled posts imported"],
|
||||||
|
"scheduled posts past or refused" => Localizer["scheduled posts past or refused"],
|
||||||
|
"followed hashtags" => Localizer["hashtags followed"],
|
||||||
|
"notification policy" => Localizer["notification policy set"],
|
||||||
|
"filters" => Localizer["filters imported"],
|
||||||
|
"pins" => Localizer["posts pinned"],
|
||||||
|
"blocked servers" => Localizer["servers blocked"],
|
||||||
|
"blocks" => Localizer["accounts blocked"],
|
||||||
|
"mutes" => Localizer["accounts muted"],
|
||||||
|
"follows" => Localizer["follows asked"],
|
||||||
|
"lists" => Localizer["lists made"],
|
||||||
|
"list members" => Localizer["list members added"],
|
||||||
|
"list members not followed" => Localizer["list members not followed"],
|
||||||
|
"bookmarks" => Localizer["bookmarks imported"],
|
||||||
|
"likes" => Localizer["likes given"],
|
||||||
|
"profile" => Localizer["profile updated"],
|
||||||
|
"pictures refused" => Localizer["pictures refused"],
|
||||||
|
_ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]],
|
||||||
|
_ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]],
|
||||||
|
_ => what
|
||||||
|
};
|
||||||
|
|
||||||
|
void Choose(string part, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
chosen.Add(part);
|
||||||
|
else
|
||||||
|
chosen.Remove(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||||
|
|
||||||
|
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||||
|
|
||||||
|
async Task Export()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Export(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)result.Data;
|
||||||
|
link = null;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Link()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Ticket(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||||
|
self ??= DotNetObjectReference.Create(this);
|
||||||
|
var started = await Archives.StartUpload();
|
||||||
|
if (started.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload = (ViewArchiveUpload)started.Data;
|
||||||
|
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||||
|
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri;
|
||||||
|
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||||
|
uploading = false;
|
||||||
|
if (!sent.Ok)
|
||||||
|
{
|
||||||
|
await Archives.CancelUpload(upload.Id);
|
||||||
|
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] });
|
||||||
|
if (asked.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(asked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)asked.Data;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Stop()
|
||||||
|
{
|
||||||
|
var result = await Archives.Stop(loadedFor);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public Task Progress(long received, long total)
|
||||||
|
{
|
||||||
|
(uploaded, uploadSize) = (received, total);
|
||||||
|
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||||
|
return InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UploadOutcome
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
polling?.Cancel();
|
||||||
|
self?.Dispose();
|
||||||
|
if (transfer is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transfer.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
|
||||||
|
to one another: no server, PrivaPub's included, shows that they share a root. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-personas>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var persona in CascadingState.Personas)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
|
||||||
|
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
|
||||||
|
@if (persona.Id == CascadingState.PersonaId)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm">@Localizer["A new persona"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.UserName" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-persona-username>
|
||||||
|
<Prefix>@@</Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["Display name"]" data-persona-name />
|
||||||
|
</div>
|
||||||
|
<DTextarea @bind-Value="form.Biography" Immediate="true" rows="3" autocomplete="off" maxlength="@ProfileForm.NoteLength"
|
||||||
|
placeholder="@Localizer["Biography"]" data-persona-biography />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-persona-create>
|
||||||
|
<i class="ion-md-person-add"></i><span>@Localizer["Make the persona"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
|
||||||
|
InsertAvatarForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
string switching;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
async Task Use(ViewAvatar persona)
|
||||||
|
{
|
||||||
|
switching = persona.Id;
|
||||||
|
var result = await CascadingState.SwitchPersona(persona.Id);
|
||||||
|
switching = null;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
form.UserName = form.UserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
var result = await AuthService.CreatePersona(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form = new();
|
||||||
|
await CascadingState.ReloadPersonas();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Persona made."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The current persona's profile (/api/v1/accounts/update_credentials): its pictures, name, biography and fields as it
|
||||||
|
wrote them, whether it approves followers by hand, is a bot, is listed and searchable, and how its new posts start.
|
||||||
|
PrivaPub tells the persona's followers' servers of the change. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (account is null)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||||
|
<div class="relative w-full">
|
||||||
|
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||||
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
|
||||||
|
alt="@Localizer["Avatar"]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>@Localizer["Avatar"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
|
</label>
|
||||||
|
@if (HasOwn(account.Avatar) && form.Avatar is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>@Localizer["Header"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
|
</label>
|
||||||
|
@if (HasOwn(account.Header) && form.Header is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 max-w-md">
|
||||||
|
<span class="text-sm">@Localizer["Display name"]</span>
|
||||||
|
<DInput TValue="string" @bind-Value="form.DisplayName" Immediate="true" autocomplete="off" maxlength="@ProfileForm.DisplayNameLength"
|
||||||
|
data-profile-name />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Biography"]</span>
|
||||||
|
<DTextarea @bind-Value="form.Note" Immediate="true" rows="4" autocomplete="off" maxlength="@ProfileForm.NoteLength" data-profile-note />
|
||||||
|
<span class="text-xs opacity-70 tabular-nums self-end">@(form.Note?.Length ?? 0)/@ProfileForm.NoteLength</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Profile fields"]</legend>
|
||||||
|
@foreach (var field in form.Fields)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2" data-profile-field="@form.Fields.IndexOf(field)">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-36" @bind-Value="field.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Label"]" />
|
||||||
|
<DInput TValue="string" class="flex-[2] min-w-48" @bind-Value="field.Value" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Content"]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<DToggle LabelText="@Localizer["Approve followers by hand"]" @bind-Value="form.Locked" data-profile-locked />
|
||||||
|
<DToggle LabelText="@Localizer["This persona is automated (a bot)"]" @bind-Value="form.Bot" />
|
||||||
|
<DToggle LabelText="@Localizer["List it in the directory and suggest it to others"]" @bind-Value="form.Discoverable" />
|
||||||
|
<DToggle LabelText="@Localizer["Let its public posts be found by search"]" @bind-Value="form.Indexable" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-4">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Visibility"]</span>
|
||||||
|
<DSelect TValue="Visibility" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||||
|
@foreach (var type in Visibilities)
|
||||||
|
{
|
||||||
|
<option value="@type">@Localizer[type.ToString()]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Language"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="form.Language">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach (var (languageCode, languageName) in Languages.All)
|
||||||
|
{
|
||||||
|
<option value="@languageCode">@languageName</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Who may quote them"]</span>
|
||||||
|
<DSelect TValue="QuotePolicy" Size="DSize.Sm" @bind-Value="form.QuotePolicy">
|
||||||
|
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||||
|
{
|
||||||
|
<option value="@policy">@QuotePolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DToggle class="self-end" LabelText="@Localizer["Mark media as sensitive"]" @bind-Value="form.Sensitive" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalErrors="pictureErrors" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-end">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving)">@Localizer["Reset"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-profile-save>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
static readonly Visibility[] Visibilities = [Visibility.Public, Visibility.Unlisted, Visibility.FollowersOnly];
|
||||||
|
|
||||||
|
Account account;
|
||||||
|
ProfileForm form = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
List<string> pictureErrors = [];
|
||||||
|
// the picture types the server takes, read with the account
|
||||||
|
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
|
||||||
|
string PictureTypes => string.Join(",", pictureTypes);
|
||||||
|
|
||||||
|
// a picture of its own, not the server's default
|
||||||
|
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
// the account as the persona sees itself, with its Source
|
||||||
|
account = await AuthService.CurrentAccount();
|
||||||
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
form = account is null ? new() : ProfileForm.Of(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
string QuotePolicyName(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => Localizer["Its followers"],
|
||||||
|
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||||
|
_ => Localizer["Anyone"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs, bool isAvatar)
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
var file = eventArgs.File;
|
||||||
|
if (!pictureTypes.Contains(file.ContentType))
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.Size > ProfileForm.MaximumPictureSize)
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
|
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
|
||||||
|
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
|
||||||
|
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||||
|
if (isAvatar)
|
||||||
|
form.Avatar = picture;
|
||||||
|
else
|
||||||
|
form.Header = picture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
|
||||||
|
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(buffer);
|
||||||
|
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemovePicture(bool avatar)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.RemovePicture(avatar);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.UpdateProfile(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Profile saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
|
||||||
|
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
|
||||||
|
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
|
||||||
|
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
|
||||||
|
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
|
||||||
|
<p class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
|
||||||
|
@if (account is { TwoFactorEnabled: true })
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (account is not null)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else if (recoveryCodes.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-3" data-totp-codes>
|
||||||
|
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
|
||||||
|
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
|
||||||
|
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
|
||||||
|
</DAlert>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
|
||||||
|
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
|
||||||
|
</p>
|
||||||
|
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
|
||||||
|
@foreach (var recoveryCode in recoveryCodes)
|
||||||
|
{
|
||||||
|
<li>@recoveryCode</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
|
||||||
|
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (account is { TwoFactorEnabled: true })
|
||||||
|
{
|
||||||
|
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
|
||||||
|
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
|
||||||
|
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
|
||||||
|
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
|
||||||
|
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isDisabling)
|
||||||
|
{
|
||||||
|
<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"
|
||||||
|
placeholder="@Localizer["Current password"]" data-totp-disable-password />
|
||||||
|
@if (disableWithRecoveryCode)
|
||||||
|
{
|
||||||
|
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
|
||||||
|
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
|
||||||
|
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
|
||||||
|
}
|
||||||
|
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
|
||||||
|
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
|
||||||
|
</DButton>
|
||||||
|
<ErrorViewer />
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
|
||||||
|
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
|
||||||
|
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (account is not null)
|
||||||
|
{
|
||||||
|
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
|
||||||
|
|
||||||
|
@if (setup is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-3" data-totp-scan>
|
||||||
|
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
|
||||||
|
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
|
||||||
|
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Key"]</span>
|
||||||
|
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
|
||||||
|
</div>
|
||||||
|
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
|
||||||
|
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
|
||||||
|
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
|
||||||
|
</DButton>
|
||||||
|
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
|
||||||
|
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
|
||||||
|
<ErrorViewer />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
|
||||||
|
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (isAskingPassword)
|
||||||
|
{
|
||||||
|
<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"
|
||||||
|
placeholder="@Localizer["Current password"]" data-totp-setup-password />
|
||||||
|
<ErrorViewer />
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
|
||||||
|
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
|
||||||
|
data-totp-setup-open>
|
||||||
|
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>From this many recovery codes left down, the block warns.</summary>
|
||||||
|
const int FewRecoveryCodes = 3;
|
||||||
|
|
||||||
|
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
|
||||||
|
ViewAccount account;
|
||||||
|
PasswordForm setupPassword = new();
|
||||||
|
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
|
||||||
|
ViewTotpSetup setup;
|
||||||
|
TotpCodeForm enable = new();
|
||||||
|
TotpDisableForm disable = new();
|
||||||
|
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
|
||||||
|
List<string> recoveryCodes = [];
|
||||||
|
bool isAskingPassword;
|
||||||
|
bool isDisabling;
|
||||||
|
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
|
||||||
|
bool disableWithRecoveryCode;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => account = await AuthService.Account();
|
||||||
|
|
||||||
|
void OpenSetup()
|
||||||
|
{
|
||||||
|
setupPassword = new();
|
||||||
|
isAskingPassword = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenDisable()
|
||||||
|
{
|
||||||
|
disable = new();
|
||||||
|
disableWithRecoveryCode = false;
|
||||||
|
isDisabling = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
|
||||||
|
void CloseForms()
|
||||||
|
{
|
||||||
|
isAskingPassword = false;
|
||||||
|
isDisabling = false;
|
||||||
|
setup = default;
|
||||||
|
setupPassword = new();
|
||||||
|
enable = new();
|
||||||
|
disable = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ToggleDisableRecoveryCode()
|
||||||
|
{
|
||||||
|
disableWithRecoveryCode = !disableWithRecoveryCode;
|
||||||
|
disable.Code = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Setup()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.TotpSetup(setupPassword.Password);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
// a 409: two-factor sign-in was turned on meanwhile, from another device
|
||||||
|
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
CloseForms();
|
||||||
|
account = await AuthService.Account() ?? account;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup = result.Data as ViewTotpSetup;
|
||||||
|
setupPassword = new();
|
||||||
|
enable = new();
|
||||||
|
isAskingPassword = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Enable()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
|
||||||
|
var result = await AuthService.TotpEnable(enable.Code.Trim());
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
enable = new();
|
||||||
|
// a 409: the setup expired (an hour), so it begins again with the password
|
||||||
|
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
CloseForms();
|
||||||
|
account = await AuthService.Account() ?? account;
|
||||||
|
isAskingPassword = account is { TwoFactorEnabled: false };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryCodes = result.Data as List<string> ?? [];
|
||||||
|
setup = default;
|
||||||
|
enable = new();
|
||||||
|
account.TwoFactorEnabled = true;
|
||||||
|
account.RecoveryCodesLeft = recoveryCodes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task CopyCodes()
|
||||||
|
{
|
||||||
|
// a refusal is reported by main.js; the codes can still be selected and copied by hand
|
||||||
|
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CodesSaved() => recoveryCodes = [];
|
||||||
|
|
||||||
|
async Task Disable()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
|
||||||
|
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
|
||||||
|
return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
disable.Code = default;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||||
|
CloseForms();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
||||||
@if (Toast.Messages.Count > 0)
|
@if (Toast.Messages.Count > 0)
|
||||||
{
|
{
|
||||||
<DToast class="z-[1000]">
|
@* (on a phone above the round New post button) *@
|
||||||
|
<DToast class="z-[1000] max-md:bottom-20">
|
||||||
@foreach (var message in Toast.Messages)
|
@foreach (var message in Toast.Messages)
|
||||||
{
|
{
|
||||||
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
||||||
|
|||||||
@@ -1,704 +0,0 @@
|
|||||||
{
|
|
||||||
"_type": "export",
|
|
||||||
"__export_format": 4,
|
|
||||||
"__export_date": "2022-02-14T20:02:43.810Z",
|
|
||||||
"__export_source": "insomnia.desktop.app:v2021.7.2",
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"_id": "req_db7df93225be442e89a167ae594c5551",
|
|
||||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
|
||||||
"modified": 1644868863001,
|
|
||||||
"created": 1643816994345,
|
|
||||||
"url": "https://localhost:5000/api/v1/message/add",
|
|
||||||
"name": "Add message",
|
|
||||||
"description": "rootMessageId => un susseguirsi di risposte ad un messaggio si portera' dietro sempre l'id del messaggio di partenza\n\ntitle => titolo opzionale\n\ncontent => testo del messaggio, con supporto di markdown\n\nResponse JSON:\n\n>\t\t{\n>\t\t\t\"user\": {\n\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n\t\t\t\t\"displayName\": \"loosy\",\n\t\t\t\t\"pictureUrl\": \"https://...\",\n\t\t\t\t\"backgroundUrl\": \"https://...\",\n\t\t\t\t\"profileUrl\": \"https://...\"\n\t\t\t},\n\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n\t\t\t\"rootMessageId\": null,\n\t\t\t\"messageId\": \"guid/uuid/whatever\",\n\t\t\t\"title\": \"...\",\n\t\t\t\"content\": \"...\",\n\t\t\t\"isFavourite\": false,\n\t\t\t\"isBoosted\": false,\n\t\t\t\"media\": [{\n\t\t\t\t\t\"fileName\": \"ciao.png\",\n\t\t\t\t\t\"altText\": \"descrizione immagine\",\n\t\t\t\t\t\"contentType\": \"image/png\",\n\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n\t\t\t}],\n\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t}\n",
|
|
||||||
"method": "POST",
|
|
||||||
"body": {
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": " {\n\t\"contentType\": \"1||2||3\",//PlainText,HTML,Markdown\n\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public,\n\t\"mediaType\": \"1||2||3||4\",//Images,Video,Audio,Documents\n\t\"rootMessageId\": null,\n\t\"title\": \"...\",\n\t\"content\": \"...\",\n\t\"media\": [{\n\t\t\"fileName\": \"ciao.png\",\n \"altText\": \"descrizione opzionale immagine\",\n\t\t\"contentType\": \"image/png\",//mime\n\t\t\"blob\": [23,33,11,...]//byteArray || base64?\n\t}],\n\t\"createdAt\": \"UTC or ticks\"\n}\n\n"
|
|
||||||
},
|
|
||||||
"parameters": [],
|
|
||||||
"headers": [
|
|
||||||
{
|
|
||||||
"name": "Content-Type",
|
|
||||||
"value": "application/json",
|
|
||||||
"id": "pair_b95faf0515f9453595110e7895330c5f"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"authentication": {},
|
|
||||||
"metaSortKey": -1643816957190,
|
|
||||||
"isPrivate": false,
|
|
||||||
"settingStoreCookies": true,
|
|
||||||
"settingSendCookies": true,
|
|
||||||
"settingDisableRenderRequestBody": false,
|
|
||||||
"settingEncodeUrl": true,
|
|
||||||
"settingRebuildPath": true,
|
|
||||||
"settingFollowRedirects": "global",
|
|
||||||
"_type": "request"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "fld_2fca219979e448c997a6788d2b539ee7",
|
|
||||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
|
||||||
"modified": 1643829443147,
|
|
||||||
"created": 1643816769446,
|
|
||||||
"name": "APIs",
|
|
||||||
"description": "",
|
|
||||||
"environment": {},
|
|
||||||
"environmentPropertyOrder": {},
|
|
||||||
"metaSortKey": -1643816769446,
|
|
||||||
"_type": "request_group"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
|
||||||
"parentId": null,
|
|
||||||
"modified": 1608987862911,
|
|
||||||
"created": 1608987862911,
|
|
||||||
"name": "Insomnia",
|
|
||||||
"description": "",
|
|
||||||
"scope": "collection",
|
|
||||||
"_type": "workspace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "req_b33056a8e8a64c838662a60a4909537c",
|
|
||||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
|
||||||
"modified": 1643829255295,
|
|
||||||
"created": 1643817054991,
|
|
||||||
"url": "https://localhost:5000/api/v1/message/messageId/delete",
|
|
||||||
"name": "Remove message",
|
|
||||||
"description": "[queryParam] messageId => id del messaggio da eliminare\n\nResponse 200\n",
|
|
||||||
"method": "DELETE",
|
|
||||||
"body": {},
|
|
||||||
"parameters": [],
|
|
||||||
"headers": [],
|
|
||||||
"authentication": {
|
|
||||||
"type": "oauth2",
|
|
||||||
"grantType": "authorization_code"
|
|
||||||
},
|
|
||||||
"metaSortKey": -1643816957177.5,
|
|
||||||
"isPrivate": false,
|
|
||||||
"settingStoreCookies": true,
|
|
||||||
"settingSendCookies": true,
|
|
||||||
"settingDisableRenderRequestBody": false,
|
|
||||||
"settingEncodeUrl": true,
|
|
||||||
"settingRebuildPath": true,
|
|
||||||
"settingFollowRedirects": "global",
|
|
||||||
"_type": "request"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "req_95c442071ee247f1a97d823c6e503db2",
|
|
||||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
|
||||||
"modified": 1644868763642,
|
|
||||||
"created": 1643816956965,
|
|
||||||
"url": "https://localhost:5000/api/v1/messages",
|
|
||||||
"name": "Get messages list",
|
|
||||||
"description": "Response JSON:\n>\t\t[\n>\t\t\t{\n>\t\t\t\t\"user\": {\n>\t\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n>\t\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n>\t\t\t\t\t\"displayName\": \"loosy\",\n>\t\t\t\t\t\"pictureUrl\": \"https://...\",\n>\t\t\t\t\t\"backgroundUrl\": \"https://...\",\n>\t\t\t\t\t\"profileUrl\": \"https://...\"\n>\t\t\t\t},\n>\t\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n>\t\t\t\t\"rootMessageId\": null,\n>\t\t\t\t\"messageId\": \"guid/uuid/whatever\",\n>\t\t\t\t\"title\": \"...\",\n>\t\t\t\t\"content\": \"...\",\n>\t\t\t\t\"isFavourite\": false,\n>\t\t\t\t\"isBoosted\": false,\n>\t\t\t\t\"media\": [{\n>\t\t\t\t\t\"fileName\": \"ciao.png\",\n>\t\t\t\t\t\"altText\": \"descrizione immagine\",\n>\t\t\t\t\t\"contentType\": \"image/png\",\n>\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n>\t\t\t\t}],\n>\t\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t\t}, {...}\n>\t\t]",
|
|
||||||
"method": "GET",
|
|
||||||
"body": {},
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"id": "pair_6e5222c6a78341bbb24f9b18d236df6c",
|
|
||||||
"name": "since",
|
|
||||||
"value": "UTC/ticks/messageId?",
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"headers": [],
|
|
||||||
"authentication": {},
|
|
||||||
"metaSortKey": -1643816956965,
|
|
||||||
"isPrivate": false,
|
|
||||||
"settingStoreCookies": true,
|
|
||||||
"settingSendCookies": true,
|
|
||||||
"settingDisableRenderRequestBody": false,
|
|
||||||
"settingEncodeUrl": true,
|
|
||||||
"settingRebuildPath": true,
|
|
||||||
"settingFollowRedirects": "global",
|
|
||||||
"_type": "request"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
|
||||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
|
||||||
"modified": 1642377560788,
|
|
||||||
"created": 1608987862952,
|
|
||||||
"name": "Base Environment",
|
|
||||||
"data": {},
|
|
||||||
"dataPropertyOrder": {},
|
|
||||||
"color": null,
|
|
||||||
"isPrivate": false,
|
|
||||||
"metaSortKey": 1608987862952,
|
|
||||||
"_type": "environment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "jar_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
|
||||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
|
||||||
"modified": 1642378408249,
|
|
||||||
"created": 1608987862953,
|
|
||||||
"name": "Default Jar",
|
|
||||||
"cookies": [
|
|
||||||
{
|
|
||||||
"key": "oc_sessionPassphrase",
|
|
||||||
"value": "nMmK2Vb0FhKNaSQiPteel3EhA5swjs9%2FYJwU79BzPrq1cgNmCdL2w88ktlfpy8BXU1Yuj7MKSmCLNYMB0NIDFZ%2B7AnbhqpW0am5cFolB4eRzT2uSUSg16nKnAPVXAkGa",
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=Lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:48:03.279Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:48:03.279Z",
|
|
||||||
"id": "06476282363234076"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "occ7de4yt74j",
|
|
||||||
"value": "dsleqfmojuisnmgcq63p0nrk77",
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=Lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:48:03.279Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "9557040429295196"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "__Host-nc_sameSiteCookielax",
|
|
||||||
"value": "true",
|
|
||||||
"expires": "2100-12-31T23:59:59.000Z",
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:48:03.280Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
|
||||||
"id": "4410522677170672"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "__Host-nc_sameSiteCookiestrict",
|
|
||||||
"value": "true",
|
|
||||||
"expires": "2100-12-31T23:59:59.000Z",
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=strict" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:48:03.280Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
|
||||||
"id": "7173600438498109"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2021-05-04T11:49:33.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/apps/dashboard",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.818Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
|
||||||
"id": "3533626334860853"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/apps/dashboard",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.818Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
|
||||||
"id": "06043516223469947"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/apps/dashboard",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.819Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
|
||||||
"id": "1905708786875855"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/apps/dashboard",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.819Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
|
||||||
"id": "4672408440036"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.819Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "7693972008479257"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.819Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "9731704342625882"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2021-05-04T10:49:20.819Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "3557084085645068"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2021-05-04T11:51:28.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/remote.php/dav/files",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:51:28.651Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:51:28.651Z",
|
|
||||||
"id": "8645799753636227"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/remote.php/dav/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:51:44.619Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
|
||||||
"id": "9029869294914612"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/remote.php/dav/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:51:44.619Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
|
||||||
"id": "6422488233461934"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/remote.php/dav/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T10:51:44.619Z",
|
|
||||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
|
||||||
"id": "7557066093242031"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2021-05-04T12:01:26.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/index.php/login",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T11:01:26.192Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:26.192Z",
|
|
||||||
"id": "7093910949861533"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/index.php/login",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T11:01:42.223Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.223Z",
|
|
||||||
"id": "677709203943275"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/index.php/login",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T11:01:42.224Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "9477816718956802"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "nuvola.xyz",
|
|
||||||
"path": "/index.php/login",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2021-05-04T11:01:42.224Z",
|
|
||||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
|
||||||
"id": "41804757195232756"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "oc_sessionPassphrase",
|
|
||||||
"value": "pUIH26mXSdmGTfubB4dUzw8F8MQsUHdwnHCeo7ElPWIGDxXte2XC1M0EMyN5eriarJcMmkfh4kSGFI9%2BRcs%2BHt2gdDygddkz5I7Tl8KtjkwC%2BawIYuL8z3lobWTL%2BCy%2B",
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=Lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:03:10.727Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:03:10.727Z",
|
|
||||||
"id": "06313521114695941"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "__Host-nc_sameSiteCookielax",
|
|
||||||
"value": "true",
|
|
||||||
"expires": "2100-12-31T23:59:59.000Z",
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:03:10.728Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
|
||||||
"id": "860532957782806"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "__Host-nc_sameSiteCookiestrict",
|
|
||||||
"value": "true",
|
|
||||||
"expires": "2100-12-31T23:59:59.000Z",
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=strict" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:03:10.728Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
|
||||||
"id": "31117326197847706"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "ocl7d3yrw74j",
|
|
||||||
"value": "uecghev7rleu4uptmruhptuvto",
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"extensions": [ "SameSite=Lax" ],
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:03:10.728Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:13:28.249Z",
|
|
||||||
"id": "5917940161261273"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2022-01-17T01:03:09.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/index.php/login",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:03:10.728Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
|
||||||
"id": "009292942805962934"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/index.php/login/v2",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.252Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
|
||||||
"id": "5470762369580981"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/index.php/login/v2",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.252Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
|
||||||
"id": "4385020724400406"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/index.php/login/v2",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.253Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:04:18.253Z",
|
|
||||||
"id": "05297254853120181"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.253Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "46009278671166176"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.253Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "05812364381235913"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"creation": "2022-01-17T00:04:18.253Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "3772361407666647"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2022-01-17T01:05:17.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/apps/files",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:05:18.388Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
|
||||||
"id": "08277815359810625"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/apps/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:05:18.388Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
|
||||||
"id": "7962572269421415"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/apps/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:05:18.388Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
|
||||||
"id": "8604909194077435"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/apps/files",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:05:18.388Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
|
||||||
"id": "6043027104701622"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cookie_test",
|
|
||||||
"value": "test",
|
|
||||||
"expires": "2022-01-17T01:11:28.000Z",
|
|
||||||
"maxAge": 3600,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/ocs/v1.php/cloud",
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:11:28.872Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:11:28.872Z",
|
|
||||||
"id": "014769824799037545"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_username",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/ocs/v1.php/cloud",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:12:52.404Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "7141536696124962"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_token",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/ocs/v1.php/cloud",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:12:52.404Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "08455474112707173"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "nc_session_id",
|
|
||||||
"value": "deleted",
|
|
||||||
"expires": "1970-01-01T00:00:01.000Z",
|
|
||||||
"maxAge": 0,
|
|
||||||
"domain": "cloud.zigzag.academy",
|
|
||||||
"path": "/ocs/v1.php/cloud",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"hostOnly": true,
|
|
||||||
"pathIsDefault": true,
|
|
||||||
"creation": "2022-01-17T00:12:52.404Z",
|
|
||||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
|
||||||
"id": "141765916939931"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"_type": "cookie_jar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "spc_f832b28de5204b4a88374d08ba93a8e2",
|
|
||||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
|
||||||
"modified": 1608987862912,
|
|
||||||
"created": 1608987862912,
|
|
||||||
"fileName": "Insomnia",
|
|
||||||
"contents": "",
|
|
||||||
"contentType": "yaml",
|
|
||||||
"_type": "api_spec"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_id": "env_77dfa781871a430f8e5014b07c9f9061",
|
|
||||||
"parentId": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
|
||||||
"modified": 1642377578797,
|
|
||||||
"created": 1642377566379,
|
|
||||||
"name": "Nextcloud API",
|
|
||||||
"data": {},
|
|
||||||
"dataPropertyOrder": {},
|
|
||||||
"color": null,
|
|
||||||
"isPrivate": false,
|
|
||||||
"metaSortKey": 1642377566379,
|
|
||||||
"_type": "environment"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,15 @@
|
|||||||
using Blazored.LocalStorage;
|
using Blazored.LocalStorage;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Helpers;
|
using decePubClient.Helpers;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
using decePubClient.Services;
|
using decePubClient.Services;
|
||||||
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -28,24 +21,6 @@ namespace decePubClient.Extensions
|
|||||||
{
|
{
|
||||||
public static partial class ExtensionMethods
|
public static partial class ExtensionMethods
|
||||||
{
|
{
|
||||||
#region Navigation Manager
|
|
||||||
|
|
||||||
public static NameValueCollection QueryString(this NavigationManager navigationManager) =>
|
|
||||||
HttpUtility.ParseQueryString(new Uri(navigationManager.Uri).Query);
|
|
||||||
|
|
||||||
public static string QueryString(this NavigationManager navigationManager, string key) =>
|
|
||||||
navigationManager.QueryString()[key];
|
|
||||||
|
|
||||||
public static T QueryString<T>(this NavigationManager navigationManager, string key)
|
|
||||||
{
|
|
||||||
var value = navigationManager.QueryString()[key];
|
|
||||||
if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
|
|
||||||
return default;
|
|
||||||
return (T)Convert.ChangeType(value, typeof(T));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Navigation Manager
|
|
||||||
|
|
||||||
#region Setups
|
#region Setups
|
||||||
|
|
||||||
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
|
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
|
||||||
@@ -82,30 +57,6 @@ namespace decePubClient.Extensions
|
|||||||
|
|
||||||
#endregion Setups
|
#endregion Setups
|
||||||
|
|
||||||
public static string GetLogLevelClass(this string logType) => logType switch
|
|
||||||
{
|
|
||||||
"Debug" => "text-base-content/60",
|
|
||||||
"Information" => "text-info",
|
|
||||||
"Warning" => "text-warning",
|
|
||||||
"Error" => "text-error",
|
|
||||||
"Fatal" => "text-error font-bold",
|
|
||||||
_ => string.Empty
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
#region Formats
|
|
||||||
|
|
||||||
public static MarkdownPipeline Pipeline =>
|
|
||||||
new MarkdownPipelineBuilder().UseGenericAttributes().Build();
|
|
||||||
|
|
||||||
public static string ToJson(this object value) =>
|
|
||||||
JsonSerializer.Serialize(value);
|
|
||||||
|
|
||||||
public static string ToBase64PngImage(this string base64Image) =>
|
|
||||||
$"data:image/png;base64,{base64Image}";
|
|
||||||
|
|
||||||
#endregion Formats
|
|
||||||
|
|
||||||
#region Models
|
#region Models
|
||||||
|
|
||||||
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
|
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
|
||||||
@@ -240,25 +191,6 @@ namespace decePubClient.Extensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
|
|
||||||
{
|
|
||||||
var timeframe = DateTime.Now - creationDate.ToLocalTime();
|
|
||||||
switch ((int)timeframe.TotalHours)
|
|
||||||
{
|
|
||||||
case >= 24:
|
|
||||||
return localizer["{0} day/s", (int)timeframe.TotalDays];
|
|
||||||
|
|
||||||
case >= 1 and < 24:
|
|
||||||
return localizer["{0} hour/s", (int)timeframe.TotalHours];
|
|
||||||
|
|
||||||
case 0:
|
|
||||||
return localizer["{0} minute/s", (int)timeframe.TotalMinutes];
|
|
||||||
|
|
||||||
default:
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AuthorizationPolicy IsAdminPolicy() =>
|
public static AuthorizationPolicy IsAdminPolicy() =>
|
||||||
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
||||||
.RequireClaim(Policies.IsAdmin, "true")
|
.RequireClaim(Policies.IsAdmin, "true")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using collAnon.Client.Services;
|
using System.Collections.Specialized;
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -12,12 +11,6 @@ using decePubClient.Models.Types;
|
|||||||
using decePubClient.Resources;
|
using decePubClient.Resources;
|
||||||
using decePubClient.Services;
|
using decePubClient.Services;
|
||||||
|
|
||||||
using DnetIndexedDb;
|
|
||||||
using DnetIndexedDb.Fluent;
|
|
||||||
using DnetIndexedDb.Models;
|
|
||||||
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
@@ -26,26 +19,6 @@ namespace decePubClient.Extensions;
|
|||||||
|
|
||||||
public static class GenericExtensions
|
public static class GenericExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddIndexedDb(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
return services.AddIndexedDbDatabase<IndexedDb>(options =>
|
|
||||||
{
|
|
||||||
var model = new IndexedDbDatabaseModel()
|
|
||||||
.WithName("data")
|
|
||||||
.WithVersion(3)
|
|
||||||
.WithModelId(1);
|
|
||||||
|
|
||||||
// IStorage.AddLog numbers entries itself (max key + 1), so the key is Id rather than generated. Version 2
|
|
||||||
// added this store; version 3 left the mock feed's Message store out of the model.
|
|
||||||
model.AddStore(nameof(ClientLogs))
|
|
||||||
.WithKey(nameof(ClientLogs.Id))
|
|
||||||
.AddUniqueIndex(nameof(ClientLogs.Id))
|
|
||||||
.AddIndex(nameof(ClientLogs.TimeStamp));
|
|
||||||
|
|
||||||
options.UseDatabase(model);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetPassedTime(this DateTime dateTime, CoalescingStringLocalizer localizer)
|
public static string GetPassedTime(this DateTime dateTime, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
var timeframe = DateTime.Now - dateTime.ToLocalTime();
|
var timeframe = DateTime.Now - dateTime.ToLocalTime();
|
||||||
@@ -57,23 +30,32 @@ 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],
|
||||||
_ => localizer["{0}KB", size / 1_024]
|
_ => localizer["{0}KB", size / 1_024]
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string GetMessageTypeIcon(this MessageType messageType)
|
public static string GetVisibilityIcon(this Visibility visibility)
|
||||||
{
|
{
|
||||||
switch (messageType)
|
switch (visibility)
|
||||||
{
|
{
|
||||||
case MessageType.Public:
|
case Visibility.Public:
|
||||||
return "ion-md-globe";
|
return "ion-md-globe";
|
||||||
case MessageType.Unlisted:
|
case Visibility.Unlisted:
|
||||||
return "ion-md-unlock";
|
return "ion-md-unlock";
|
||||||
case MessageType.FollowersOnly:
|
case Visibility.FollowersOnly:
|
||||||
return "ion-md-lock";
|
return "ion-md-lock";
|
||||||
case MessageType.Direct:
|
case Visibility.Direct:
|
||||||
return "ion-md-paper-plane";
|
return "ion-md-paper-plane";
|
||||||
default:
|
default:
|
||||||
return default;
|
return default;
|
||||||
@@ -92,6 +74,7 @@ public static class GenericExtensions
|
|||||||
TimelineType.Favourites => "ion-md-heart",
|
TimelineType.Favourites => "ion-md-heart",
|
||||||
TimelineType.Bookmarks => "ion-md-bookmark",
|
TimelineType.Bookmarks => "ion-md-bookmark",
|
||||||
TimelineType.Tag => "ion-md-pricetag",
|
TimelineType.Tag => "ion-md-pricetag",
|
||||||
|
TimelineType.Lists => "ion-md-list",
|
||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,8 +86,6 @@ public static class GenericExtensions
|
|||||||
return "ion-md-images";
|
return "ion-md-images";
|
||||||
case MediaType.Video:
|
case MediaType.Video:
|
||||||
return "ion-md-videocam";
|
return "ion-md-videocam";
|
||||||
case MediaType.Documents:
|
|
||||||
return "ion-md-document";
|
|
||||||
case MediaType.Audio:
|
case MediaType.Audio:
|
||||||
return "ion-md-volume-high";
|
return "ion-md-volume-high";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace decePubClient.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A post's or an account's custom emoji (`:shortcode:` among its <c>emojis</c>) shown as their pictures. The shortcode
|
||||||
|
/// stays as the picture's alt text, so it is what a screen reader reads and what is copied. Only shortcodes the
|
||||||
|
/// server listed for that post or account are replaced, and only with an http(s) picture.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class CustomEmojis
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@":([A-Za-z0-9_+-]{1,64}):")]
|
||||||
|
private static partial Regex Shortcode();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(<[^>]*>)")]
|
||||||
|
private static partial Regex Tag();
|
||||||
|
|
||||||
|
/// <summary>Plain text (a name, a content warning): escaped, then its shortcodes as pictures.</summary>
|
||||||
|
public static MarkupString Text(string text, IReadOnlyCollection<CustomEmoji> emojis) =>
|
||||||
|
new(Replace(WebUtility.HtmlEncode(text ?? string.Empty), emojis));
|
||||||
|
|
||||||
|
/// <summary>HTML the server sanitized: the shortcodes in its text, never inside a tag.</summary>
|
||||||
|
public static string Html(string html, IReadOnlyCollection<CustomEmoji> emojis) =>
|
||||||
|
emojis.Count == 0 || string.IsNullOrEmpty(html)
|
||||||
|
? html
|
||||||
|
: string.Concat(Tag().Split(html).Select(part => part.StartsWith('<') ? part : Replace(part, emojis)));
|
||||||
|
|
||||||
|
static string Replace(string text, IReadOnlyCollection<CustomEmoji> emojis)
|
||||||
|
{
|
||||||
|
if (emojis.Count == 0 || !text.Contains(':'))
|
||||||
|
return text;
|
||||||
|
var known = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var emoji in emojis)
|
||||||
|
if (!string.IsNullOrEmpty(emoji.Shortcode) && Uri.TryCreate(emoji.Url, UriKind.Absolute, out var url)
|
||||||
|
&& url.Scheme is "https" or "http")
|
||||||
|
known.TryAdd(emoji.Shortcode, url.AbsoluteUri);
|
||||||
|
return Shortcode().Replace(text, match => known.TryGetValue(match.Groups[1].Value, out var url)
|
||||||
|
? $"<img class=\"custom-emoji\" src=\"{WebUtility.HtmlEncode(url)}\" alt=\"{match.Value}\" title=\"{match.Value}\" draggable=\"false\" />"
|
||||||
|
: match.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace decePubClient.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>The languages a post can be marked as written in: ISO 639-1 codes and each language's own name.</summary>
|
||||||
|
public static class Languages
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<(string Code, string Name)> All =
|
||||||
|
[
|
||||||
|
("ar", "العربية"), ("bg", "Български"), ("ca", "Català"), ("cs", "Čeština"), ("cy", "Cymraeg"), ("da", "Dansk"),
|
||||||
|
("de", "Deutsch"), ("el", "Ελληνικά"), ("en", "English"), ("eo", "Esperanto"), ("es", "Español"), ("et", "Eesti"),
|
||||||
|
("eu", "Euskara"), ("fa", "فارسی"), ("fi", "Suomi"), ("fr", "Français"), ("ga", "Gaeilge"), ("gl", "Galego"),
|
||||||
|
("he", "עברית"), ("hi", "हिन्दी"), ("hr", "Hrvatski"), ("hu", "Magyar"), ("id", "Bahasa Indonesia"), ("is", "Íslenska"),
|
||||||
|
("it", "Italiano"), ("ja", "日本語"), ("ko", "한국어"), ("lt", "Lietuvių"), ("lv", "Latviešu"), ("nl", "Nederlands"),
|
||||||
|
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"), ("ru", "Русский"), ("sk", "Slovenčina"),
|
||||||
|
("sl", "Slovenščina"), ("sr", "Српски"), ("sv", "Svenska"), ("th", "ไทย"), ("tr", "Türkçe"), ("uk", "Українська"),
|
||||||
|
("vi", "Tiếng Việt"), ("zh", "中文")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Net.Mail;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Text.Json.Serialization.Metadata;
|
using System.Text.Json.Serialization.Metadata;
|
||||||
@@ -108,58 +107,5 @@ namespace decePubClient.Helpers
|
|||||||
|
|
||||||
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
|
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
|
||||||
conditionIsTrue ? ifTrue : ifFalse;
|
conditionIsTrue ? ifTrue : ifFalse;
|
||||||
|
|
||||||
public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
|
|
||||||
{
|
|
||||||
// convert datetime to a TimeSpan
|
|
||||||
var now = datetime.TimeOfDay;
|
|
||||||
// see if start comes before end
|
|
||||||
if (start < end)
|
|
||||||
return start <= now && now <= end;
|
|
||||||
// start is after end, so do the inverse comparison
|
|
||||||
return !(end < now && now < start);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetFileIcon(string fileName)
|
|
||||||
{
|
|
||||||
switch (Path.GetExtension(fileName))
|
|
||||||
{
|
|
||||||
case ".odp":
|
|
||||||
case ".pptx":
|
|
||||||
return "file-powerpoint";
|
|
||||||
|
|
||||||
case ".ods":
|
|
||||||
case ".xlsx":
|
|
||||||
return "file-excel";
|
|
||||||
|
|
||||||
case ".odt":
|
|
||||||
case ".docx":
|
|
||||||
return "file-word";
|
|
||||||
|
|
||||||
case ".pdf":
|
|
||||||
return "file-pdf";
|
|
||||||
|
|
||||||
case ".jpg":
|
|
||||||
case ".jpeg":
|
|
||||||
case ".png":
|
|
||||||
return "file-image";
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "file";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsValidEmail(string email)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var addr = new MailAddress(email);
|
|
||||||
return addr.Address == email;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
|
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
|
||||||
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
|
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
|
||||||
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
|
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
|
||||||
compare PersonaId to reload what belongs to the persona. *@
|
compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's:
|
||||||
|
taken at sign-in and at start-up, saved there a moment after it changes here. *@
|
||||||
<CascadingValue IsFixed=false Value=this>
|
<CascadingValue IsFixed=false Value=this>
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
@@ -11,6 +12,8 @@
|
|||||||
@code {
|
@code {
|
||||||
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
|
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
|
||||||
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
|
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
|
||||||
|
/// <summary>How long a change of the page settings waits before it is saved as the login's: a slider sends many.</summary>
|
||||||
|
static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500);
|
||||||
|
|
||||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
[Inject] ILocalStorageService Storage { get; set; }
|
[Inject] ILocalStorageService Storage { get; set; }
|
||||||
@@ -27,6 +30,8 @@
|
|||||||
public PublicCacheData PublicCacheData { get; private set; } = new();
|
public PublicCacheData PublicCacheData { get; private set; } = new();
|
||||||
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
||||||
public bool IsSignedIn { get; private set; }
|
public bool IsSignedIn { get; private set; }
|
||||||
|
/// <summary>The session has been read once: from then on a null PersonaId means the login has no persona, not "not yet".</summary>
|
||||||
|
public bool IsSessionLoaded { get; private set; }
|
||||||
/// <summary>The root's login name, shown to its owner only.</summary>
|
/// <summary>The root's login name, shown to its owner only.</summary>
|
||||||
public string RootUserName { get; private set; }
|
public string RootUserName { get; private set; }
|
||||||
/// <summary>The root's personas.</summary>
|
/// <summary>The root's personas.</summary>
|
||||||
@@ -40,6 +45,11 @@
|
|||||||
PeriodicTimer onlineTimer;
|
PeriodicTimer onlineTimer;
|
||||||
PeriodicTimer refreshTimer;
|
PeriodicTimer refreshTimer;
|
||||||
readonly CancellationTokenSource disposeCancellation = new();
|
readonly CancellationTokenSource disposeCancellation = new();
|
||||||
|
/// <summary>What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already
|
||||||
|
/// match it, the login's own just taken among them, aren't saved again.</summary>
|
||||||
|
(string, short, short, bool, bool, bool, bool)? loginSettings;
|
||||||
|
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
|
||||||
|
CancellationTokenSource pendingSettingsSave;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -57,6 +67,8 @@
|
|||||||
_ = PollOnline(cancellationToken);
|
_ = PollOnline(cancellationToken);
|
||||||
|
|
||||||
await LoadSession(cancellationToken);
|
await LoadSession(cancellationToken);
|
||||||
|
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
|
||||||
|
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
|
||||||
refreshTimer = new(RefreshInterval);
|
refreshTimer = new(RefreshInterval);
|
||||||
_ = KeepSessionFresh(cancellationToken);
|
_ = KeepSessionFresh(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -75,13 +87,98 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary>
|
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the
|
||||||
|
/// login's a moment later (SaveSettingsSoon).</summary>
|
||||||
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
|
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
change(PublicCacheData.PageSettings);
|
change(PublicCacheData.PageSettings);
|
||||||
|
SaveSettingsSoon();
|
||||||
return UpdatePublicCache(PublicCacheData, cancellationToken);
|
return UpdatePublicCache(PublicCacheData, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the
|
||||||
|
/// login's settings), then keeps the theme with the login's (KeepThemeWithLogin).
|
||||||
|
/// </summary>
|
||||||
|
public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
loginSettings = null;
|
||||||
|
await LoadSession(cancellationToken);
|
||||||
|
await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the
|
||||||
|
/// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing.
|
||||||
|
/// </summary>
|
||||||
|
async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (settings.SavedAt is null)
|
||||||
|
{
|
||||||
|
SaveSettingsSoon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PublicCacheData.PageSettings.TakeLoginTheme(settings);
|
||||||
|
// as the login keeps them now, so taking them isn't followed by saving them back
|
||||||
|
loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings());
|
||||||
|
await UpdatePublicCache(PublicCacheData, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with
|
||||||
|
/// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure
|
||||||
|
/// is logged (AuthService.SaveSettings), never shown.
|
||||||
|
/// </summary>
|
||||||
|
void SaveSettingsSoon()
|
||||||
|
{
|
||||||
|
CancelSettingsSave();
|
||||||
|
var settings = PublicCacheData.PageSettings;
|
||||||
|
if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pendingSettingsSave = new();
|
||||||
|
_ = SaveSettingsLater(pendingSettingsSave.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SaveSettingsLater(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(SettingsSaveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSignedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var settings = PublicCacheData.PageSettings.ToLoginSettings();
|
||||||
|
var result = await AuthService.SaveSettings(settings, cancellationToken);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
loginSettings = LoginFingerprint(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CancelSettingsSave()
|
||||||
|
{
|
||||||
|
var pending = pendingSettingsSave;
|
||||||
|
pendingSettingsSave = null;
|
||||||
|
pending?.Cancel();
|
||||||
|
pending?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved.</summary>
|
||||||
|
static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) =>
|
||||||
|
(settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming,
|
||||||
|
settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
|
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
|
||||||
/// acts as the last one used (the first one otherwise).
|
/// acts as the last one used (the first one otherwise).
|
||||||
@@ -93,6 +190,7 @@
|
|||||||
if (!IsSignedIn)
|
if (!IsSignedIn)
|
||||||
{
|
{
|
||||||
ClearSession();
|
ClearSession();
|
||||||
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,11 +203,16 @@
|
|||||||
{
|
{
|
||||||
PersonaId = null;
|
PersonaId = null;
|
||||||
Persona = null;
|
Persona = null;
|
||||||
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loaded once the persona is known (or couldn't be used): a page rendered while it is switched to would otherwise
|
||||||
|
// see a loaded session without one, and the feed would say it is empty
|
||||||
await SwitchPersona(personaId, cancellationToken);
|
await SwitchPersona(personaId, cancellationToken);
|
||||||
|
IsSessionLoaded = true;
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
||||||
@@ -127,9 +230,33 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the root's personas again, after one was made.</summary>
|
||||||
|
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Personas = await AuthService.Personas(cancellationToken);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
|
||||||
|
/// chooser shows the new name and picture too.</summary>
|
||||||
|
public void PersonaChanged(Account account)
|
||||||
|
{
|
||||||
|
if (account is null || PersonaId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Persona = account;
|
||||||
|
if (Personas.FirstOrDefault(p => p.Id == PersonaId) is { } persona)
|
||||||
|
{
|
||||||
|
persona.Name = account.DisplayName;
|
||||||
|
persona.PictureURL = account.Avatar;
|
||||||
|
}
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
||||||
public async Task SignOut(CancellationToken cancellationToken = default)
|
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
CancelSettingsSave();
|
||||||
await AuthService.Logout(cancellationToken);
|
await AuthService.Logout(cancellationToken);
|
||||||
ClearSession();
|
ClearSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -137,6 +264,8 @@
|
|||||||
|
|
||||||
void ClearSession()
|
void ClearSession()
|
||||||
{
|
{
|
||||||
|
CancelSettingsSave();
|
||||||
|
loginSettings = null;
|
||||||
IsSignedIn = false;
|
IsSignedIn = false;
|
||||||
RootUserName = null;
|
RootUserName = null;
|
||||||
Personas = [];
|
Personas = [];
|
||||||
@@ -205,6 +334,7 @@
|
|||||||
{
|
{
|
||||||
await disposeCancellation.CancelAsync();
|
await disposeCancellation.CancelAsync();
|
||||||
disposeCancellation.Dispose();
|
disposeCancellation.Dispose();
|
||||||
|
CancelSettingsSave();
|
||||||
onlineTimer?.Dispose();
|
onlineTimer?.Dispose();
|
||||||
refreshTimer?.Dispose();
|
refreshTimer?.Dispose();
|
||||||
cascadingStateReference?.Dispose();
|
cascadingStateReference?.Dispose();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Services;
|
using decePubClient.Services;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace decePubClient.LayerComponents
|
|
||||||
{
|
|
||||||
public class PagesBase : LocalizableComponentBase
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,11 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(Account))]
|
[JsonSerializable(typeof(Account))]
|
||||||
[JsonSerializable(typeof(List<Account>))]
|
[JsonSerializable(typeof(List<Account>))]
|
||||||
[JsonSerializable(typeof(Relationship))]
|
[JsonSerializable(typeof(Relationship))]
|
||||||
|
[JsonSerializable(typeof(List<Relationship>))]
|
||||||
|
[JsonSerializable(typeof(List<string>))]
|
||||||
|
[JsonSerializable(typeof(SearchResults))]
|
||||||
[JsonSerializable(typeof(MediaAttachment))]
|
[JsonSerializable(typeof(MediaAttachment))]
|
||||||
|
[JsonSerializable(typeof(InstanceV2))]
|
||||||
[JsonSerializable(typeof(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
[JsonSerializable(typeof(List<Notification>))]
|
[JsonSerializable(typeof(List<Notification>))]
|
||||||
[JsonSerializable(typeof(Conversation))]
|
[JsonSerializable(typeof(Conversation))]
|
||||||
@@ -31,6 +35,38 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(TokenResponse))]
|
[JsonSerializable(typeof(TokenResponse))]
|
||||||
[JsonSerializable(typeof(MastodonError))]
|
[JsonSerializable(typeof(MastodonError))]
|
||||||
[JsonSerializable(typeof(PostStatusForm))]
|
[JsonSerializable(typeof(PostStatusForm))]
|
||||||
|
[JsonSerializable(typeof(StatusEditForm))]
|
||||||
|
[JsonSerializable(typeof(StatusSource))]
|
||||||
|
[JsonSerializable(typeof(List<StatusEdit>))]
|
||||||
|
[JsonSerializable(typeof(ReportRequest))]
|
||||||
|
[JsonSerializable(typeof(NotificationPolicy))]
|
||||||
|
[JsonSerializable(typeof(List<NotificationRequest>))]
|
||||||
|
[JsonSerializable(typeof(Poll))]
|
||||||
|
[JsonSerializable(typeof(PollVoteForm))]
|
||||||
|
[JsonSerializable(typeof(MastodonList))]
|
||||||
|
[JsonSerializable(typeof(List<MastodonList>))]
|
||||||
|
[JsonSerializable(typeof(ListAccountsForm))]
|
||||||
|
[JsonSerializable(typeof(Filter))]
|
||||||
|
[JsonSerializable(typeof(List<Filter>))]
|
||||||
|
[JsonSerializable(typeof(FilterForm))]
|
||||||
|
[JsonSerializable(typeof(FilterStatus))]
|
||||||
|
[JsonSerializable(typeof(ScheduledStatus))]
|
||||||
|
[JsonSerializable(typeof(List<ScheduledStatus>))]
|
||||||
|
[JsonSerializable(typeof(ScheduleForm))]
|
||||||
|
[JsonSerializable(typeof(Tag))]
|
||||||
|
[JsonSerializable(typeof(List<Tag>))]
|
||||||
|
[JsonSerializable(typeof(List<TrendLink>))]
|
||||||
|
[JsonSerializable(typeof(GroupedNotifications))]
|
||||||
|
[JsonSerializable(typeof(MarkersForm))]
|
||||||
|
[JsonSerializable(typeof(UnreadCount))]
|
||||||
|
[JsonSerializable(typeof(InstanceRule))]
|
||||||
|
[JsonSerializable(typeof(List<InstanceRule>))]
|
||||||
|
[JsonSerializable(typeof(InstanceDocument))]
|
||||||
|
[JsonSerializable(typeof(TermsOfService))]
|
||||||
|
[JsonSerializable(typeof(List<PublicDomainBlock>))]
|
||||||
|
[JsonSerializable(typeof(Announcement))]
|
||||||
|
[JsonSerializable(typeof(List<Announcement>))]
|
||||||
|
[JsonSerializable(typeof(AnnouncementReactionEvent))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+147
-4
@@ -9,19 +9,86 @@ public static class APIs
|
|||||||
{
|
{
|
||||||
public const string ClientApiPrefix = "clientapi/";
|
public const string ClientApiPrefix = "clientapi/";
|
||||||
public const string MastodonPrefix = "api/";
|
public const string MastodonPrefix = "api/";
|
||||||
public const string OAuthPrefix = "oauth/";
|
|
||||||
|
|
||||||
public static class ClientApi
|
public static class ClientApi
|
||||||
{
|
{
|
||||||
public const string POST_Login = "clientapi/user/login";
|
public const string POST_Login = "clientapi/user/login";
|
||||||
|
public const string GET_Setup = "clientapi/user/setup";
|
||||||
|
public const string POST_Setup = "clientapi/user/setup";
|
||||||
|
public const string POST_InvitationSignUp = "clientapi/user/invitation/signup";
|
||||||
public const string GET_Logout = "clientapi/user/logout";
|
public const string GET_Logout = "clientapi/user/logout";
|
||||||
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
||||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
public const string POST_UpdatePassword = "clientapi/user/update/password";
|
||||||
|
public const string POST_LogoutEverywhere = "clientapi/user/logout/everywhere";
|
||||||
|
public const string POST_RecoverPassword = "clientapi/user/recover/password";
|
||||||
|
public const string POST_RecoverValid = "clientapi/user/recover/valid";
|
||||||
|
public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password";
|
||||||
|
public const string GET_Account = "clientapi/user/account";
|
||||||
|
public const string POST_Email = "clientapi/user/email";
|
||||||
|
public const string POST_EmailVerify = "clientapi/user/email/verify";
|
||||||
|
public const string POST_EmailRemove = "clientapi/user/email/remove";
|
||||||
|
public const string POST_TotpSetup = "clientapi/user/totp/setup";
|
||||||
|
public const string POST_TotpEnable = "clientapi/user/totp/enable";
|
||||||
|
public const string POST_TotpDisable = "clientapi/user/totp/disable";
|
||||||
|
public const string GET_UserSettings = "clientapi/user/settings";
|
||||||
|
public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
|
||||||
|
public const string POST_DeleteAccount = "clientapi/user/delete";
|
||||||
public const string GET_Personas = "clientapi/avatar/private/list";
|
public const string GET_Personas = "clientapi/avatar/private/list";
|
||||||
|
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||||
public const string GET_Groups = "clientapi/group/list";
|
public const string GET_Groups = "clientapi/group/list";
|
||||||
|
public const string GET_GroupMembers = "clientapi/group/members";
|
||||||
|
public const string POST_InsertGroup = "clientapi/group/insert";
|
||||||
|
public const string POST_UpdateGroup = "clientapi/group/update";
|
||||||
|
public const string POST_JoinGroup = "clientapi/group/join";
|
||||||
|
public const string POST_LeaveGroup = "clientapi/group/leave";
|
||||||
|
public const string POST_ApproveGroupMember = "clientapi/group/approve";
|
||||||
|
public const string POST_RejectGroupMember = "clientapi/group/reject";
|
||||||
|
public const string POST_RemoveGroupMember = "clientapi/group/remove";
|
||||||
public const string GET_Posts = "clientapi/post/list";
|
public const string GET_Posts = "clientapi/post/list";
|
||||||
public const string GET_Nearby = "clientapi/post/nearby";
|
public const string GET_Nearby = "clientapi/post/nearby";
|
||||||
public const string POST_InsertPost = "clientapi/post/insert";
|
public const string POST_InsertPost = "clientapi/post/insert";
|
||||||
|
public const string POST_DeletePost = "clientapi/post/delete";
|
||||||
|
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||||
|
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
||||||
|
public const string GET_ModeratorLog = "clientapi/moderator/log";
|
||||||
|
public const string GET_AdminSettings = "clientapi/admin/settings";
|
||||||
|
public const string POST_AdminSettings = "clientapi/admin/settings";
|
||||||
|
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
|
||||||
|
public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail";
|
||||||
|
public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list";
|
||||||
|
public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert";
|
||||||
|
public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update";
|
||||||
|
public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}";
|
||||||
|
public const string GET_AdminRelays = "clientapi/admin/relays";
|
||||||
|
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
|
||||||
|
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
|
||||||
|
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
|
||||||
|
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
|
||||||
|
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
|
||||||
|
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||||
|
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||||
|
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||||
|
public static string GET_M_StatisticsHost(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}";
|
||||||
|
public static string POST_M_StatisticsDescribe(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}/describe";
|
||||||
|
public const string GET_StatisticsEvents = "clientapi/admin/statistics/events";
|
||||||
|
public const string GET_StatisticsServer = "clientapi/admin/statistics/server";
|
||||||
|
public const string GET_AdminBackups = "clientapi/admin/backups";
|
||||||
|
public const string POST_AdminBackup = "clientapi/admin/backups";
|
||||||
|
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket";
|
||||||
|
public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore";
|
||||||
|
public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads";
|
||||||
|
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
|
||||||
|
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
|
||||||
|
public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
|
||||||
|
public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
|
||||||
|
public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
|
||||||
|
public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class OAuth
|
public static class OAuth
|
||||||
@@ -36,6 +103,12 @@ public static class APIs
|
|||||||
public static class Mastodon
|
public static class Mastodon
|
||||||
{
|
{
|
||||||
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
||||||
|
public const string PATCH_UpdateCredentials = "api/v1/accounts/update_credentials";
|
||||||
|
public const string GET_Mutes = "api/v1/mutes";
|
||||||
|
public const string GET_Blocks = "api/v1/blocks";
|
||||||
|
public const string GET_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string POST_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string DELETE_DomainBlocks = "api/v1/domain_blocks";
|
||||||
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
||||||
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
||||||
public const string GET_Statuses = "api/v1/statuses";
|
public const string GET_Statuses = "api/v1/statuses";
|
||||||
@@ -43,27 +116,97 @@ public static class APIs
|
|||||||
public const string GET_Favourites = "api/v1/favourites";
|
public const string GET_Favourites = "api/v1/favourites";
|
||||||
public const string GET_Bookmarks = "api/v1/bookmarks";
|
public const string GET_Bookmarks = "api/v1/bookmarks";
|
||||||
public const string GET_Conversations = "api/v1/conversations";
|
public const string GET_Conversations = "api/v1/conversations";
|
||||||
public const string GET_Notifications = "api/v1/notifications";
|
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
|
||||||
|
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||||
public const string POST_Media = "api/v2/media";
|
public const string POST_Media = "api/v2/media";
|
||||||
|
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public const string GET_InstanceV2 = "api/v2/instance";
|
||||||
|
public const string GET_InstanceRules = "api/v1/instance/rules";
|
||||||
|
public const string GET_InstanceExtendedDescription = "api/v1/instance/extended_description";
|
||||||
|
public const string GET_InstancePrivacyPolicy = "api/v1/instance/privacy_policy";
|
||||||
|
public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service";
|
||||||
|
public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks";
|
||||||
|
public const string GET_Announcements = "api/v1/announcements";
|
||||||
|
public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss";
|
||||||
|
public static string M_AnnouncementReaction(string announcementId, string emoji) =>
|
||||||
|
$"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}";
|
||||||
|
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
|
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||||
|
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||||
|
public const string GET_Search = "api/v2/search";
|
||||||
|
|
||||||
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
||||||
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
||||||
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
||||||
|
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
||||||
|
public static string GET_M_AccountFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/followers";
|
||||||
|
public static string POST_M_RemoveFromFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/remove_from_followers";
|
||||||
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
||||||
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
|
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
|
||||||
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
|
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
|
||||||
|
public static string M_Reaction(string statusId, string emoji) =>
|
||||||
|
$"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}";
|
||||||
|
public static string POST_M_PollVotes(string pollId) => $"api/v1/polls/{Uri.EscapeDataString(pollId)}/votes";
|
||||||
|
public static string POST_M_FollowRequest(string accountId, string answer) => $"api/v1/follow_requests/{Uri.EscapeDataString(accountId)}/{answer}";
|
||||||
|
public const string GET_Lists = "api/v1/lists";
|
||||||
|
public const string POST_List = "api/v1/lists";
|
||||||
|
public const string GET_AccountSearch = "api/v1/accounts/search";
|
||||||
|
public static string PUT_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
|
||||||
|
public static string DELETE_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
|
||||||
|
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
|
||||||
|
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
|
||||||
|
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
|
||||||
|
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
|
||||||
|
public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source";
|
||||||
|
public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
|
||||||
|
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
||||||
|
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
||||||
|
public const string POST_Reports = "api/v1/reports";
|
||||||
|
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
|
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
|
public const string GET_NotificationRequests = "api/v1/notifications/requests";
|
||||||
|
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
||||||
|
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
||||||
|
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
|
public static string PUT_M_StatusInteractionPolicy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/interaction_policy";
|
||||||
|
public static string POST_M_RevokeQuote(string quotedId, string quotingId) =>
|
||||||
|
$"api/v1/statuses/{Uri.EscapeDataString(quotedId)}/quotes/{Uri.EscapeDataString(quotingId)}/revoke";
|
||||||
|
public const string GET_FollowedTags = "api/v1/followed_tags";
|
||||||
|
public const string GET_GroupedNotifications = "api/v2/notifications";
|
||||||
|
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
|
||||||
|
public static string POST_M_DismissNotificationGroup(string groupKey) => $"api/v2/notifications/{Uri.EscapeDataString(groupKey)}/dismiss";
|
||||||
|
public const string POST_Markers = "api/v1/markers";
|
||||||
|
public const string GET_TrendingTags = "api/v1/trends/tags";
|
||||||
|
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
||||||
|
public const string GET_TrendingLinks = "api/v1/trends/links";
|
||||||
|
public const string GET_Directory = "api/v1/directory";
|
||||||
|
public static string GET_M_Tag(string name) => $"api/v1/tags/{Uri.EscapeDataString(name)}";
|
||||||
|
public static string POST_M_TagAction(string name, string action) => $"api/v1/tags/{Uri.EscapeDataString(name)}/{action}";
|
||||||
|
public static string PUT_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string DELETE_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
|
||||||
|
public const string GET_Filters = "api/v2/filters";
|
||||||
|
public const string POST_Filter = "api/v2/filters";
|
||||||
|
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string DELETE_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string POST_M_FilterStatus(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}/statuses";
|
||||||
|
public static string DELETE_M_FilterStatus(string filterStatusId) => $"api/v2/filters/statuses/{Uri.EscapeDataString(filterStatusId)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PrivaPub
|
public static class PrivaPub
|
||||||
{
|
{
|
||||||
public const string GET_Instances = "api/privapub/v1/instances";
|
public const string GET_Instances = "api/privapub/v1/instances";
|
||||||
public const string GET_Cdns = "api/privapub/v1/cdns";
|
|
||||||
|
|
||||||
public static string GET_M_InstanceHistory(string host) => $"api/privapub/v1/instances/{Uri.EscapeDataString(host)}/history";
|
public static string GET_M_InstanceHistory(string host) => $"api/privapub/v1/instances/{Uri.EscapeDataString(host)}/history";
|
||||||
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
||||||
|
|
||||||
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
||||||
|
public static string POST_M_Join(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/join";
|
||||||
|
public static string POST_M_Leave(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/leave";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ namespace decePubClient.Models;
|
|||||||
|
|
||||||
public class AppConfiguration
|
public class AppConfiguration
|
||||||
{
|
{
|
||||||
public string Version { get; set; }
|
|
||||||
public string ApiBaseAddress { get; set; }
|
public string ApiBaseAddress { get; set; }
|
||||||
/// <summary>PrivaPub's first-party application, the only one allowed to exchange the root JWT for persona tokens.</summary>
|
/// <summary>PrivaPub's first-party application, the only one allowed to exchange the root JWT for persona tokens.</summary>
|
||||||
public string ClientId { get; set; } = "decepub";
|
public string ClientId { get; set; } = "decepub";
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace decePubClient.Models
|
|
||||||
{
|
|
||||||
public class ClientLogs
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public string Where { get; set; }
|
|
||||||
public string WarningMessage { get; set; }
|
|
||||||
public ClientLogException Exception { get; set; }
|
|
||||||
public DateTime TimeStamp { get; set; } = DateTime.Now;
|
|
||||||
/// <summary>The entry as HTML for a log viewer: the warning text, or the exception's details.</summary>
|
|
||||||
public string ErrorMessage()
|
|
||||||
{
|
|
||||||
if (Exception is null)
|
|
||||||
return WarningMessage;
|
|
||||||
|
|
||||||
var details = @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
|
|
||||||
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br>
|
|
||||||
<b>Message</b>: {Exception.Message ?? "N/A"}";
|
|
||||||
if (Exception.InnerExceptionMessage is null)
|
|
||||||
return details;
|
|
||||||
|
|
||||||
return $"{details}<br>\n<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ClientLogException
|
|
||||||
{
|
|
||||||
public string Type { get; set; }
|
|
||||||
public string HelpLink { get; set; }
|
|
||||||
public string HResult { get; set; }
|
|
||||||
public string Source { get; set; }
|
|
||||||
public string StackTrace { get; set; }
|
|
||||||
public string TargetSiteName { get; set; }
|
|
||||||
public string Message { get; set; }
|
|
||||||
public string InnerExceptionMessage { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+28
-6
@@ -22,8 +22,10 @@ public class ComposeForm
|
|||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
public Visibility Visibility { get; set; } = Visibility.Public;
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
|
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
@@ -37,17 +39,37 @@ public class ComposeForm
|
|||||||
public double? Latitude { get; set; }
|
public double? Latitude { get; set; }
|
||||||
public double? Longitude { get; set; }
|
public double? Longitude { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A poll instead of media: two to four choices, open ExpiresIn seconds, one choice or several.</summary>
|
||||||
|
public bool HasPoll { get; set; }
|
||||||
|
public List<string> PollOptions { get; set; } = ["", ""];
|
||||||
|
public int PollExpiresIn { get; set; } = 86400;
|
||||||
|
public bool PollMultiple { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The language the post is written in (ISO 639-1); null leaves it to PrivaPub.</summary>
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The post this one quotes.</summary>
|
||||||
|
public string QuotedStatusId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now.</summary>
|
||||||
|
public bool IsScheduled { get; set; }
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the post is to be published, in UTC, or null when it goes now or ScheduledAt is not a time.</summary>
|
||||||
|
public DateTime? ScheduledAtUtc => IsScheduled && DateTime.TryParse(ScheduledAt, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AssumeLocal, out var local) ? local.ToUniversalTime() : null;
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public MediaType MediaType { get; set; } = MediaType.Images;
|
public MediaType MediaType { get; set; } = MediaType.Images;
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public bool IsScopeOptionsOpen { get; set; }
|
public bool IsScopeOptionsOpen { get; set; }
|
||||||
|
|
||||||
/// <summary>The Mastodon visibility of MessageType.</summary>
|
/// <summary>The Mastodon visibility of Visibility.</summary>
|
||||||
public string Visibility => MessageType switch
|
public string MastodonVisibility => Visibility switch
|
||||||
{
|
{
|
||||||
MessageType.Direct => "direct",
|
Visibility.Direct => "direct",
|
||||||
MessageType.FollowersOnly => "private",
|
Visibility.FollowersOnly => "private",
|
||||||
MessageType.Unlisted => "unlisted",
|
Visibility.Unlisted => "unlisted",
|
||||||
_ => "public"
|
_ => "public"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ public class FeedFilter
|
|||||||
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
||||||
/// <summary>The community or circle the Groups tab shows.</summary>
|
/// <summary>The community or circle the Groups tab shows.</summary>
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
|
/// <summary>The persona's list the Lists tab shows.</summary>
|
||||||
|
public string ListId { get; set; }
|
||||||
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
||||||
public string Tag { get; set; }
|
public string Tag { get; set; }
|
||||||
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
||||||
|
|||||||
+38
-3
@@ -17,7 +17,11 @@ public class FeedPost
|
|||||||
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public Account BoostedBy { get; init; }
|
public Account BoostedBy { get; init; }
|
||||||
public Notification Notification { get; init; }
|
public Notification Notification { get; set; }
|
||||||
|
/// <summary>For a notification standing for a group (likes of one post, follows of one hour): how many, and a few of
|
||||||
|
/// the accounts, newest first.</summary>
|
||||||
|
public int GroupCount { get; set; } = 1;
|
||||||
|
public List<Account> GroupAccounts { get; set; } = [];
|
||||||
public Conversation Conversation { get; init; }
|
public Conversation Conversation { get; init; }
|
||||||
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
||||||
public bool Located { get; init; }
|
public bool Located { get; init; }
|
||||||
@@ -30,6 +34,11 @@ public class FeedPost
|
|||||||
|
|
||||||
public string Host => Author?.Host;
|
public string Host => Author?.Host;
|
||||||
|
|
||||||
|
/// <summary>When the post was written (for a follow, when it happened), in UTC; the globe shows the Sun of that moment.</summary>
|
||||||
|
public DateTime? WrittenAt => (Status?.CreatedAt ?? Notification?.CreatedAt) is { } at
|
||||||
|
? (at.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(at, DateTimeKind.Utc) : at.ToUniversalTime())
|
||||||
|
: null;
|
||||||
|
|
||||||
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
||||||
|
|
||||||
public static FeedPost Of(Status status) => status.Reblog is { } original
|
public static FeedPost Of(Status status) => status.Reblog is { } original
|
||||||
@@ -38,12 +47,38 @@ public class FeedPost
|
|||||||
|
|
||||||
public static FeedPost Of(Notification notification) => new()
|
public static FeedPost Of(Notification notification) => new()
|
||||||
{
|
{
|
||||||
Key = notification.Id,
|
Key = notification.GroupKey ?? notification.Id,
|
||||||
CursorId = notification.Id,
|
CursorId = notification.Id,
|
||||||
Status = notification.Status?.Reblog ?? notification.Status,
|
Status = notification.Status?.Reblog ?? notification.Status,
|
||||||
Notification = notification
|
Notification = notification,
|
||||||
|
GroupAccounts = notification.Account is null ? [] : [notification.Account]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>A group of notifications as one item: its newest notification, how many it holds, and a few accounts.</summary>
|
||||||
|
public static FeedPost Of(NotificationGroup group, IReadOnlyDictionary<string, Account> accounts, IReadOnlyDictionary<string, Status> statuses)
|
||||||
|
{
|
||||||
|
var sample = group.SampleAccountIds.Select(accounts.GetValueOrDefault).Where(a => a is not null).ToList();
|
||||||
|
var status = group.StatusId is null ? null : statuses.GetValueOrDefault(group.StatusId);
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Key = group.GroupKey,
|
||||||
|
CursorId = group.MostRecentNotificationId,
|
||||||
|
Status = status?.Reblog ?? status,
|
||||||
|
Notification = new Notification
|
||||||
|
{
|
||||||
|
Id = group.MostRecentNotificationId,
|
||||||
|
Type = group.Type,
|
||||||
|
CreatedAt = group.LatestPageNotificationAt,
|
||||||
|
Account = sample.FirstOrDefault(),
|
||||||
|
Status = status,
|
||||||
|
Emoji = group.Emoji,
|
||||||
|
GroupKey = group.GroupKey
|
||||||
|
},
|
||||||
|
GroupCount = Math.Max(1, group.NotificationsCount),
|
||||||
|
GroupAccounts = sample
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static FeedPost Of(Conversation conversation) => new()
|
public static FeedPost Of(Conversation conversation) => new()
|
||||||
{
|
{
|
||||||
Key = conversation.Id,
|
Key = conversation.Id,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ public class Account
|
|||||||
public string LastStatusAt { get; set; }
|
public string LastStatusAt { get; set; }
|
||||||
public List<CustomEmoji> Emojis { get; set; } = [];
|
public List<CustomEmoji> Emojis { get; set; } = [];
|
||||||
public List<AccountField> Fields { get; set; } = [];
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>The account it moved to (PrivaPub shows it once the new account names this one among its aliases).</summary>
|
||||||
|
public Account Moved { get; set; }
|
||||||
|
/// <summary>Whether it is listed in the directory and suggested; null when its server does not say.</summary>
|
||||||
|
public bool? Discoverable { get; set; }
|
||||||
|
/// <summary>Whether its public posts may be found by full-text search.</summary>
|
||||||
|
public bool? Indexable { get; set; }
|
||||||
|
/// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its
|
||||||
|
/// posting defaults.</summary>
|
||||||
|
public AccountSource Source { get; set; }
|
||||||
|
|
||||||
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
||||||
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
||||||
@@ -33,6 +42,27 @@ public class Account
|
|||||||
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AccountSource
|
||||||
|
{
|
||||||
|
/// <summary>The biography as written, before PrivaPub renders it.</summary>
|
||||||
|
public string Note { get; set; }
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>The default visibility of a new post: public, unlisted or private.</summary>
|
||||||
|
public string Privacy { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
/// <summary>Who may quote the persona's posts: public, followers or nobody.</summary>
|
||||||
|
public string QuotePolicy { get; set; }
|
||||||
|
public int FollowRequestsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page of accounts (mutes, blocks), and the cursor of the next one: null at the end.</summary>
|
||||||
|
public class AccountPage
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public string MaxId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class AccountField
|
public class AccountField
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@@ -53,6 +83,20 @@ public class Relationship
|
|||||||
public bool Following { get; set; }
|
public bool Following { get; set; }
|
||||||
public bool FollowedBy { get; set; }
|
public bool FollowedBy { get; set; }
|
||||||
public bool Blocking { get; set; }
|
public bool Blocking { get; set; }
|
||||||
|
public bool BlockedBy { get; set; }
|
||||||
public bool Muting { get; set; }
|
public bool Muting { get; set; }
|
||||||
|
public bool MutingNotifications { get; set; }
|
||||||
|
public bool ShowingReblogs { get; set; }
|
||||||
|
public bool Notifying { get; set; }
|
||||||
public bool Requested { get; set; }
|
public bool Requested { get; set; }
|
||||||
|
public bool RequestedBy { get; set; }
|
||||||
|
public bool DomainBlocking { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/search: what matched, a remote handle or URL resolved by PrivaPub.</summary>
|
||||||
|
public class SearchResults
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public List<Status> Statuses { get; set; } = [];
|
||||||
|
public List<StatusTag> Hashtags { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>/api/v1/announcements: what the server's administrators announce, shown while published and not ended, as
|
||||||
|
/// the persona sees it (Read and each reaction's Me are the persona's own; signed out, neither is said).</summary>
|
||||||
|
public class Announcement
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
/// <summary>Sanitized HTML, from the administrator's Markdown.</summary>
|
||||||
|
public string Content { get; set; }
|
||||||
|
public DateTime? StartsAt { get; set; }
|
||||||
|
public DateTime? EndsAt { get; set; }
|
||||||
|
public bool AllDay { get; set; }
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
/// <summary>Whether the persona dismissed it.</summary>
|
||||||
|
public bool? Read { get; set; }
|
||||||
|
public List<AnnouncementReaction> Reactions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did.</summary>
|
||||||
|
public class AnnouncementReaction
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public bool? Me { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which.</summary>
|
||||||
|
public class AnnouncementReactionEvent
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public string AnnouncementId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A hashtag as PrivaPub describes it: its last week of use in public posts it holds, and whether the persona
|
||||||
|
/// follows it (null when nobody is signed in).</summary>
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
public bool? Following { get; set; }
|
||||||
|
|
||||||
|
public int WeekUses => History.Sum(h => h.UsesCount);
|
||||||
|
public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One day of a tag's or a link's use; Mastodon sends the numbers as strings, the day as unix time.</summary>
|
||||||
|
public class TagDay
|
||||||
|
{
|
||||||
|
public string Day { get; set; }
|
||||||
|
public string Uses { get; set; }
|
||||||
|
public string Accounts { get; set; }
|
||||||
|
|
||||||
|
public int UsesCount => int.TryParse(Uses, out var n) ? n : 0;
|
||||||
|
public int AccountsCount => int.TryParse(Accounts, out var n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A link many authors shared this week: its preview card with its days.</summary>
|
||||||
|
public class TrendLink : PreviewCard
|
||||||
|
{
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
|
||||||
|
public int WeekAccounts => History.Sum(h => h.AccountsCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A persona's filter (Mastodon's v2 filters): words and posts it would rather not see in some contexts, to be
|
||||||
|
/// warned about, hidden or blurred. Statuses carry the filters they match (Status.Filtered); the client applies the
|
||||||
|
/// context and the action.</summary>
|
||||||
|
public class Filter
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public List<FilterKeyword> Keywords { get; set; } = [];
|
||||||
|
public List<FilterStatus> Statuses { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsActive => ExpiresAt is null || ExpiresAt > DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeyword
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string StatusId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterResult
|
||||||
|
{
|
||||||
|
public Filter Filter { get; set; }
|
||||||
|
public List<string> KeywordMatches { get; set; } = [];
|
||||||
|
public List<string> StatusMatches { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a filter applies; a card is told which of these it is shown in.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterContext>))]
|
||||||
|
public enum FilterContext
|
||||||
|
{
|
||||||
|
[JsonStringEnumMemberName("home")] Home,
|
||||||
|
[JsonStringEnumMemberName("notifications")] Notifications,
|
||||||
|
[JsonStringEnumMemberName("public")] Public,
|
||||||
|
[JsonStringEnumMemberName("thread")] Thread,
|
||||||
|
[JsonStringEnumMemberName("account")] Account
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterAction>))]
|
||||||
|
public enum FilterAction
|
||||||
|
{
|
||||||
|
/// <summary>The post is folded behind the filter's title.</summary>
|
||||||
|
[JsonStringEnumMemberName("warn")] Warn,
|
||||||
|
/// <summary>The post is not shown at all.</summary>
|
||||||
|
[JsonStringEnumMemberName("hide")] Hide,
|
||||||
|
/// <summary>The post's media are blurred, as sensitive media are.</summary>
|
||||||
|
[JsonStringEnumMemberName("blur")] Blur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What making or changing a filter sends; ExpiresIn 0 means never.</summary>
|
||||||
|
public class FilterForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public int? ExpiresIn { get; set; }
|
||||||
|
public List<FilterKeywordForm> KeywordsAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeywordForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
[JsonPropertyName("_destroy")]
|
||||||
|
public bool? Destroy { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
|
||||||
|
/// its rules, and what an upload may be.</summary>
|
||||||
|
public class InstanceV2
|
||||||
|
{
|
||||||
|
public string Domain { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
/// <summary>The short description, as plain text.</summary>
|
||||||
|
public string Description { get; set; }
|
||||||
|
public InstanceThumbnail Thumbnail { get; set; } = new();
|
||||||
|
public InstanceContact Contact { get; set; } = new();
|
||||||
|
public List<InstanceRule> Rules { get; set; } = [];
|
||||||
|
public InstanceConfiguration Configuration { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstanceThumbnail
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Who answers for the server: an address, and the persona its administrator named (null when none).</summary>
|
||||||
|
public class InstanceContact
|
||||||
|
{
|
||||||
|
public string Email { get; set; }
|
||||||
|
public Account Account { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One of the server's rules (/api/v1/instance/rules and the instance's own), in their order.</summary>
|
||||||
|
public class InstanceRule
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string Hint { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstanceConfiguration
|
||||||
|
{
|
||||||
|
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
|
||||||
|
public class MediaConfiguration
|
||||||
|
{
|
||||||
|
public List<string> SupportedMimeTypes { get; set; } = [];
|
||||||
|
public long ImageSizeLimit { get; set; }
|
||||||
|
public long VideoSizeLimit { get; set; }
|
||||||
|
public long ImageMatrixLimit { get; set; }
|
||||||
|
public long VideoMatrixLimit { get; set; }
|
||||||
|
public int VideoFrameRateLimit { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it
|
||||||
|
/// last changed.</summary>
|
||||||
|
public class InstanceDocument
|
||||||
|
{
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none
|
||||||
|
/// (404).</summary>
|
||||||
|
public class TermsOfService
|
||||||
|
{
|
||||||
|
/// <summary>yyyy-MM-dd.</summary>
|
||||||
|
public string EffectiveDate { get; set; }
|
||||||
|
/// <summary>Whether they apply already (false while their date is ahead).</summary>
|
||||||
|
public bool Effective { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string SucceededBy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is
|
||||||
|
/// obfuscated, its digest the SHA-256 of the whole name.</summary>
|
||||||
|
public class PublicDomainBlock
|
||||||
|
{
|
||||||
|
public string Domain { get; set; }
|
||||||
|
public string Digest { get; set; }
|
||||||
|
/// <summary>suspend, silence or noop.</summary>
|
||||||
|
public string Severity { get; set; }
|
||||||
|
public string Comment { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A persona's list of accounts it follows (Mastodon's List): read as its own feed, and when exclusive kept out of
|
||||||
|
/// the home feed. Lists are the persona's own; PrivaPub never federates them.</summary>
|
||||||
|
public class MastodonList
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public ListRepliesPolicy RepliesPolicy { get; set; } = ListRepliesPolicy.List;
|
||||||
|
public bool Exclusive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which replies a list's feed shows besides its members' replies to themselves and to the persona.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<ListRepliesPolicy>))]
|
||||||
|
public enum ListRepliesPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Replies to any account the persona follows.</summary>
|
||||||
|
[JsonStringEnumMemberName("followed")] Followed,
|
||||||
|
/// <summary>Replies to the list's members.</summary>
|
||||||
|
[JsonStringEnumMemberName("list")] List,
|
||||||
|
[JsonStringEnumMemberName("none")] None
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListAccountsForm
|
||||||
|
{
|
||||||
|
public List<string> AccountIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -12,6 +12,45 @@ public class Notification
|
|||||||
public Account Account { get; set; }
|
public Account Account { get; set; }
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public string Emoji { get; set; }
|
public string Emoji { get; set; }
|
||||||
|
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
|
||||||
|
public string GroupKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary>
|
||||||
|
public class GroupedNotifications
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public List<Status> Statuses { get; set; } = [];
|
||||||
|
public List<NotificationGroup> NotificationGroups { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationGroup
|
||||||
|
{
|
||||||
|
public string GroupKey { get; set; }
|
||||||
|
public int NotificationsCount { get; set; }
|
||||||
|
public string Type { get; set; }
|
||||||
|
public string MostRecentNotificationId { get; set; }
|
||||||
|
public string PageMinId { get; set; }
|
||||||
|
public string PageMaxId { get; set; }
|
||||||
|
public DateTime LatestPageNotificationAt { get; set; }
|
||||||
|
public List<string> SampleAccountIds { get; set; } = [];
|
||||||
|
public string StatusId { get; set; }
|
||||||
|
public string Emoji { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkersForm
|
||||||
|
{
|
||||||
|
public MarkerForm Notifications { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkerForm
|
||||||
|
{
|
||||||
|
public string LastReadId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UnreadCount
|
||||||
|
{
|
||||||
|
public int Count { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A direct conversation: its participants and its last post.</summary>
|
/// <summary>A direct conversation: its participants and its last post.</summary>
|
||||||
|
|||||||
@@ -176,4 +176,119 @@ public class PostStatusForm
|
|||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
public string Visibility { get; set; }
|
public string Visibility { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
/// <summary>When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now.</summary>
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
public PollForm Poll { get; set; }
|
||||||
|
public string QuotedStatusId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PollForm
|
||||||
|
{
|
||||||
|
public List<string> Options { get; set; } = [];
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
public bool Multiple { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||||
|
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||||
|
public class StatusEditForm
|
||||||
|
{
|
||||||
|
public string Status { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public List<string> MediaIds { get; set; } = [];
|
||||||
|
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||||
|
public class MediaAttributeForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||||
|
public class StatusSource
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/notifications/policy: what notifications from people the persona does not follow, people who do not
|
||||||
|
/// follow it, new accounts, unasked private mentions and limited accounts become: accept, filter or drop.</summary>
|
||||||
|
public class NotificationPolicy
|
||||||
|
{
|
||||||
|
public string ForNotFollowing { get; set; }
|
||||||
|
public string ForNotFollowers { get; set; }
|
||||||
|
public string ForNewAccounts { get; set; }
|
||||||
|
public string ForPrivateMentions { get; set; }
|
||||||
|
public string ForLimitedAccounts { get; set; }
|
||||||
|
public NotificationPolicySummary Summary { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationPolicySummary
|
||||||
|
{
|
||||||
|
public int PendingRequestsCount { get; set; }
|
||||||
|
public int PendingNotificationsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/notifications/requests: the notifications the policy filtered from one account, waiting to be let in
|
||||||
|
/// (and that account from then on) or dismissed.</summary>
|
||||||
|
public class NotificationRequest
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
/// <summary>A number, as a string (Mastodon's).</summary>
|
||||||
|
public string NotificationsCount { get; set; }
|
||||||
|
public Account Account { get; set; }
|
||||||
|
public Status LastStatus { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
|
||||||
|
/// a remote account's server gets it too, from the server's own actor.</summary>
|
||||||
|
public class ReportRequest
|
||||||
|
{
|
||||||
|
public string AccountId { get; set; }
|
||||||
|
public List<string> StatusIds { get; set; } = [];
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>spam, legal, violation or other.</summary>
|
||||||
|
public string Category { get; set; }
|
||||||
|
/// <summary>The server's rules a violation breaks (only a violation names rules).</summary>
|
||||||
|
public List<string> RuleIds { get; set; } = [];
|
||||||
|
public bool Forward { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
|
||||||
|
public class StatusEdit
|
||||||
|
{
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public List<CustomEmoji> Emojis { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
|
||||||
|
public class ScheduledStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime ScheduledAt { get; set; }
|
||||||
|
public ScheduledParams Params { get; set; } = new();
|
||||||
|
public List<MediaAttachment> MediaAttachments { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScheduledParams
|
||||||
|
{
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public string Visibility { get; set; }
|
||||||
|
public string InReplyToId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScheduleForm
|
||||||
|
{
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,11 @@ public class PrivaPubStatus
|
|||||||
{
|
{
|
||||||
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
||||||
public string ObjectType { get; set; }
|
public string ObjectType { get; set; }
|
||||||
|
/// <summary>Its community's moderators locked it: PrivaPub refuses a reply.</summary>
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
/// <summary>The persona's reply or boost of a post whose author must approve it first (GoToSocial's interaction
|
||||||
|
/// policies): "pending" until the author answers, "rejected" if they refuse; null otherwise.</summary>
|
||||||
|
public string Approval { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Excerpt { get; set; }
|
public string Excerpt { get; set; }
|
||||||
public string Cover { get; set; }
|
public string Cover { get; set; }
|
||||||
@@ -16,6 +21,8 @@ public class PrivaPubStatus
|
|||||||
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
|
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
|
||||||
public PrivaPubPlace Place { get; set; }
|
public PrivaPubPlace Place { get; set; }
|
||||||
public PrivaPubVotes Votes { get; set; }
|
public PrivaPubVotes Votes { get; set; }
|
||||||
|
/// <summary>Whose wall it was written on (Smithereen's walls, FEP-400e), when not its author's own.</summary>
|
||||||
|
public Account Wall { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PrivaPubSource
|
public class PrivaPubSource
|
||||||
@@ -68,9 +75,13 @@ public class PrivaPubEvent
|
|||||||
public string TimeZone { get; set; }
|
public string TimeZone { get; set; }
|
||||||
public List<PrivaPubPlace> Places { get; set; } = [];
|
public List<PrivaPubPlace> Places { get; set; } = [];
|
||||||
public string OnlineUrl { get; set; }
|
public string OnlineUrl { get; set; }
|
||||||
|
/// <summary>Mobilizon's: "free", "restricted" (the organiser approves), "invite" or "external" (not joined from here).</summary>
|
||||||
|
public string JoinMode { get; set; }
|
||||||
public int? Participants { get; set; }
|
public int? Participants { get; set; }
|
||||||
public int? Capacity { get; set; }
|
public int? Capacity { get; set; }
|
||||||
public string Status { get; set; }
|
public string Status { get; set; }
|
||||||
|
/// <summary>The persona's own: "pending", "accepted" or "rejected"; null when it has not joined.</summary>
|
||||||
|
public string Participation { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PrivaPubPlace
|
public class PrivaPubPlace
|
||||||
@@ -83,8 +94,12 @@ public class PrivaPubPlace
|
|||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A post's votes: its favourites are its upvotes. PrivaPub sends them on every post; a threadiverse post's
|
||||||
|
/// downvotes are the ones that mean something.</summary>
|
||||||
public class PrivaPubVotes
|
public class PrivaPubVotes
|
||||||
{
|
{
|
||||||
public int Up { get; set; }
|
public int Up { get; set; }
|
||||||
public int Down { get; set; }
|
public int Down { get; set; }
|
||||||
|
/// <summary>The persona's own downvote (POST /api/v1/statuses/:id/downvote, /undownvote); null with no persona.</summary>
|
||||||
|
public bool? Downvoted { get; set; }
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,13 @@ public class Status
|
|||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public PrivaPubStatus Privapub { get; set; }
|
public PrivaPubStatus Privapub { get; set; }
|
||||||
public Quote Quote { get; set; }
|
public Quote Quote { get; set; }
|
||||||
|
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
|
||||||
|
public List<EmojiReaction> EmojiReactions { get; set; } = [];
|
||||||
|
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
|
||||||
|
public List<FilterResult> Filtered { get; set; } = [];
|
||||||
public int QuotesCount { get; set; }
|
public int QuotesCount { get; set; }
|
||||||
|
/// <summary>Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona.</summary>
|
||||||
|
public QuoteApproval QuoteApproval { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StatusMention
|
public class StatusMention
|
||||||
@@ -85,9 +91,28 @@ public class Poll
|
|||||||
public int VotesCount { get; set; }
|
public int VotesCount { get; set; }
|
||||||
public int? VotersCount { get; set; }
|
public int? VotersCount { get; set; }
|
||||||
public bool? Voted { get; set; }
|
public bool? Voted { get; set; }
|
||||||
|
/// <summary>The options the current persona chose (indexes).</summary>
|
||||||
|
public List<int> OwnVotes { get; set; } = [];
|
||||||
public List<PollOption> Options { get; set; } = [];
|
public List<PollOption> Options { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class EmojiReaction
|
||||||
|
{
|
||||||
|
/// <summary>The emoji itself, or a custom emoji's shortcode.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
/// <summary>Whether the current persona reacted with it.</summary>
|
||||||
|
public bool Me { get; set; }
|
||||||
|
/// <summary>A custom emoji's image, through PrivaPub's proxy.</summary>
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/v1/polls/{id}/votes.</summary>
|
||||||
|
public class PollVoteForm
|
||||||
|
{
|
||||||
|
public List<int> Choices { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public class PollOption
|
public class PollOption
|
||||||
{
|
{
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
@@ -107,3 +132,10 @@ public class PreviewCard
|
|||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public string Blurhash { get; set; }
|
public string Blurhash { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class QuoteApproval
|
||||||
|
{
|
||||||
|
public List<string> Automatic { get; set; } = [];
|
||||||
|
public List<string> Manual { get; set; } = [];
|
||||||
|
public string CurrentUser { get; set; }
|
||||||
|
}
|
||||||
+33
-5
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
using PrivaPub.ClientModels.Resources;
|
using PrivaPub.ClientModels.Resources;
|
||||||
|
using PrivaPub.ClientModels.User;
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
@@ -10,20 +11,19 @@ namespace decePubClient.Models
|
|||||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
Display(Name = "LanguageId", ResourceType = typeof(FieldsNameResource))]
|
Display(Name = "LanguageId", ResourceType = typeof(FieldsNameResource))]
|
||||||
public string CurrentLanguageCode { get; set; } = "en";
|
public string CurrentLanguageCode { get; set; } = "en";
|
||||||
//public bool NativeNotificationsEnabled { get; set; } = false;
|
|
||||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
public short LightThemeIndexColour { get; set; } = 25;
|
public short LightThemeIndexColour { get; set; } = 25;
|
||||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
public short DarkThemeIndexColour { get; set; } = 215;
|
public short DarkThemeIndexColour { get; set; } = 215;
|
||||||
[Range(-2, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
|
||||||
public short IconsThemeIndexColour { get; set; } = 25;
|
|
||||||
|
|
||||||
public bool PreferSystemTheming { get; set; } = true;
|
public bool PreferSystemTheming { get; set; } = true;
|
||||||
public bool ThemeIsDarkMode { get; set; } = false;
|
public bool ThemeIsDarkMode { get; set; } = false;
|
||||||
public bool ThemeIsLightGray { get; set; } = true;
|
public bool ThemeIsLightGray { get; set; } = true;
|
||||||
public bool ThemeIsDarkGray { get; set; } = false;
|
public bool ThemeIsDarkGray { get; set; } = false;
|
||||||
|
// before publishing pictures without a description, the composer asks once
|
||||||
public bool ShowDonatorBadge { get; set; } = false;
|
public bool RemindAltText { get; set; } = true;
|
||||||
|
// the theme is the login's (PrivaPub's user settings): taken at sign-in, saved there whenever it changes here
|
||||||
|
public bool SyncWithLogin { get; set; } = true;
|
||||||
|
|
||||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||||
@@ -59,5 +59,33 @@ namespace decePubClient.Models
|
|||||||
LightThemeIndexColour = hue;
|
LightThemeIndexColour = hue;
|
||||||
SetGray(false, isDark);
|
SetGray(false, isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// These settings as the login keeps them: the language as the two letters of the culture the app runs in
|
||||||
|
/// (ExtensionMethods.ToCulture: "it" is it-IT, anything else en-GB), the icons' hue at its default, since the client
|
||||||
|
/// has no such setting.
|
||||||
|
/// </summary>
|
||||||
|
public ViewUserSettings ToLoginSettings() => new()
|
||||||
|
{
|
||||||
|
LanguageCode = CurrentLanguageCode is "it" ? "it" : "en",
|
||||||
|
LightThemeIndexColour = LightThemeIndexColour,
|
||||||
|
DarkThemeIndexColour = DarkThemeIndexColour,
|
||||||
|
IconsThemeIndexColour = DefaultLightThemeIndexColour,
|
||||||
|
PreferSystemTheming = PreferSystemTheming,
|
||||||
|
ThemeIsDarkMode = ThemeIsDarkMode,
|
||||||
|
ThemeIsLightGray = ThemeIsLightGray,
|
||||||
|
ThemeIsDarkGray = ThemeIsDarkGray
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Takes the login's theme; its language stays this device's, since changing it reloads the app.</summary>
|
||||||
|
public void TakeLoginTheme(ViewUserSettings settings)
|
||||||
|
{
|
||||||
|
LightThemeIndexColour = Math.Clamp(settings.LightThemeIndexColour, (short)0, (short)359);
|
||||||
|
DarkThemeIndexColour = Math.Clamp(settings.DarkThemeIndexColour, (short)0, (short)359);
|
||||||
|
PreferSystemTheming = settings.PreferSystemTheming;
|
||||||
|
ThemeIsDarkMode = settings.ThemeIsDarkMode;
|
||||||
|
ThemeIsLightGray = settings.ThemeIsLightGray;
|
||||||
|
ThemeIsDarkGray = settings.ThemeIsDarkGray;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
namespace decePubClient.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The persona's profile as Settings edits it, which is what update_credentials takes: the words as written (its
|
||||||
|
/// account's Source), the flags, the posting defaults, and a new avatar or header when one was picked. Fields always
|
||||||
|
/// holds FieldCount rows; a row without a name is dropped.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfileForm
|
||||||
|
{
|
||||||
|
public const int FieldCount = 4;
|
||||||
|
public const int DisplayNameLength = 30;
|
||||||
|
public const int NoteLength = 500;
|
||||||
|
public const int FieldLength = 255;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
public string Note { get; set; }
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
public bool Bot { get; set; }
|
||||||
|
public bool Discoverable { get; set; }
|
||||||
|
public bool Indexable { get; set; }
|
||||||
|
public Visibility Privacy { get; set; } = Visibility.Public;
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
public UploadMedia Avatar { get; set; }
|
||||||
|
public UploadMedia Header { get; set; }
|
||||||
|
|
||||||
|
public static ProfileForm Of(Account account)
|
||||||
|
{
|
||||||
|
var source = account.Source ?? new();
|
||||||
|
var fields = source.Fields.Count > 0 ? source.Fields : account.Fields;
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
DisplayName = account.DisplayName,
|
||||||
|
Note = source.Note,
|
||||||
|
Locked = account.Locked,
|
||||||
|
Bot = account.Bot,
|
||||||
|
Discoverable = account.Discoverable == true,
|
||||||
|
Indexable = account.Indexable == true,
|
||||||
|
Privacy = source.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => Visibility.FollowersOnly,
|
||||||
|
"unlisted" => Visibility.Unlisted,
|
||||||
|
_ => Visibility.Public
|
||||||
|
},
|
||||||
|
Sensitive = source.Sensitive,
|
||||||
|
Language = source.Language,
|
||||||
|
QuotePolicy = source.QuotePolicy switch
|
||||||
|
{
|
||||||
|
"followers" => QuotePolicy.Followers,
|
||||||
|
"nobody" => QuotePolicy.Nobody,
|
||||||
|
_ => QuotePolicy.Public
|
||||||
|
},
|
||||||
|
Fields = [.. fields.Take(FieldCount).Select(f => new AccountField { Name = f.Name, Value = f.Value }),
|
||||||
|
.. Enumerable.Range(0, Math.Max(0, FieldCount - fields.Count)).Select(_ => new AccountField())]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form as update_credentials reads it, with the pictures as files.</summary>
|
||||||
|
public MultipartFormDataContent Content()
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ new StringContent(DisplayName?.Trim() ?? ""), "display_name" },
|
||||||
|
{ new StringContent(Note ?? ""), "note" },
|
||||||
|
{ new StringContent(Flag(Locked)), "locked" },
|
||||||
|
{ new StringContent(Flag(Bot)), "bot" },
|
||||||
|
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
||||||
|
{ new StringContent(Flag(Indexable)), "indexable" },
|
||||||
|
{ new StringContent(Privacy switch { Visibility.FollowersOnly => "private", Visibility.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||||
|
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
||||||
|
{ new StringContent(Language ?? ""), "source[language]" },
|
||||||
|
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
||||||
|
};
|
||||||
|
// every row is sent, so a row emptied here is removed there
|
||||||
|
for (var i = 0; i < Fields.Count; i++)
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(Fields[i].Name?.Trim() ?? ""), $"fields_attributes[{i}][name]");
|
||||||
|
content.Add(new StringContent(Fields[i].Value?.Trim() ?? ""), $"fields_attributes[{i}][value]");
|
||||||
|
}
|
||||||
|
if (Avatar is not null)
|
||||||
|
content.Add(Picture(Avatar), "avatar", Avatar.FileName);
|
||||||
|
if (Header is not null)
|
||||||
|
content.Add(Picture(Header), "header", Header.FileName);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
// PrivaPub keeps pictures up to this size and resizes them
|
||||||
|
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
// streamed from the browser's file, read again for each attempt
|
||||||
|
static StreamContent Picture(UploadMedia media)
|
||||||
|
{
|
||||||
|
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||||
|
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
|
||||||
|
/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name.
|
||||||
|
/// </summary>
|
||||||
|
public class PublicActionNotices
|
||||||
|
{
|
||||||
|
/// <summary>Told about reactions and poll votes.</summary>
|
||||||
|
public List<string> PersonaIds { get; set; } = [];
|
||||||
|
/// <summary>Told about joining events.</summary>
|
||||||
|
public List<string> EventPersonaIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PublicAct
|
||||||
|
{
|
||||||
|
Reaction = 0,
|
||||||
|
Participation = 1
|
||||||
|
}
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
using BlazorZXingJs;
|
namespace decePubClient.Models
|
||||||
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
|
|
||||||
namespace decePubClient.Models
|
|
||||||
{
|
{
|
||||||
public class PublicCacheData
|
public class PublicCacheData
|
||||||
{
|
{
|
||||||
public IReadOnlyList<ViewLanguage> Languages { get; set; } = [];
|
|
||||||
public List<MediaDeviceInfo> QrCodeDevices { get; set; } = [];
|
|
||||||
public string LastPage { get; set; }
|
|
||||||
public string CurrentSubPage { get; set; }
|
|
||||||
public PageSettings PageSettings { get; set; } = new();
|
public PageSettings PageSettings { get; set; } = new();
|
||||||
public bool ShowLogs { get; set; } = false;
|
|
||||||
public IReadOnlyDictionary<string, string> Policies { get; set; } = ReadOnlyDictionary<string, string>.Empty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
using decePubClient.Resources;
|
||||||
|
|
||||||
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
/// <summary>A report of an account, and of the post it was made from, as the report dialog edits it.</summary>
|
||||||
|
public class ReportForm
|
||||||
|
{
|
||||||
|
public const int CommentLength = 1000;
|
||||||
|
|
||||||
|
public ReportCategory Category { get; set; } = ReportCategory.Other;
|
||||||
|
[StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>A copy to a remote account's own server, from PrivaPub's actor.</summary>
|
||||||
|
public bool Forward { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace decePubClient.Models
|
|
||||||
{
|
|
||||||
public class SettingsOptions
|
|
||||||
{
|
|
||||||
public bool IsOpen { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
{
|
{
|
||||||
Images = 1,
|
Images = 1,
|
||||||
Video,
|
Video,
|
||||||
Audio,
|
Audio
|
||||||
Documents
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace decePubClient.Models.Types
|
|
||||||
{
|
|
||||||
public enum MessageType
|
|
||||||
{
|
|
||||||
Direct = 1,
|
|
||||||
FollowersOnly,
|
|
||||||
Unlisted,
|
|
||||||
Public
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Who may quote a persona's posts (Mastodon's quote_policy).</summary>
|
||||||
|
public enum QuotePolicy
|
||||||
|
{
|
||||||
|
Public = 1,
|
||||||
|
Followers,
|
||||||
|
Nobody
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Why an account is reported (Mastodon's categories). A violation names the server's rules it breaks.</summary>
|
||||||
|
public enum ReportCategory
|
||||||
|
{
|
||||||
|
Other = 0,
|
||||||
|
Spam = 1,
|
||||||
|
Legal = 2,
|
||||||
|
Violation = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ namespace decePubClient.Models.Types
|
|||||||
Notifications,
|
Notifications,
|
||||||
Favourites,
|
Favourites,
|
||||||
Bookmarks,
|
Bookmarks,
|
||||||
Tag
|
Tag,
|
||||||
|
Lists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Who a post is for; the members are resource keys too (Localizer[value.ToString()]).</summary>
|
||||||
|
public enum Visibility
|
||||||
|
{
|
||||||
|
Direct = 1,
|
||||||
|
FollowersOnly,
|
||||||
|
Unlisted,
|
||||||
|
Public
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-4
@@ -1,17 +1,48 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
public enum UploadState
|
||||||
|
{
|
||||||
|
Uploading,
|
||||||
|
Processing,
|
||||||
|
Ready,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||||
|
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||||
|
/// </summary>
|
||||||
public class UploadMedia
|
public class UploadMedia
|
||||||
{
|
{
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
public string FileName { get; set; }
|
public string FileName { get; set; }
|
||||||
public string AltText { get; set; }
|
public string AltText { get; set; }
|
||||||
public byte[] Blob { get; set; } = [];
|
public long Size { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public string Base64Preview { get; set; }
|
public IBrowserFile File { get; set; }
|
||||||
|
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public long Size { get; set; }
|
public string Preview { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public UploadState State { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Error { get; set; }
|
||||||
|
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string SentAltText { get; set; }
|
||||||
|
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public bool Posted { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public CancellationTokenSource Cancel { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public Task Done { get; set; } = Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace decePubClient.Models
|
|
||||||
{
|
|
||||||
public static class VConstants
|
|
||||||
{
|
|
||||||
public const string HideClass = "hidden";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace decePubClient.Models
|
|
||||||
{
|
|
||||||
public class ViewLanguage
|
|
||||||
{
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string International2Code { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
@page "/about"
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
<Title>@Localizer["About {0}", instance?.Title ?? "PrivaPub"]</Title>
|
||||||
|
|
||||||
|
@* What this server says of itself, to anyone, signed in or not (AboutService, the Mastodon instance API without
|
||||||
|
credentials): its name, short description and thumbnail, its long description, who answers for it, its rules, its
|
||||||
|
privacy policy and terms of service, and the domain blocks its administrator published (an obfuscated one partly
|
||||||
|
starred, as the server gives it). The documents are the server's sanitized HTML. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto" data-about>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (instance is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col items-center gap-2 mt-6">
|
||||||
|
<p class="text-sm text-error">@Localizer["The server's description could not be read."]</p>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<article class="flex flex-col w-full max-w-3xl rounded-xl neo-raised neo-sm overflow-hidden" data-about-server>
|
||||||
|
@if (instance.Thumbnail.Url is { Length: > 0 } thumbnail)
|
||||||
|
{
|
||||||
|
@* decorative: the server's name follows *@
|
||||||
|
<img src="@thumbnail" alt="" class="w-full aspect-[40/21] object-cover" />
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 p-4 md:p-5">
|
||||||
|
<h1 class="text-2xl font-bold break-words" data-about-title>@instance.Title</h1>
|
||||||
|
@if (instance.Domain is { Length: > 0 } domain)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 break-all">@domain</p>
|
||||||
|
}
|
||||||
|
@if (instance.Description is { Length: > 0 } summary)
|
||||||
|
{
|
||||||
|
<p class="text-base break-words" data-about-summary>@summary</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@if (description?.Content is { Length: > 0 } extended)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-description>
|
||||||
|
<div class="post-content doc-content break-words">@((MarkupString)extended)</div>
|
||||||
|
@if (description.UpdatedAt is { } describedAt)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-contact>
|
||||||
|
<h2 class="text-lg font-bold">@Localizer["Contact"]</h2>
|
||||||
|
@if (instance.Contact.Account is { } account)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-2 rounded-lg neo-raised neo-2xs min-w-0" href="@AccountLink(account)" data-about-contact-account="@account.Id">
|
||||||
|
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (instance.Contact.Email is { Length: > 0 } email)
|
||||||
|
{
|
||||||
|
<a class="link inline-flex items-center gap-2 break-all touch-target self-start" href="@($"mailto:{email}")" data-about-contact-email>
|
||||||
|
<i class="ion-md-mail" aria-hidden="true"></i>@email
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-rules>
|
||||||
|
<h2 class="text-lg font-bold">@Localizer["Rules"]</h2>
|
||||||
|
@if (instance.Rules.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The server has no rules."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ol class="flex flex-col gap-2 list-decimal ps-6">
|
||||||
|
@foreach (var rule in instance.Rules)
|
||||||
|
{
|
||||||
|
<li class="break-words" data-about-rule="@rule.Id">
|
||||||
|
<span>@rule.Text</span>
|
||||||
|
@if (rule.Hint is { Length: > 0 } hint)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@hint</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@if (privacy?.Content is { Length: > 0 } policy)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-privacy>
|
||||||
|
<h2 class="text-lg font-bold">@Localizer["Privacy policy"]</h2>
|
||||||
|
<div class="post-content doc-content break-words">@((MarkupString)policy)</div>
|
||||||
|
@if (privacy.UpdatedAt is { } policyAt)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (terms?.Content is { Length: > 0 } termsText)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-terms>
|
||||||
|
<h2 class="text-lg font-bold">@Localizer["Terms of service"]</h2>
|
||||||
|
@if (TermsDate is { } effectiveOn)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn])
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="post-content doc-content break-words">@((MarkupString)termsText)</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (blocks.Count > 0)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-blocks>
|
||||||
|
<h2 class="text-lg font-bold">@Localizer["Moderated servers"]</h2>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]</p>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
@foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
<li class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs" data-about-block="@block.Domain">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 break-all font-mono text-sm" title="@($"SHA-256 {block.Digest}")">@block.Domain</b>
|
||||||
|
@switch (block.Severity)
|
||||||
|
{
|
||||||
|
case "suspend":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Suspended"]</DBadge>
|
||||||
|
break;
|
||||||
|
case "silence":
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Silenced"]</DBadge>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Limited"]</DBadge>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (block.Comment is { Length: > 0 } reason)
|
||||||
|
{
|
||||||
|
<p class="text-xs break-words">@reason</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AboutService AboutService { get; set; }
|
||||||
|
|
||||||
|
InstanceV2 instance;
|
||||||
|
InstanceDocument description;
|
||||||
|
InstanceDocument privacy;
|
||||||
|
TermsOfService terms;
|
||||||
|
List<PublicDomainBlock> blocks = [];
|
||||||
|
|
||||||
|
// the terms' date as the reader writes dates
|
||||||
|
string TermsDate => DateTime.TryParseExact(terms?.EffectiveDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var on) ? on.ToString(SUtility.dateTimeFormat) : null;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
var reading = AboutService.Instance(fresh: true);
|
||||||
|
var describing = AboutService.ExtendedDescription();
|
||||||
|
var policing = AboutService.PrivacyPolicy();
|
||||||
|
var terming = AboutService.Terms();
|
||||||
|
var blocking = AboutService.DomainBlocks();
|
||||||
|
await Task.WhenAll(reading, describing, policing, terming, blocking);
|
||||||
|
instance = reading.Result;
|
||||||
|
description = describing.Result;
|
||||||
|
privacy = policing.Result;
|
||||||
|
terms = terming.Result;
|
||||||
|
blocks = blocking.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Retry()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// signed in, the contact's profile in the app; signed out, its own page
|
||||||
|
string AccountLink(Account account) =>
|
||||||
|
CascadingState.IsSignedIn ? $"account/{Uri.EscapeDataString(account.Id)}" : account.Url;
|
||||||
|
}
|
||||||
+38
-69
@@ -1,180 +1,149 @@
|
|||||||
@page "/administration"
|
@page "/administration"
|
||||||
|
@attribute [Authorize(Policy = Policies.IsModerator)]
|
||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
<Title>@Localizer["Settings"]</Title>
|
<Title>@Localizer["Administration"]</Title>
|
||||||
|
|
||||||
|
@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its
|
||||||
|
rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation
|
||||||
|
statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it.
|
||||||
|
Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="reports">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Users"]</span>
|
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<ReportsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Reports"]</span>
|
<i class="ion-md-list-box text-xl"></i> <span>@Localizer["Moderation log"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<ModerationLogPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<AuthorizeView Policy="@Policies.IsAdmin">
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["ActivityPub?"]</span>
|
<i class="ion-md-information-circle text-xl"></i> <span>@Localizer["Instance"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<InstancePanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="rules">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Authentication"]</span>
|
<i class="ion-md-book text-xl"></i> <span>@Localizer["Rules"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<RulesPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="announcements">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Emoji?"]</span>
|
<i class="ion-md-megaphone text-xl"></i> <span>@Localizer["Announcements"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<AnnouncementsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Frontend"]</span>
|
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<ServerBlocksPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="relays">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Instance"]</span>
|
<i class="ion-md-radio text-xl"></i> <span>@Localizer["Relays"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<RelaysPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="media">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Link formatter"]</span>
|
<i class="ion-md-images text-xl"></i> <span>@Localizer["Media limits"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<MediaLimitsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="statistics">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Metadata"]</span>
|
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<StatisticsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="backups">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Policies"]</span>
|
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<BackupsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</ChildContent>
|
|
||||||
</DCollapse>
|
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
|
||||||
<Header>
|
|
||||||
<p class="inline-flex items-center gap-2">
|
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Upload"]</span>
|
|
||||||
</p>
|
|
||||||
</Header>
|
|
||||||
<ChildContent>
|
|
||||||
<div class="block w-full p-3 md:p-4">
|
|
||||||
<p class="w-full text-center text-lg">
|
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
@page "/explore"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Explore"]</Title>
|
||||||
|
|
||||||
|
@* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already
|
||||||
|
holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the
|
||||||
|
hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-compass text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
|
||||||
|
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||||
|
aria-label="@Localizer["Explore"]">
|
||||||
|
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
|
||||||
|
</DTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Hashtags or ExploreTab.Followed)
|
||||||
|
{
|
||||||
|
@if (tags.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty>
|
||||||
|
<i class="ion-ios-remove-circle-outline"></i> @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."])
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var item in tags)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
|
||||||
|
<button type="button" class="flex flex-col items-start justify-center min-w-0 flex-1 text-left pointer-coarse:min-h-11" @onclick="() => OpenTag(item.Name)">
|
||||||
|
<b class="truncate">#@item.Name</b>
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
|
||||||
|
</button>
|
||||||
|
<span class="flex items-end gap-px h-6" aria-hidden="true">
|
||||||
|
@foreach (var day in Enumerable.Reverse(item.History))
|
||||||
|
{
|
||||||
|
<span class="w-1.5 rounded-sm bg-primary/60" style="@($"height:{Bar(day.UsesCount, item)}%")"></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(item.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isActing)"
|
||||||
|
OnClick="@(() => ToggleFollow(item))">
|
||||||
|
<span>@(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"])</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Posts)
|
||||||
|
{
|
||||||
|
@if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@foreach (var post in posts)
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Public" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Links)
|
||||||
|
{
|
||||||
|
@if (links.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var link in links)
|
||||||
|
{
|
||||||
|
<a class="flex gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@link.Url" target="_blank" rel="noopener noreferrer" data-link="@link.Url">
|
||||||
|
@if (link.Image is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
@* alt="": the title is next to it. *@
|
||||||
|
<img alt="" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-lg" src="@image" />
|
||||||
|
}
|
||||||
|
<span class="flex flex-col min-w-0 gap-1">
|
||||||
|
<b class="text-sm break-words">@link.Title</b>
|
||||||
|
@if (link.Description is { Length: > 0 } description)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-80 line-clamp-2">@description</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-60">@link.ProviderName · @Localizer["{0} people this week", link.WeekAccounts]</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<DSelect TValue="DirectoryOrder" Size="DSize.Sm" class="w-auto" Value="order" ValueChanged="ChangeOrder" ValueExpression="() => order">
|
||||||
|
<option value="@DirectoryOrder.Active">@Localizer["Recently active"]</option>
|
||||||
|
<option value="@DirectoryOrder.New">@Localizer["Newcomers"]</option>
|
||||||
|
</DSelect>
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@localOnly" @onchange="ToggleLocal" />
|
||||||
|
<span>@Localizer["Only this server"]</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (people.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var account in people)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (morePeople)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isActing)" OnClick="LoadMorePeople">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
ElementReference strip;
|
||||||
|
int revealed = -1;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if ((int)tab == revealed)
|
||||||
|
return;
|
||||||
|
revealed = (int)tab;
|
||||||
|
CascadingState.Status.RevealActive(strip);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExploreTab
|
||||||
|
{
|
||||||
|
Hashtags,
|
||||||
|
Posts,
|
||||||
|
Links,
|
||||||
|
People,
|
||||||
|
Followed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||||
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
|
|
||||||
|
ExploreTab tab = ExploreTab.Hashtags;
|
||||||
|
List<Tag> tags = [];
|
||||||
|
List<FeedPost> posts = [];
|
||||||
|
List<TrendLink> links = [];
|
||||||
|
List<Account> people = [];
|
||||||
|
DirectoryOrder order = DirectoryOrder.Active;
|
||||||
|
bool localOnly;
|
||||||
|
bool morePeople;
|
||||||
|
bool isActing;
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
static int Bar(int uses, Tag tag) => tag.History.Max(h => h.UsesCount) is > 0 and var most ? Math.Max(8, uses * 100 / most) : 8;
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): what it follows is another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
switch (tab)
|
||||||
|
{
|
||||||
|
case ExploreTab.Hashtags:
|
||||||
|
tags = await DiscoveryService.TrendingTags();
|
||||||
|
break;
|
||||||
|
case ExploreTab.Followed:
|
||||||
|
tags = await DiscoveryService.FollowedTags();
|
||||||
|
break;
|
||||||
|
case ExploreTab.Posts:
|
||||||
|
posts = [.. (await DiscoveryService.TrendingStatuses()).Select(FeedPost.Of)];
|
||||||
|
await InstanceService.Resolve(posts.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
|
InstanceService.Locate(posts);
|
||||||
|
break;
|
||||||
|
case ExploreTab.Links:
|
||||||
|
links = await DiscoveryService.TrendingLinks();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
people = await DiscoveryService.Directory(order, localOnly, 0);
|
||||||
|
morePeople = people.Count == DiscoveryService.DirectoryPage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SelectTab(int index)
|
||||||
|
{
|
||||||
|
tab = (ExploreTab)index;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeOrder(DirectoryOrder value)
|
||||||
|
{
|
||||||
|
order = value;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleLocal(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
localOnly = e.Value is true;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMorePeople()
|
||||||
|
{
|
||||||
|
isActing = true;
|
||||||
|
var known = people.Select(a => a.Id).ToHashSet();
|
||||||
|
var page = await DiscoveryService.Directory(order, localOnly, people.Count);
|
||||||
|
isActing = false;
|
||||||
|
people.AddRange(page.Where(a => known.Add(a.Id)));
|
||||||
|
morePeople = page.Count == DiscoveryService.DirectoryPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleFollow(Tag item)
|
||||||
|
{
|
||||||
|
isActing = true;
|
||||||
|
var result = await DiscoveryService.FollowTag(item.Name, item.Following != true);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Following = ((Tag)result.Data).Following;
|
||||||
|
if (tab is ExploreTab.Followed && item.Following != true)
|
||||||
|
tags.Remove(item);
|
||||||
|
Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OpenTag(string tagName)
|
||||||
|
{
|
||||||
|
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.Tag = tagName;
|
||||||
|
filter.TimelineType = TimelineType.Tag;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@page "/notifications/filtered"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Filtered notifications"]</Title>
|
||||||
|
|
||||||
|
@* The persona's notification policy and the requests it filtered, as in Settings; the Notifications tab links here when
|
||||||
|
requests are waiting. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-notifications-off text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Filtered notifications"]</h1>
|
||||||
|
</div>
|
||||||
|
<NotificationPolicyEditor />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
@page "/filters"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Filters"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
||||||
|
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
|
||||||
|
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
|
||||||
|
(Content.FilterIn), and a card's "Filter this post" adds a post to one. The posts a filter holds are read by id when it
|
||||||
|
opens (/api/v1/statuses?id[]=) and show their author and words, linking their thread. Filters are the persona's own
|
||||||
|
and never leave PrivaPub. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-funnel text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Filters"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
placeholder="@Localizer["A new filter's title"]" data-filter-new>
|
||||||
|
<Prefix><i class="ion-md-add"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
|
||||||
|
<span>@Localizer["Make a filter"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no filter yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var filter in filters)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-filter-id="@filter.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@filter.Title</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@ActionName(filter.FilterAction)</DBadge>
|
||||||
|
@if (!filter.IsActive)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Expired"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (filter.ExpiresAt is { } expires)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" title="@NerdRow.Time(expires)">@Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count]</span>
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == filter.Id)" OnClick="@(() => Toggle(filter))" title="@Localizer["Edit"]">
|
||||||
|
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
|
||||||
|
<DInput TValue="string" class="w-full" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
aria-label="@Localizer["Title"]" data-filter-title />
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Where"]</legend>
|
||||||
|
@foreach (var context in Contexts)
|
||||||
|
{
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@edit.Contexts.Contains(context)"
|
||||||
|
@onchange="@((ChangeEventArgs e) => ToggleContext(context, e.Value is true))" />
|
||||||
|
<span>@ContextName(context)</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DSelect TValue="FilterAction" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.Action" aria-label="@Localizer["How"]">
|
||||||
|
<Prefix><i class="ion-md-funnel"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var action in Actions)
|
||||||
|
{
|
||||||
|
<option value="@action">@ActionText(action)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.ExpiresIn" aria-label="@Localizer["Expires"]">
|
||||||
|
<Prefix><i class="ion-md-time"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var (seconds, label) in Expiries)
|
||||||
|
{
|
||||||
|
<option value="@seconds">@label</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Words"]</h2>
|
||||||
|
<div class="flex flex-col gap-2" data-filter-keywords>
|
||||||
|
@foreach (var row in edit.Keywords.Where(k => !k.Removed))
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2" data-keyword="@row.Keyword">
|
||||||
|
<span class="flex-1 min-w-0 break-words text-sm">@row.Keyword</span>
|
||||||
|
<label class="label text-xs">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" checked="@row.WholeWord" @onchange="@((ChangeEventArgs e) => row.WholeWord = e.Value is true)" />
|
||||||
|
<span>@Localizer["Whole word"]</span>
|
||||||
|
</label>
|
||||||
|
<DButton Size="DSize.Xs" OnClick="@(() => RemoveKeyword(row))" title="@Localizer["Remove"]">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.NewKeyword" Immediate="true" autocomplete="off" maxlength="100"
|
||||||
|
placeholder="@Localizer["A word or a phrase"]" data-filter-keyword-new />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(string.IsNullOrWhiteSpace(edit.NewKeyword))" OnClick="AddKeyword">
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filter.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var item in filter.Statuses)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
|
||||||
|
@if (PostOf(item) is { } post)
|
||||||
|
{
|
||||||
|
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
|
||||||
|
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<span class="truncate text-xs">
|
||||||
|
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
|
||||||
|
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
|
||||||
|
</span>
|
||||||
|
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (readPostIds.Contains(item.StatusId))
|
||||||
|
{
|
||||||
|
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary"
|
||||||
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title) || edit.Contexts.Count == 0)">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (isConfirmingDelete)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const int KeepExpiry = -1;
|
||||||
|
static readonly FilterContext[] Contexts = [FilterContext.Home, FilterContext.Notifications, FilterContext.Public, FilterContext.Thread, FilterContext.Account];
|
||||||
|
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
||||||
|
|
||||||
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
|
|
||||||
|
List<Filter> filters = [];
|
||||||
|
TitleForm draft = new();
|
||||||
|
FilterEdit edit = new();
|
||||||
|
Filter open;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
bool isConfirmingDelete;
|
||||||
|
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
|
||||||
|
Dictionary<string, Status> filteredPosts = new();
|
||||||
|
HashSet<string> readPostIds = [];
|
||||||
|
|
||||||
|
(int Seconds, string Label)[] Expiries =>
|
||||||
|
[
|
||||||
|
(KeepExpiry, Localizer["Keep its expiry"]),
|
||||||
|
(0, Localizer["Never expires"]),
|
||||||
|
(1800, Localizer["Expires in 30 minutes"]),
|
||||||
|
(3600, Localizer["Expires in 1 hour"]),
|
||||||
|
(43200, Localizer["Expires in 12 hours"]),
|
||||||
|
(86400, Localizer["Expires in 1 day"]),
|
||||||
|
(604800, Localizer["Expires in 1 week"])
|
||||||
|
];
|
||||||
|
|
||||||
|
string ContextName(FilterContext context) => context switch
|
||||||
|
{
|
||||||
|
FilterContext.Home => Localizer["Home and lists"],
|
||||||
|
FilterContext.Notifications => Localizer["Notifications"],
|
||||||
|
FilterContext.Public => Localizer["Public feeds"],
|
||||||
|
FilterContext.Thread => Localizer["Threads"],
|
||||||
|
_ => Localizer["Profiles"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionName(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Hide"],
|
||||||
|
FilterAction.Blur => Localizer["Blur"],
|
||||||
|
_ => Localizer["Warn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionText(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Leave the post out"],
|
||||||
|
FilterAction.Blur => Localizer["Blur its media"],
|
||||||
|
_ => Localizer["Fold the post behind the filter's title"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its filters are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
filteredPosts = new();
|
||||||
|
readPostIds = [];
|
||||||
|
filters = await FilterService.All();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a new filter applies everywhere and warns until it is told otherwise
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(null, new FilterForm { Title = draft.Title.Trim(), Context = [.. Contexts], FilterAction = FilterAction.Warn });
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = (Filter)result.Data;
|
||||||
|
filters.Add(filter);
|
||||||
|
draft = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
||||||
|
await Toggle(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Toggle(Filter filter)
|
||||||
|
{
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = filter;
|
||||||
|
edit = new FilterEdit
|
||||||
|
{
|
||||||
|
Title = filter.Title,
|
||||||
|
Contexts = [.. filter.Context],
|
||||||
|
Action = filter.FilterAction,
|
||||||
|
ExpiresIn = KeepExpiry,
|
||||||
|
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
||||||
|
};
|
||||||
|
await ReadPosts(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
|
||||||
|
async Task ReadPosts(Filter filter)
|
||||||
|
{
|
||||||
|
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return;
|
||||||
|
foreach (var (id, status) in await TimelineService.Read(ids))
|
||||||
|
filteredPosts[id] = status;
|
||||||
|
readPostIds.UnionWith(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
|
||||||
|
|
||||||
|
// what the post says, as plain text: its content warning when it has one, else the start of its words
|
||||||
|
static string Excerpt(Status status)
|
||||||
|
{
|
||||||
|
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
|
||||||
|
if (string.IsNullOrEmpty(html))
|
||||||
|
return null;
|
||||||
|
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
|
||||||
|
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
|
||||||
|
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
void ToggleContext(FilterContext context, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
edit.Contexts.Add(context);
|
||||||
|
else
|
||||||
|
edit.Contexts.Remove(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddKeyword()
|
||||||
|
{
|
||||||
|
var keyword = edit.NewKeyword?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(keyword) || edit.Keywords.Any(k => !k.Removed && string.Equals(k.Keyword, keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
edit.Keywords.Add(new KeywordRow { Keyword = keyword, WholeWord = true });
|
||||||
|
edit.NewKeyword = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveKeyword(KeywordRow row)
|
||||||
|
{
|
||||||
|
if (row.Id is null)
|
||||||
|
edit.Keywords.Remove(row);
|
||||||
|
else
|
||||||
|
row.Removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
AddKeyword();
|
||||||
|
var form = new FilterForm
|
||||||
|
{
|
||||||
|
Title = edit.Title.Trim(),
|
||||||
|
Context = [.. Contexts.Where(edit.Contexts.Contains)],
|
||||||
|
FilterAction = edit.Action,
|
||||||
|
ExpiresIn = edit.ExpiresIn == KeepExpiry ? null : edit.ExpiresIn,
|
||||||
|
KeywordsAttributes =
|
||||||
|
[
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && k.Removed).Select(k => new FilterKeywordForm { Id = k.Id, Destroy = true }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && !k.Removed && k.WholeWord != k.WasWholeWord)
|
||||||
|
.Select(k => new FilterKeywordForm { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is null).Select(k => new FilterKeywordForm { Keyword = k.Keyword, WholeWord = k.WholeWord })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(open.Id, form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = (Filter)result.Data;
|
||||||
|
var index = filters.FindIndex(f => f.Id == saved.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
filters[index] = saved;
|
||||||
|
open = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} saved.", saved.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Delete(open.Id);
|
||||||
|
isSaving = false;
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.RemoveAll(f => f.Id == open.Id);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} deleted.", open.Title]);
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveStatus(Filter filter, FilterStatus item)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.RemoveStatus(item.Id);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.Statuses.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TitleForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class FilterEdit
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public HashSet<FilterContext> Contexts { get; set; } = [];
|
||||||
|
public FilterAction Action { get; set; } = FilterAction.Warn;
|
||||||
|
public int ExpiresIn { get; set; } = KeepExpiry;
|
||||||
|
public List<KeywordRow> Keywords { get; set; } = [];
|
||||||
|
public string NewKeyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class KeywordRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
public bool WasWholeWord { get; set; }
|
||||||
|
public bool Removed { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
@page "/groups"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Groups"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones
|
||||||
|
it runs, edited, their invitation shown, the requests to join answered and members taken out. A circle's members are
|
||||||
|
never shown to anyone but its owner and moderators, here as on every server. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-people text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Groups"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm">@Localizer["A new group"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.UserName" Immediate="true" autocomplete="off" maxlength="32"
|
||||||
|
placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-group-username>
|
||||||
|
<Prefix>@@</Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.Name" Immediate="true" autocomplete="off" maxlength="64"
|
||||||
|
placeholder="@Localizer["Name"]" data-group-name />
|
||||||
|
</div>
|
||||||
|
<DTextarea @bind-Value="draft.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" placeholder="@Localizer["Description"]" />
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsCommunity" LabelText="@Localizer["A community, open to the fediverse (otherwise a private circle)"]" data-group-community />
|
||||||
|
@if (draft.IsCommunity)
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="draft.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
||||||
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in PostingPolicies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.ManuallyApprovesMembers" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="draft.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
||||||
|
placeholder="@Localizer["Invitation password (optional)"]" />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-create>
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Make the group"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<EditForm Model="joining" OnValidSubmit="Join" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-join>
|
||||||
|
<b class="text-sm">@Localizer["Join by invitation"]</b>
|
||||||
|
<DInput TValue="string" @bind-Value="joining.InvitationCode" Immediate="true" autocomplete="off" maxlength="64"
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona is in no group yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var group in groups)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-group-id="@group.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") text-lg"></i>
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate">@(group.Name ?? group.UserName)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + group.Handle)</span>
|
||||||
|
</span>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(group.IsCommunity ? Localizer["Community"] : Localizer["Circle"])</DBadge>
|
||||||
|
<span class="text-sm tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person"></i> @group.MembersCount</span>
|
||||||
|
@if (group.IsOwner)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == group.Id)" OnClick="@(() => Toggle(group))" data-group-manage>
|
||||||
|
<i class="ion-md-settings"></i><span class="hidden sm:inline">@Localizer["Manage"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Leave(group))" data-group-leave>
|
||||||
|
<i class="ion-md-exit"></i><span>@Localizer["Leave"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == group.Id)
|
||||||
|
{
|
||||||
|
@if (group.InvitationCode is { Length: > 0 } invitation)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
|
||||||
|
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
|
||||||
|
aria-label="@Localizer["Invitation code"]" data-group-invitation />
|
||||||
|
<span>@Localizer["Invitation link, for someone without a login here"]</span>
|
||||||
|
<input class="input input-sm w-full font-mono text-xs" value="@($"{Navigation.BaseUri}join?code={Uri.EscapeDataString(invitation)}")" readonly
|
||||||
|
autocomplete="off" onfocus="this.select()" aria-label="@Localizer["Invitation link, for someone without a login here"]" data-group-invitation-link />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-2">
|
||||||
|
<DInput TValue="string" @bind-Value="edit.Name" Immediate="true" autocomplete="off" maxlength="64" aria-label="@Localizer["Name"]" />
|
||||||
|
<DTextarea @bind-Value="edit.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" aria-label="@Localizer["Description"]" />
|
||||||
|
@if (group.IsCommunity)
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="edit.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
||||||
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in PostingPolicies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="editDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="editApproves" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="edit.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
||||||
|
placeholder="@Localizer["A new invitation password"]" />
|
||||||
|
@if (group.IsPasswordRequired)
|
||||||
|
{
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RemoveInvitationPassword" LabelText="@Localizer["Remove the invitation password"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RegenerateInvitationCode" LabelText="@Localizer["A new invitation code (the old one stops working)"]" />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-save>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count(m => !m.IsPending)</span></h2>
|
||||||
|
<div class="flex flex-col gap-2" data-group-members>
|
||||||
|
@foreach (var member in members.OrderBy(m => m.IsPending ? 0 : 1).ThenBy(m => m.Since))
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3 min-w-0" data-member="@member.ActorUri" data-pending="@(member.IsPending ? "true" : "false")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(member.PictureUrl ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate text-sm">@(member.Name ?? member.Handle)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + member.Handle)</span>
|
||||||
|
</span>
|
||||||
|
@if (member.IsPending)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["asks to join"]</DBadge>
|
||||||
|
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, true))" data-member-approve>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Let in"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, false))" data-member-reject>
|
||||||
|
<i class="ion-md-close"></i><span>@Localizer["Decline"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (member.Role == "owner")
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["owner"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Remove(member))" title="@Localizer["Take out of the group"]" data-member-remove>
|
||||||
|
<i class="ion-md-remove-circle"></i><span class="hidden sm:inline">@Localizer["Take out"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
|
||||||
|
|
||||||
|
[Inject] GroupService GroupService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
List<ViewGroup> groups = [];
|
||||||
|
List<ViewGroupMember> members = [];
|
||||||
|
InsertGroupForm draft = new() { PostingPolicy = "followers" };
|
||||||
|
JoinGroupForm joining = new();
|
||||||
|
UpdateGroupForm edit = new();
|
||||||
|
bool editDiscoverable;
|
||||||
|
bool editApproves;
|
||||||
|
ViewGroup open;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
string PolicyName(string policy) => policy switch
|
||||||
|
{
|
||||||
|
"anyone" => Localizer["Anyone may post"],
|
||||||
|
"moderators" => Localizer["Only its moderators post"],
|
||||||
|
_ => Localizer["Its followers post"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its groups are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
// (the forms are the persona's: validated as they are filled, so they name it from the start)
|
||||||
|
draft.AvatarId = CascadingState.PersonaId;
|
||||||
|
joining.AvatarId = CascadingState.PersonaId;
|
||||||
|
groups = await GroupService.List(CascadingState.PersonaId);
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
if (!draft.IsCommunity)
|
||||||
|
draft.IsDiscoverable = false;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Insert(draft);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = (ViewGroup)result.Data;
|
||||||
|
draft = new() { AvatarId = CascadingState.PersonaId, PostingPolicy = "followers" };
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} made.", group.Name ?? group.UserName]);
|
||||||
|
await LoadData();
|
||||||
|
if (groups.FirstOrDefault(g => g.Id == group.Id) is { } made)
|
||||||
|
await Toggle(made);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Join()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Join(joining);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = (ViewGroup)result.Data;
|
||||||
|
joining = new() { AvatarId = CascadingState.PersonaId };
|
||||||
|
Toast.AddResponseSuccess(Localizer["You joined {0}.", group.Name ?? group.UserName]);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Leave(ViewGroup group)
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
|
||||||
|
return;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Remove(group);
|
||||||
|
Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Toggle(ViewGroup group)
|
||||||
|
{
|
||||||
|
if (open?.Id == group.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = group;
|
||||||
|
edit = new UpdateGroupForm { AvatarId = CascadingState.PersonaId, GroupId = group.Id, Name = group.Name, Description = group.Description, PostingPolicy = group.PostingPolicy };
|
||||||
|
editDiscoverable = group.IsDiscoverable;
|
||||||
|
editApproves = group.ManuallyApprovesMembers;
|
||||||
|
members = await GroupService.Members(CascadingState.PersonaId, group.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
edit.IsDiscoverable = open.IsCommunity ? editDiscoverable : null;
|
||||||
|
edit.ManuallyApprovesMembers = editApproves;
|
||||||
|
if (string.IsNullOrEmpty(edit.InvitationPassword))
|
||||||
|
edit.InvitationPassword = null;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Update(edit);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (ViewGroup)result.Data;
|
||||||
|
var index = groups.FindIndex(g => g.Id == updated.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
groups[index] = updated;
|
||||||
|
open = updated;
|
||||||
|
edit.InvitationPassword = null;
|
||||||
|
edit.RemoveInvitationPassword = false;
|
||||||
|
edit.RegenerateInvitationCode = false;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(ViewGroupMember member, bool approve)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = approve
|
||||||
|
? await GroupService.Approve(CascadingState.PersonaId, open.Id, member.ActorUri)
|
||||||
|
: await GroupService.Reject(CascadingState.PersonaId, open.Id, member.ActorUri);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
members = await GroupService.Members(CascadingState.PersonaId, open.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Remove(ViewGroupMember member)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Remove(CascadingState.PersonaId, open.Id, member.ActorUri);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
members.Remove(member);
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is out of the group.", member.Name ?? member.Handle]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user