Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c88980374 | ||
|
|
b8d7858acb | ||
|
|
b731d8c8c0 | ||
|
|
7d4b35f0d7 | ||
|
|
fd2f2e7c65 | ||
|
|
e4414f1ac9 |
No files matched your search
@@ -368,6 +368,3 @@ FodyWeavers.xsd
|
|||||||
.tools/
|
.tools/
|
||||||
wwwroot/css/app.css
|
wwwroot/css/app.css
|
||||||
|
|
||||||
# the e2e tests: the published app they serve and the browser state they keep
|
|
||||||
tests/e2e/.publish/
|
|
||||||
tests/e2e/.state/
|
|
||||||
@@ -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
|
||||||
@@ -45,63 +43,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands
|
|||||||
|
|
||||||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||||||
|
|
||||||
## Testing: end to end against the pasture
|
## Verifying
|
||||||
|
|
||||||
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
|
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
|
||||||
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
|
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
|
||||||
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
|
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
|
||||||
../SocialPub/tools/pasture/town.sh seed village
|
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
|
||||||
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
|
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
|
||||||
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
|
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
|
||||||
time and published only then; the deploy fails if it ever ships.
|
ever ships.
|
||||||
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
|
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
|
||||||
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
|
127.0.0.1:6443"`), and must accept the pasture's CA.
|
||||||
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
|
|
||||||
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
|
|
||||||
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
|
|
||||||
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
|
|
||||||
town's report (`out/client.jsonl`).
|
|
||||||
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
|
||||||
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
|
||||||
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
|
|
||||||
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
|
|
||||||
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
|
|
||||||
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
|
|
||||||
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
|
|
||||||
- sideways scroll, unless the strip is marked `data-scroll-x`;
|
|
||||||
- boxes past the screen's sides;
|
|
||||||
- open menus, sheets and dialogs that are cut or outside the screen;
|
|
||||||
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
|
|
||||||
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
|
|
||||||
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
|
|
||||||
- control text under 12px.
|
|
||||||
|
|
||||||
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
|
|
||||||
`client.mobile.touch.*`, with the phone as the ref.
|
|
||||||
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
|
|
||||||
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
|
|
||||||
- `DesktopUnchangedTests` holds desktop's control sizes.
|
|
||||||
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
|
|
||||||
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
|
|
||||||
turned to the subsolar point.
|
|
||||||
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
|
|
||||||
to the banner.
|
|
||||||
- An idle globe draws no frame.
|
|
||||||
|
|
||||||
Photos and stats go to `tests/e2e/out/globe/`.
|
|
||||||
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
|
|
||||||
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
|
|
||||||
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
|
|
||||||
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
|
||||||
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
|
||||||
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
|
||||||
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
|
|
||||||
|
|
||||||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||||||
|
|
||||||
@@ -125,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.
|
||||||
|
|
||||||
@@ -145,7 +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 persona (see **Settings** under PrivaPub wiring).
|
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);
|
||||||
@@ -167,7 +130,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
- 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
|
- 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.
|
`pointer-coarse:` at a call site.
|
||||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
|
- 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.
|
- 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
|
- **`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:
|
daisyUI's own size variables:
|
||||||
@@ -180,8 +143,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
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 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).
|
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`). The
|
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
|
||||||
phone sweep fails anything less.
|
|
||||||
- **Overlays on a phone rise from the bottom.**
|
- **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
|
- `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`).
|
close button (`ShowClose`, named by `CloseText`).
|
||||||
@@ -197,16 +159,17 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
navigation, which closes every overlay and goes on.
|
navigation, which closes every overlay and goes on.
|
||||||
- No history entries are pushed.
|
- No history entries are pushed.
|
||||||
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
- **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, leaving a group, dropping a scheduled post and
|
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
|
||||||
signing out.
|
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
|
||||||
|
server's thumbnail.
|
||||||
- **On a touch screen:**
|
- **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
|
- 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;
|
pinned to the globe with a mouse only;
|
||||||
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||||
- pictures open in `Components/MediaViewer.razor`.
|
- pictures open in `Components/MediaViewer.razor`.
|
||||||
- **The feed on a phone:**
|
- **The feed on a phone:**
|
||||||
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
|
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
|
||||||
stats' Details are in More, and the author's picture is in the header;
|
the nerd stats' Details are in More, and the author's picture is in the header;
|
||||||
- a new post starts from a floating button;
|
- 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 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`).
|
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||||
@@ -257,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
|
||||||
@@ -277,8 +240,8 @@ supplies:
|
|||||||
`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` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
||||||
PrivaPub 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
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
`HttpClient`'s own is off.
|
`HttpClient`'s own is off.
|
||||||
@@ -295,7 +258,19 @@ 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):
|
- **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
|
- **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
|
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
|
||||||
@@ -304,31 +279,56 @@ supplies:
|
|||||||
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
|
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
|
- 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.
|
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, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
threads, mute, block, poll votes, downvotes, who may quote the persona's posts and revoking a quote of one, emoji
|
||||||
|
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
|
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,
|
`/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, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
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`),
|
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
|
`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`
|
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
|
||||||
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and
|
(`/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`.
|
`ProvenanceService`.
|
||||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
||||||
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
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,
|
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
|
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,
|
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`).
|
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:`
|
- **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
|
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, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
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
|
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
|
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"
|
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`.
|
(`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
|
- **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.
|
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
|
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
||||||
@@ -336,8 +336,16 @@ supplies:
|
|||||||
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
|
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.
|
linking the new account's profile.
|
||||||
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
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.
|
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`).
|
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
|
- **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
|
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.
|
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
||||||
@@ -353,7 +361,11 @@ supplies:
|
|||||||
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
|
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,
|
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
|
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
|
||||||
its media (blur).
|
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
|
- **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
|
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.
|
post opened in its thread shows whole.
|
||||||
@@ -366,7 +378,9 @@ supplies:
|
|||||||
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
- 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 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`);
|
- 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 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
|
- **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.
|
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
|
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||||
@@ -377,15 +391,31 @@ supplies:
|
|||||||
(`/api/v2/search`, never resolving), once typing pauses.
|
(`/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
|
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`).
|
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
|
||||||
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
- **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
|
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.
|
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
|
- **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
|
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.
|
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
|
||||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
|
||||||
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
|
||||||
the client then signs out), the persona's profile (`ProfileEditor`:
|
`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,
|
`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,
|
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,
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
@@ -396,9 +426,67 @@ supplies:
|
|||||||
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
|
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),
|
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.
|
with progress, a report and Stop. Imported posts are delivered to no one.
|
||||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked
|
||||||
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
|
||||||
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
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** (`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.
|
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
|
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
||||||
@@ -411,7 +499,9 @@ supplies:
|
|||||||
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
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
|
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
|
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
|
||||||
links there while requests wait.
|
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
|
- **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.
|
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
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
@@ -432,6 +522,16 @@ supplies:
|
|||||||
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
|
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
|
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.
|
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
|
- **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,
|
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
|
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
|
||||||
@@ -487,17 +587,14 @@ supplies:
|
|||||||
- 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
|
||||||
@@ -507,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
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -27,8 +27,6 @@ namespace decePubClient;
|
|||||||
[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))]
|
||||||
@@ -37,6 +35,20 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ViewServerSetup))]
|
[JsonSerializable(typeof(ViewServerSetup))]
|
||||||
[JsonSerializable(typeof(UserPasswordForm))]
|
[JsonSerializable(typeof(UserPasswordForm))]
|
||||||
[JsonSerializable(typeof(RemoveSelfForm))]
|
[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>))]
|
||||||
@@ -61,6 +73,13 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
[JsonSerializable(typeof(ViewHostsPage))]
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
[JsonSerializable(typeof(ViewCrawler))]
|
[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(ViewBackups))]
|
||||||
[JsonSerializable(typeof(ViewBackup))]
|
[JsonSerializable(typeof(ViewBackup))]
|
||||||
[JsonSerializable(typeof(BackupPasswordForm))]
|
[JsonSerializable(typeof(BackupPasswordForm))]
|
||||||
@@ -71,6 +90,15 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
|
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
|
||||||
[JsonSerializable(typeof(ViewArchiveTicket))]
|
[JsonSerializable(typeof(ViewArchiveTicket))]
|
||||||
[JsonSerializable(typeof(ViewArchiveUpload))]
|
[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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
|
@* 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; resolved here. *@
|
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>
|
<div class="flex flex-col gap-3 w-full" data-reports>
|
||||||
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
|
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
|
||||||
|
|
||||||
@@ -57,6 +58,27 @@
|
|||||||
<b>@Localizer["From"]</b>
|
<b>@Localizer["From"]</b>
|
||||||
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
|
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
|
||||||
</p>
|
</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)
|
@if (report.Comment is { Length: > 0 } comment)
|
||||||
{
|
{
|
||||||
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
|
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
|
||||||
@@ -113,6 +135,10 @@
|
|||||||
IsLoading = false;
|
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
|
string CategoryName(string category) => category switch
|
||||||
{
|
{
|
||||||
"spam" => Localizer["Spam"],
|
"spam" => Localizer["Spam"],
|
||||||
|
|||||||
@@ -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."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
|
@* 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. *@
|
(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>
|
<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">
|
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
@@ -13,13 +16,19 @@
|
|||||||
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
|
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
|
||||||
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
|
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
|
||||||
</div>
|
</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"
|
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
placeholder="@Localizer["Why, for everyone to read"]" />
|
placeholder="@Localizer["Why, for everyone to read"]" />
|
||||||
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
|
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
placeholder="@Localizer["Why, for the admins only"]" />
|
placeholder="@Localizer["Why, for the admins only"]" />
|
||||||
<ErrorViewer />
|
<ErrorViewer />
|
||||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
|
||||||
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save>
|
||||||
@Localizer["Block server"]
|
@Localizer["Block server"]
|
||||||
</DButton>
|
</DButton>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
@@ -48,7 +57,16 @@
|
|||||||
{
|
{
|
||||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
|
<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>
|
<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)">
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
|
||||||
@Localizer["Unblock"]
|
@Localizer["Unblock"]
|
||||||
</DButton>
|
</DButton>
|
||||||
@@ -89,6 +107,25 @@
|
|||||||
|
|
||||||
async Task LoadData() => blocks = await AdminService.ServerBlocks();
|
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()
|
async Task Block()
|
||||||
{
|
{
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
|
@* 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) and how far the crawler got. Counted per server, never per account. *@
|
(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-col gap-4 w-full" data-statistics>
|
||||||
<div class="flex flex-wrap gap-2 items-end">
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
@@ -87,7 +90,10 @@
|
|||||||
@foreach (var host in hosts.Hosts)
|
@foreach (var host in hosts.Hosts)
|
||||||
{
|
{
|
||||||
<tr data-statistics-host="@host.Host">
|
<tr data-statistics-host="@host.Host">
|
||||||
<td class="max-w-48 truncate">@host.Host</td>
|
<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>@host.Software @host.Version</td>
|
||||||
<td class="text-right tabular-nums">@host.In</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.Out</td>
|
||||||
@@ -116,6 +122,11 @@
|
|||||||
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
<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)
|
@if (crawler is not null)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
||||||
@@ -141,6 +152,99 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -148,11 +252,24 @@
|
|||||||
[Inject] AdminService AdminService { get; set; }
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
const int PageSize = 50;
|
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];
|
static readonly int[] Periods = [1, 7, 30, 90];
|
||||||
|
|
||||||
ViewStatisticsOverview overview;
|
ViewStatisticsOverview overview;
|
||||||
ViewHostsPage hosts;
|
ViewHostsPage hosts;
|
||||||
ViewCrawler crawler;
|
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;
|
int days = 30;
|
||||||
string sort = "traffic";
|
string sort = "traffic";
|
||||||
int page = 1;
|
int page = 1;
|
||||||
@@ -179,10 +296,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Task LoadData()
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
await LoadDays();
|
||||||
|
crawler = await AdminService.Crawler();
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// what the period chosen shows
|
||||||
|
async Task LoadDays()
|
||||||
{
|
{
|
||||||
overview = await AdminService.Overview(days);
|
overview = await AdminService.Overview(days);
|
||||||
hosts = await AdminService.Hosts(days, sort, page);
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
crawler = await AdminService.Crawler();
|
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() ?? "—";
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
||||||
@@ -191,7 +351,7 @@
|
|||||||
{
|
{
|
||||||
days = value;
|
days = value;
|
||||||
page = 1;
|
page = 1;
|
||||||
await LoadData();
|
await LoadDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task ChangeSort(string value)
|
async Task ChangeSort(string value)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-24
@@ -124,7 +124,8 @@
|
|||||||
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
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 />
|
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||||
</div>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@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>
|
||||||
@@ -167,17 +168,17 @@
|
|||||||
@if (!form.IsLocated && GroupId is null && Editing 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")" SheetOnNarrow="true"
|
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
|
||||||
ContentClass="mt-2 flex flex-col 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.MessageType.ToString()]]">
|
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
|
||||||
<Trigger>
|
<Trigger>
|
||||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></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" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.Visibility == visibility" OnClick="() => SetVisibility(visibility)">
|
||||||
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
<i class="@visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
|
||||||
<span>@Localizer[messageType.ToString()]</span>
|
<span>@Localizer[visibility.ToString()]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
@@ -215,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>
|
||||||
}
|
}
|
||||||
@@ -391,11 +392,11 @@
|
|||||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
|
|
||||||
// a new post starts with the visibility the persona chose in Settings
|
// a new post starts with the visibility the persona chose in Settings
|
||||||
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
Visibility DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||||
{
|
{
|
||||||
"private" => MessageType.FollowersOnly,
|
"private" => Visibility.FollowersOnly,
|
||||||
"unlisted" => MessageType.Unlisted,
|
"unlisted" => Visibility.Unlisted,
|
||||||
_ => MessageType.Public
|
_ => Visibility.Public
|
||||||
};
|
};
|
||||||
|
|
||||||
DTextarea<string> textarea;
|
DTextarea<string> textarea;
|
||||||
@@ -413,7 +414,7 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
form.MessageType = DefaultVisibility;
|
form.Visibility = DefaultVisibility;
|
||||||
form.Language = DefaultLanguage;
|
form.Language = DefaultLanguage;
|
||||||
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||||
form.QuotedStatusId = Quoting?.Id;
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
@@ -446,12 +447,12 @@
|
|||||||
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
|
// 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.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
|
||||||
@@ -462,9 +463,9 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,10 +600,15 @@
|
|||||||
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
// 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);
|
void Retry(UploadMedia media) => Start(media);
|
||||||
|
|
||||||
void Remove(UploadMedia 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();
|
media.Cancel?.Cancel();
|
||||||
form.Media.Remove(media);
|
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
|
// the reader chose to publish its pictures without descriptions
|
||||||
@@ -688,14 +694,14 @@
|
|||||||
if (result.Data is ScheduledStatus scheduled)
|
if (result.Data is ScheduledStatus scheduled)
|
||||||
{
|
{
|
||||||
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||||
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||||
await OnPublished.InvokeAsync(null);
|
await OnPublished.InvokeAsync(null);
|
||||||
return;
|
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, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||||
await OnPublished.InvokeAsync(published);
|
await OnPublished.InvokeAsync(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+144
-13
@@ -5,10 +5,13 @@
|
|||||||
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
||||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
|
||||||
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
|
The persona's filters apply where the page says the card is (FilterIn): a
|
||||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
|
||||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
|
||||||
|
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
|
||||||
|
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats
|
||||||
|
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||||
@if (!IsFilteredOut)
|
@if (!IsFilteredOut)
|
||||||
{
|
{
|
||||||
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
||||||
@@ -49,6 +52,13 @@
|
|||||||
}
|
}
|
||||||
<span>@NotificationText(notification)</span>
|
<span>@NotificationText(notification)</span>
|
||||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
@if (!Compact)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="ml-auto" Disabled="@(IsDefaultDisabled || isActing)" OnClick="DismissNotification" title="@Localizer["Dismiss"]"
|
||||||
|
aria-label="@Localizer["Dismiss"]" data-notification-dismiss>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
else if (Post.BoostedBy is { } booster)
|
else if (Post.BoostedBy is { } booster)
|
||||||
@@ -283,11 +293,24 @@
|
|||||||
<span class="hidden md:inline">@status.FavouritesCount</span>
|
<span class="hidden md:inline">@status.FavouritesCount</span>
|
||||||
}
|
}
|
||||||
</DButton>
|
</DButton>
|
||||||
@* a threadiverse post's downvotes: its upvotes are its favourites *@
|
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
|
||||||
@if (status.Privapub?.Votes is { Down: > 0 } votes)
|
favourite (on a phone, in More) *@
|
||||||
|
@if (CanDownvote(status) && status.Privapub.Votes is { } votes)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="votes.Downvoted == true" Disabled="IsDefaultDisabled || isActing"
|
||||||
|
OnClick="() => Downvote(status)" title="@(votes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])"
|
||||||
|
aria-pressed="@(votes.Downvoted == true ? "true" : "false")" data-downvote="@(votes.Downvoted == true ? "on" : "off")">
|
||||||
|
<i aria-hidden="true" class="ion-md-arrow-down text-lg @(votes.Downvoted == true ? "text-warning" : null)"></i>
|
||||||
|
@if (votes.Down > 0)
|
||||||
|
{
|
||||||
|
<span class="tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>@votes.Down</span>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (status.Privapub?.Votes is { Down: > 0 } ownVotes)
|
||||||
{
|
{
|
||||||
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
||||||
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
|
<i aria-hidden="true" class="ion-md-arrow-down"></i>@ownVotes.Down
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||||
@@ -335,6 +358,18 @@
|
|||||||
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
|
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||||
<span>@Localizer["Bookmark"]</span>
|
<span>@Localizer["Bookmark"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
@if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="phoneVotes.Downvoted == true"
|
||||||
|
Disabled="IsDefaultDisabled || isActing" OnClick="() => Downvote(status)" data-downvote-phone="@(phoneVotes.Downvoted == true ? "on" : "off")">
|
||||||
|
<i aria-hidden="true" class="ion-md-arrow-down text-base @(phoneVotes.Downvoted == true ? "text-warning" : null)"></i>
|
||||||
|
<span>@(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])</span>
|
||||||
|
@if (phoneVotes.Down > 0)
|
||||||
|
{
|
||||||
|
<span class="opacity-70 tabular-nums">@phoneVotes.Down</span>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
|
||||||
<i aria-hidden="true" class="ion-md-stats text-base"></i>
|
<i aria-hidden="true" class="ion-md-stats text-base"></i>
|
||||||
<span>@Localizer["Details"]</span>
|
<span>@Localizer["Details"]</span>
|
||||||
@@ -376,6 +411,17 @@
|
|||||||
}
|
}
|
||||||
@if (!IsOwn)
|
@if (!IsOwn)
|
||||||
{
|
{
|
||||||
|
@if (QuotesOwnPost(status))
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="RevokeQuote" data-revoke-quote>
|
||||||
|
<i aria-hidden="true" class="ion-md-quote text-base text-error"></i>
|
||||||
|
<span>@Localizer["Revoke quote"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isFilteringPost = true)" data-filter-post>
|
||||||
|
<i aria-hidden="true" class="ion-md-funnel text-base"></i>
|
||||||
|
<span>@Localizer["Filter this post"]</span>
|
||||||
|
</DButton>
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
||||||
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
|
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
|
||||||
<span>@Localizer["Report"]</span>
|
<span>@Localizer["Report"]</span>
|
||||||
@@ -398,6 +444,11 @@
|
|||||||
<i aria-hidden="true" class="ion-md-pin text-base"></i>
|
<i aria-hidden="true" class="ion-md-pin text-base"></i>
|
||||||
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
|
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
|
||||||
|
OnClick="@(() => isChoosingQuotePolicy = true)" data-quote-policy="@StatusService.QuotePolicyValue(StatusService.QuotePolicyOf(status))">
|
||||||
|
<i aria-hidden="true" class="ion-md-quote text-base"></i>
|
||||||
|
<span>@Localizer["Who may quote it"]</span>
|
||||||
|
</DButton>
|
||||||
}
|
}
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
|
||||||
<i aria-hidden="true" class="ion-md-create text-base"></i>
|
<i aria-hidden="true" class="ion-md-create text-base"></i>
|
||||||
@@ -461,6 +512,14 @@
|
|||||||
{
|
{
|
||||||
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
|
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
|
||||||
}
|
}
|
||||||
|
@if (isFilteringPost)
|
||||||
|
{
|
||||||
|
<FilterPostDialog StatusId="@status.Id" OnClosed="@(() => isFilteringPost = false)" OnFiltered="Filtered" />
|
||||||
|
}
|
||||||
|
@if (isChoosingQuotePolicy)
|
||||||
|
{
|
||||||
|
<QuotePolicyDialog Status="status" OnClosed="@(() => isChoosingQuotePolicy = false)" OnChanged="QuotePolicyChanged" />
|
||||||
|
}
|
||||||
@if (isHistoryOpen)
|
@if (isHistoryOpen)
|
||||||
{
|
{
|
||||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||||
@@ -521,6 +580,8 @@
|
|||||||
|
|
||||||
[Parameter, EditorRequired] public FeedPost Post { get; set; }
|
[Parameter, EditorRequired] public FeedPost Post { get; set; }
|
||||||
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
|
||||||
|
/// <summary>Its notification was dismissed: the page takes the item out.</summary>
|
||||||
|
[Parameter] public EventCallback<FeedPost> OnDismissed { get; set; }
|
||||||
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
|
||||||
[Parameter] public EventCallback<string> OnTag { get; set; }
|
[Parameter] public EventCallback<string> OnTag { get; set; }
|
||||||
[Parameter] public string CssContainer { get; set; }
|
[Parameter] public string CssContainer { get; set; }
|
||||||
@@ -547,6 +608,8 @@
|
|||||||
bool isReadingServer;
|
bool isReadingServer;
|
||||||
bool isHistoryOpen;
|
bool isHistoryOpen;
|
||||||
bool isReporting;
|
bool isReporting;
|
||||||
|
bool isFilteringPost;
|
||||||
|
bool isChoosingQuotePolicy;
|
||||||
// who liked it (false) or boosted it (true), when that list is open
|
// who liked it (false) or boosted it (true), when that list is open
|
||||||
bool? showInteractedBy;
|
bool? showInteractedBy;
|
||||||
List<StatusEdit> history = [];
|
List<StatusEdit> history = [];
|
||||||
@@ -572,8 +635,8 @@
|
|||||||
|
|
||||||
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
|
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
|
||||||
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
|
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
|
||||||
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
|
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
|
||||||
// (tests/e2e/Perf/FeedPerfTests).
|
// through.
|
||||||
object renderedWith;
|
object renderedWith;
|
||||||
int handlingEvents;
|
int handlingEvents;
|
||||||
|
|
||||||
@@ -824,6 +887,74 @@
|
|||||||
Post.Status = updated.Reblog ?? updated;
|
Post.Status = updated.Reblog ?? updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card
|
||||||
|
// offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it
|
||||||
|
// has downvotes already. Not on the persona's own post.
|
||||||
|
bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes
|
||||||
|
&& (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software));
|
||||||
|
|
||||||
|
static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb";
|
||||||
|
|
||||||
|
// a vote is public, like a reaction: the persona is told so once, before its first
|
||||||
|
async Task Downvote(Status status)
|
||||||
|
{
|
||||||
|
var on = status.Privapub?.Votes?.Downvoted != true;
|
||||||
|
if (on && !await Notices.Confirm(CascadingState.PersonaId))
|
||||||
|
return;
|
||||||
|
await Act(on ? StatusAction.Downvote : StatusAction.Undownvote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss)
|
||||||
|
async Task DismissNotification()
|
||||||
|
{
|
||||||
|
var notification = Post.Notification;
|
||||||
|
isActing = true;
|
||||||
|
var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}");
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnDismissed.InvokeAsync(Post);
|
||||||
|
}
|
||||||
|
|
||||||
|
// in a filter now: the post is read again, so the filter applies here at once
|
||||||
|
async Task Filtered()
|
||||||
|
{
|
||||||
|
isFilteringPost = false;
|
||||||
|
if (await StatusService.Get(Status.Id) is { } filtered)
|
||||||
|
Post.Status = filtered.Reblog ?? filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
void QuotePolicyChanged(Status updated)
|
||||||
|
{
|
||||||
|
isChoosingQuotePolicy = false;
|
||||||
|
Post.Status = updated.Reblog ?? updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// someone else's post quoting the persona's own: the persona may take its permission back
|
||||||
|
bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId;
|
||||||
|
|
||||||
|
async Task RevokeQuote()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true))
|
||||||
|
return;
|
||||||
|
isActing = true;
|
||||||
|
var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (Status)result.Data;
|
||||||
|
Post.Status = updated.Reblog ?? updated;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Quote revoked."]);
|
||||||
|
}
|
||||||
|
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] PublicNoticeService Notices { get; set; }
|
[Inject] PublicNoticeService Notices { get; set; }
|
||||||
|
|
||||||
@@ -1129,10 +1260,10 @@
|
|||||||
|
|
||||||
string VisibilityText(string visibility) => visibility switch
|
string VisibilityText(string visibility) => visibility switch
|
||||||
{
|
{
|
||||||
"public" => Localizer[MessageType.Public.ToString()],
|
"public" => Localizer[Visibility.Public.ToString()],
|
||||||
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
|
"unlisted" => Localizer[Visibility.Unlisted.ToString()],
|
||||||
"private" => Localizer[MessageType.FollowersOnly.ToString()],
|
"private" => Localizer[Visibility.FollowersOnly.ToString()],
|
||||||
"direct" => Localizer[MessageType.Direct.ToString()],
|
"direct" => Localizer[Visibility.Direct.ToString()],
|
||||||
_ => Localizer["Located"]
|
_ => Localizer["Located"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
|
@* 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. *@
|
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>
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||||
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@@ -11,8 +13,31 @@
|
|||||||
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
|
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
|
||||||
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
|
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
|
||||||
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</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>
|
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
|
||||||
</DSelect>
|
</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"
|
<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 />
|
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
|
||||||
@if (Host is { } host)
|
@if (Host is { } host)
|
||||||
@@ -30,6 +55,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] AboutService AboutService { get; set; }
|
||||||
|
|
||||||
[Parameter, EditorRequired] public Account Account { get; set; }
|
[Parameter, EditorRequired] public Account Account { get; set; }
|
||||||
/// <summary>The post it was opened from, sent with the report.</summary>
|
/// <summary>The post it was opened from, sent with the report.</summary>
|
||||||
@@ -39,17 +65,41 @@
|
|||||||
ReportForm form = new();
|
ReportForm form = new();
|
||||||
bool isSending;
|
bool isSending;
|
||||||
string error;
|
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
|
// the account's own server, for a remote account
|
||||||
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
|
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
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()
|
async Task Send()
|
||||||
{
|
{
|
||||||
isSending = true;
|
|
||||||
error = null;
|
error = null;
|
||||||
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward);
|
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;
|
isSending = false;
|
||||||
if (result.IsInvalid)
|
if (result.IsInvalid)
|
||||||
{
|
{
|
||||||
@@ -62,4 +112,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
|
||||||
|
sealed class RuleChoice
|
||||||
|
{
|
||||||
|
public InstanceRule Rule { get; init; }
|
||||||
|
public bool Picked { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,112 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* The login itself (the root, /clientapi/user): its password changed, or the whole of it deleted, every persona with
|
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
|
||||||
it. Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
|
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-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>
|
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<b class="text-sm">@Localizer["Change the password"]</b>
|
<b class="text-sm">@Localizer["Change the password"]</b>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
|
||||||
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
|
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
|
||||||
placeholder="@Localizer["Current password"]" data-account-current />
|
placeholder="@Localizer["Current password"]" data-account-current />
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@@ -20,6 +121,16 @@
|
|||||||
</DButton>
|
</DButton>
|
||||||
</EditForm>
|
</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>
|
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
|
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
|
||||||
@@ -36,17 +147,125 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AuthService AuthService { get; set; }
|
[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();
|
UserPasswordForm password = new();
|
||||||
RemoveSelfForm removal = 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 understood;
|
||||||
bool isSaving;
|
bool isSaving;
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
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()
|
async Task ChangePassword()
|
||||||
{
|
{
|
||||||
isSaving = true;
|
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);
|
var result = await AuthService.ChangePassword(password);
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
if (result.IsInvalid)
|
if (result.IsInvalid)
|
||||||
@@ -59,6 +278,26 @@
|
|||||||
Toast.AddResponseSuccess(Localizer["Password changed."]);
|
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()
|
async Task Delete()
|
||||||
{
|
{
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
data-export-kind="@data">
|
data-export-kind="@data">
|
||||||
@if (exporting == data)
|
@if (exporting == data)
|
||||||
{
|
{
|
||||||
<DLoading Size="DSize.Xs" />
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ else
|
|||||||
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span>@Localizer["Visibility"]</span>
|
<span>@Localizer["Visibility"]</span>
|
||||||
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
<DSelect TValue="Visibility" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||||
@foreach (var type in Visibilities)
|
@foreach (var type in Visibilities)
|
||||||
{
|
{
|
||||||
<option value="@type">@Localizer[type.ToString()]</option>
|
<option value="@type">@Localizer[type.ToString()]</option>
|
||||||
@@ -130,7 +130,7 @@ else
|
|||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
static readonly Visibility[] Visibilities = [Visibility.Public, Visibility.Unlisted, Visibility.FollowersOnly];
|
||||||
|
|
||||||
Account account;
|
Account account;
|
||||||
ProfileForm form = new();
|
ProfileForm form = new();
|
||||||
|
|||||||
@@ -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."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -72,17 +45,17 @@ public static class GenericExtensions
|
|||||||
_ => 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;
|
||||||
@@ -113,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:
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -42,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()
|
||||||
{
|
{
|
||||||
@@ -59,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);
|
||||||
}
|
}
|
||||||
@@ -77,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).
|
||||||
@@ -161,6 +256,7 @@
|
|||||||
/// <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();
|
||||||
@@ -168,6 +264,8 @@
|
|||||||
|
|
||||||
void ClearSession()
|
void ClearSession()
|
||||||
{
|
{
|
||||||
|
CancelSettingsSave();
|
||||||
|
loginSettings = null;
|
||||||
IsSignedIn = false;
|
IsSignedIn = false;
|
||||||
RootUserName = null;
|
RootUserName = null;
|
||||||
Personas = [];
|
Personas = [];
|
||||||
@@ -236,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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,14 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(GroupedNotifications))]
|
[JsonSerializable(typeof(GroupedNotifications))]
|
||||||
[JsonSerializable(typeof(MarkersForm))]
|
[JsonSerializable(typeof(MarkersForm))]
|
||||||
[JsonSerializable(typeof(UnreadCount))]
|
[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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+45
-4
@@ -9,7 +9,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -20,8 +19,20 @@ public static class APIs
|
|||||||
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 POST_UpdatePassword = "clientapi/user/update/password";
|
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 POST_DeleteAccount = "clientapi/user/delete";
|
||||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
|
||||||
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 POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||||
public const string GET_Groups = "clientapi/group/list";
|
public const string GET_Groups = "clientapi/group/list";
|
||||||
@@ -39,12 +50,28 @@ public static class APIs
|
|||||||
public const string POST_DeletePost = "clientapi/post/delete";
|
public const string POST_DeletePost = "clientapi/post/delete";
|
||||||
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||||
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
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 GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
|
||||||
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
|
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 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_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||||
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||||
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
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 GET_AdminBackups = "clientapi/admin/backups";
|
||||||
public const string POST_AdminBackup = "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 DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||||
@@ -91,11 +118,20 @@ public static class APIs
|
|||||||
public const string GET_Conversations = "api/v1/conversations";
|
public const string GET_Conversations = "api/v1/conversations";
|
||||||
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
|
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 static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||||
public const string GET_Notifications = "api/v1/notifications";
|
|
||||||
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 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 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_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_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
@@ -107,6 +143,8 @@ public static class APIs
|
|||||||
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_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)}";
|
||||||
@@ -136,9 +174,13 @@ public static class APIs
|
|||||||
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
||||||
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
$"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_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_FollowedTags = "api/v1/followed_tags";
|
||||||
public const string GET_GroupedNotifications = "api/v2/notifications";
|
public const string GET_GroupedNotifications = "api/v2/notifications";
|
||||||
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
|
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 POST_Markers = "api/v1/markers";
|
||||||
public const string GET_TrendingTags = "api/v1/trends/tags";
|
public const string GET_TrendingTags = "api/v1/trends/tags";
|
||||||
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
||||||
@@ -159,7 +201,6 @@ public static class APIs
|
|||||||
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)}";
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ 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
|
// 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; }
|
public bool Sensitive { get; set; }
|
||||||
@@ -64,12 +64,12 @@ public class ComposeForm
|
|||||||
[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"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,11 +1,39 @@
|
|||||||
namespace decePubClient.Models.Mastodon;
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
|
/// <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 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 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 class InstanceConfiguration
|
||||||
{
|
{
|
||||||
public MediaConfiguration MediaAttachments { get; set; } = new();
|
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||||
@@ -21,3 +49,34 @@ public class MediaConfiguration
|
|||||||
public long VideoMatrixLimit { get; set; }
|
public long VideoMatrixLimit { get; set; }
|
||||||
public int VideoFrameRateLimit { 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; }
|
||||||
|
}
|
||||||
@@ -254,8 +254,10 @@ public class ReportRequest
|
|||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
public List<string> StatusIds { get; set; } = [];
|
public List<string> StatusIds { get; set; } = [];
|
||||||
public string Comment { get; set; }
|
public string Comment { get; set; }
|
||||||
/// <summary>spam, legal or other.</summary>
|
/// <summary>spam, legal, violation or other.</summary>
|
||||||
public string Category { get; set; }
|
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; }
|
public bool Forward { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,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; }
|
||||||
}
|
}
|
||||||
+31
-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,22 +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;
|
||||||
|
|
||||||
public bool ShowDonatorBadge { get; set; } = false;
|
|
||||||
// before publishing pictures without a description, the composer asks once
|
// before publishing pictures without a description, the composer asks once
|
||||||
public bool RemindAltText { get; set; } = true;
|
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
|
||||||
@@ -61,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace decePubClient.Models
|
|||||||
public bool Bot { get; set; }
|
public bool Bot { get; set; }
|
||||||
public bool Discoverable { get; set; }
|
public bool Discoverable { get; set; }
|
||||||
public bool Indexable { get; set; }
|
public bool Indexable { get; set; }
|
||||||
public MessageType Privacy { get; set; } = MessageType.Public;
|
public Visibility Privacy { get; set; } = Visibility.Public;
|
||||||
public bool Sensitive { get; set; }
|
public bool Sensitive { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
||||||
@@ -43,9 +43,9 @@ namespace decePubClient.Models
|
|||||||
Indexable = account.Indexable == true,
|
Indexable = account.Indexable == true,
|
||||||
Privacy = source.Privacy switch
|
Privacy = source.Privacy switch
|
||||||
{
|
{
|
||||||
"private" => MessageType.FollowersOnly,
|
"private" => Visibility.FollowersOnly,
|
||||||
"unlisted" => MessageType.Unlisted,
|
"unlisted" => Visibility.Unlisted,
|
||||||
_ => MessageType.Public
|
_ => Visibility.Public
|
||||||
},
|
},
|
||||||
Sensitive = source.Sensitive,
|
Sensitive = source.Sensitive,
|
||||||
Language = source.Language,
|
Language = source.Language,
|
||||||
@@ -71,7 +71,7 @@ namespace decePubClient.Models
|
|||||||
{ new StringContent(Flag(Bot)), "bot" },
|
{ new StringContent(Flag(Bot)), "bot" },
|
||||||
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
||||||
{ new StringContent(Flag(Indexable)), "indexable" },
|
{ new StringContent(Flag(Indexable)), "indexable" },
|
||||||
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
{ new StringContent(Privacy switch { Visibility.FollowersOnly => "private", Visibility.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||||
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
||||||
{ new StringContent(Language ?? ""), "source[language]" },
|
{ new StringContent(Language ?? ""), "source[language]" },
|
||||||
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
namespace decePubClient.Models.Types
|
namespace decePubClient.Models.Types
|
||||||
{
|
{
|
||||||
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
|
/// <summary>Why an account is reported (Mastodon's categories). A violation names the server's rules it breaks.</summary>
|
||||||
public enum ReportCategory
|
public enum ReportCategory
|
||||||
{
|
{
|
||||||
Other = 0,
|
Other = 0,
|
||||||
Spam = 1,
|
Spam = 1,
|
||||||
Legal = 2
|
Legal = 2,
|
||||||
|
Violation = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+86
-161
@@ -4,30 +4,15 @@
|
|||||||
|
|
||||||
<Title>@Localizer["Administration"]</Title>
|
<Title>@Localizer["Administration"]</Title>
|
||||||
|
|
||||||
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
|
@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its
|
||||||
up and restore the server. The
|
rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation
|
||||||
other sections wait for PrivaPub endpoints that do not exist yet. *@
|
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>
|
|
||||||
<p class="inline-flex items-center gap-2">
|
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Users"]</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>
|
|
||||||
</ChildContent>
|
|
||||||
</DCollapse>
|
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
|
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
|
||||||
@@ -40,8 +25,60 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-list-box text-xl"></i> <span>@Localizer["Moderation log"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<ModerationLogPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
<AuthorizeView Policy="@Policies.IsAdmin">
|
<AuthorizeView Policy="@Policies.IsAdmin">
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-information-circle text-xl"></i> <span>@Localizer["Instance"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<InstancePanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="rules">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-book text-xl"></i> <span>@Localizer["Rules"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<RulesPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="announcements">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-megaphone text-xl"></i> <span>@Localizer["Announcements"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<AnnouncementsPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<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-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
|
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
|
||||||
@@ -54,7 +91,33 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="relays">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-radio text-xl"></i> <span>@Localizer["Relays"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<RelaysPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="media">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-images text-xl"></i> <span>@Localizer["Media limits"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<MediaLimitsPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<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-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
|
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
|
||||||
@@ -67,7 +130,7 @@
|
|||||||
</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-archive text-xl"></i> <span>@Localizer["Backups"]</span>
|
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
|
||||||
@@ -81,144 +144,6 @@
|
|||||||
</DCollapse>
|
</DCollapse>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
|
||||||
<Header>
|
|
||||||
<p class="inline-flex items-center gap-2">
|
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["ActivityPub?"]</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>
|
|
||||||
</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["Authentication"]</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>
|
|
||||||
</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["Emoji?"]</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>
|
|
||||||
</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["Frontend"]</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>
|
|
||||||
</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["Instance"]</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>
|
|
||||||
</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["Link formatter"]</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>
|
|
||||||
</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["Metadata"]</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>
|
|
||||||
</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["Policies"]</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>
|
|
||||||
</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>
|
|
||||||
</ChildContent>
|
|
||||||
</DCollapse>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
|
||||||
}
|
|
||||||
+61
-6
@@ -6,7 +6,9 @@
|
|||||||
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
@* 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
|
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
|
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
|
||||||
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
|
(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">
|
<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">
|
||||||
|
|
||||||
@@ -124,11 +126,33 @@
|
|||||||
@if (filter.Statuses.Count > 0)
|
@if (filter.Statuses.Count > 0)
|
||||||
{
|
{
|
||||||
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
@foreach (var item in filter.Statuses)
|
@foreach (var item in filter.Statuses)
|
||||||
{
|
{
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
|
||||||
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
|
@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))">
|
<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>
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
@@ -170,6 +194,7 @@
|
|||||||
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
||||||
|
|
||||||
[Inject] FilterService FilterService { get; set; }
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
|
|
||||||
List<Filter> filters = [];
|
List<Filter> filters = [];
|
||||||
TitleForm draft = new();
|
TitleForm draft = new();
|
||||||
@@ -178,6 +203,9 @@
|
|||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
bool isSaving;
|
bool isSaving;
|
||||||
bool isConfirmingDelete;
|
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 =>
|
(int Seconds, string Label)[] Expiries =>
|
||||||
[
|
[
|
||||||
@@ -240,6 +268,8 @@
|
|||||||
{
|
{
|
||||||
IsLoading = true;
|
IsLoading = true;
|
||||||
open = null;
|
open = null;
|
||||||
|
filteredPosts = new();
|
||||||
|
readPostIds = [];
|
||||||
filters = await FilterService.All();
|
filters = await FilterService.All();
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -260,10 +290,10 @@
|
|||||||
filters.Add(filter);
|
filters.Add(filter);
|
||||||
draft = new();
|
draft = new();
|
||||||
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
||||||
Toggle(filter);
|
await Toggle(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Toggle(Filter filter)
|
async Task Toggle(Filter filter)
|
||||||
{
|
{
|
||||||
isConfirmingDelete = false;
|
isConfirmingDelete = false;
|
||||||
if (open?.Id == filter.Id)
|
if (open?.Id == filter.Id)
|
||||||
@@ -281,6 +311,31 @@
|
|||||||
ExpiresIn = KeepExpiry,
|
ExpiresIn = KeepExpiry,
|
||||||
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
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)
|
void ToggleContext(FilterContext context, bool on)
|
||||||
|
|||||||
+48
-9
@@ -10,7 +10,7 @@
|
|||||||
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
|
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
|
||||||
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
|
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
|
||||||
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
|
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
|
||||||
the stream tells is applied at once. *@
|
the stream tells is applied at once. The administrators' announcements show above the tabs (AnnouncementsBanner). *@
|
||||||
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
||||||
|
|
||||||
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
<i class="ion-md-create text-xl" aria-hidden="true"></i>
|
<i class="ion-md-create text-xl" aria-hidden="true"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
|
@* the administrators' announcements: a component of its own, outside the virtualized cards *@
|
||||||
|
<AnnouncementsBanner />
|
||||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
|
||||||
OnChanged="ChangeFeed" />
|
OnChanged="ChangeFeed" />
|
||||||
|
|
||||||
@@ -41,12 +43,36 @@
|
|||||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||||
</PhoneSheet>
|
</PhoneSheet>
|
||||||
|
|
||||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
@if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0))
|
||||||
{
|
{
|
||||||
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
<i class="ion-md-notifications-off"></i>
|
@if (followRequests > 0)
|
||||||
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
{
|
||||||
</a>
|
<DButton Size="DSize.Sm" OnClick="() => isFollowRequestsOpen = true" data-follow-requests-open>
|
||||||
|
<i class="ion-md-person-add" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["{0} follow requests waiting", followRequests]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@if (filteredRequests > 0)
|
||||||
|
{
|
||||||
|
<a class="btn btn-sm" href="notifications/filtered" data-filtered-requests>
|
||||||
|
<i class="ion-md-notifications-off"></i>
|
||||||
|
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (isFollowRequestsOpen)
|
||||||
|
{
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isFollowRequestsOpen = open" CloseText="@Localizer["Close"]"
|
||||||
|
data-follow-requests-dialog>
|
||||||
|
<Heading>@Localizer["Follow requests"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="mt-3">
|
||||||
|
<FollowRequestsPanel OnAnswered="FollowRequestAnswered" />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (pending.Count > 0)
|
@if (pending.Count > 0)
|
||||||
@@ -91,10 +117,9 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
|
||||||
(tests/e2e/Perf/FeedPerfTests) *@
|
|
||||||
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||||
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnDismissed="Dismissed" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
</Virtualize>
|
</Virtualize>
|
||||||
|
|
||||||
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
||||||
@@ -128,6 +153,7 @@
|
|||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] RequestStats RequestStats { get; set; }
|
[Inject] RequestStats RequestStats { get; set; }
|
||||||
[Inject] FeedMemory Memory { get; set; }
|
[Inject] FeedMemory Memory { get; set; }
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
FeedFilter Filter { get; set; } = new();
|
FeedFilter Filter { get; set; } = new();
|
||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
@@ -137,6 +163,9 @@
|
|||||||
int unreadNotifications;
|
int unreadNotifications;
|
||||||
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
||||||
int filteredRequests;
|
int filteredRequests;
|
||||||
|
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
|
||||||
|
int followRequests;
|
||||||
|
bool isFollowRequestsOpen;
|
||||||
int unreadConversations;
|
int unreadConversations;
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
@@ -432,8 +461,11 @@
|
|||||||
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
||||||
unreadNotifications = 0;
|
unreadNotifications = 0;
|
||||||
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
||||||
|
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FollowRequestAnswered(bool accepted) => followRequests = Math.Max(0, followRequests - 1);
|
||||||
|
|
||||||
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
||||||
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
||||||
{
|
{
|
||||||
@@ -569,6 +601,13 @@
|
|||||||
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a dismissed notification is that item alone: others about the same post stay
|
||||||
|
void Dismissed(FeedPost post)
|
||||||
|
{
|
||||||
|
posts.RemoveAll(p => p.Key == post.Key);
|
||||||
|
pending.RemoveAll(p => p.Key == post.Key);
|
||||||
|
}
|
||||||
|
|
||||||
IEnumerable<NerdRow> FeedStats()
|
IEnumerable<NerdRow> FeedStats()
|
||||||
{
|
{
|
||||||
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
||||||
|
|||||||
+1
-1
@@ -115,7 +115,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CascadingState.LoadSession();
|
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||||
isWorking = false;
|
isWorking = false;
|
||||||
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
|
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
|
||||||
Navigation.NavigateTo("");
|
Navigation.NavigateTo("");
|
||||||
|
|||||||
+106
-19
@@ -4,7 +4,11 @@
|
|||||||
|
|
||||||
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
|
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
|
||||||
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
|
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
|
||||||
its setup (Setup); anyone else joins through a group's invitation (Join). *@
|
its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
|
||||||
|
the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
|
||||||
|
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode).
|
||||||
|
The heading names the server as its administrator calls it (/api/v2/instance, read without credentials); About tells
|
||||||
|
the rest. *@
|
||||||
<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 items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
@@ -24,21 +28,49 @@
|
|||||||
}
|
}
|
||||||
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
|
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
|
<h1 class="text-xl font-bold break-words" data-login-title>@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]</h1>
|
||||||
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
|
@if (needsCode)
|
||||||
|
{
|
||||||
|
@* The name and the password stay in the form, out of sight, and go again with the code. *@
|
||||||
|
<p class="text-sm" data-login-code-asked>@Localizer["Enter the code from your authenticator app, or a recovery code."]</p>
|
||||||
|
@if (useRecoveryCode)
|
||||||
|
{
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Recovery code"]</span>
|
||||||
|
<DInput TValue="string" class="w-full" @bind-Value="form.OneTimeCode" required="@true" autocomplete="one-time-code" inputmode="text"
|
||||||
|
autocapitalize="none" spellcheck="false" data-login-recovery>
|
||||||
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
|
||||||
|
<DOtp @bind-Value="form.OneTimeCode" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-login-code />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-center" OnClick="ToggleRecoveryCode" data-login-use-recovery>
|
||||||
|
@(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-sm">@Localizer["Username"]</span>
|
<span class="text-sm">@Localizer["Username"]</span>
|
||||||
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
|
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
|
||||||
<Prefix><i class="ion-md-person"></i></Prefix>
|
<Prefix><i class="ion-md-person"></i></Prefix>
|
||||||
</DInput>
|
</DInput>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-sm">@Localizer["Password"]</span>
|
<span class="text-sm">@Localizer["Password"]</span>
|
||||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
|
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
|
||||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||||
</DInput>
|
</DInput>
|
||||||
</label>
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
<ErrorViewer AdditionalError="@error" />
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
|
||||||
@@ -49,10 +81,23 @@
|
|||||||
}
|
}
|
||||||
<span>@Localizer["Login"]</span>
|
<span>@Localizer["Login"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
@if (needsCode)
|
||||||
|
{
|
||||||
|
<DButton Variant="DButtonVariant.Ghost" Size="DSize.Sm" class="self-center" Disabled="isWorking" OnClick="Back" data-login-back>
|
||||||
|
<i class="ion-md-arrow-back"></i><span>@Localizer["Back"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
|
||||||
|
}
|
||||||
</EditForm>
|
</EditForm>
|
||||||
<p class="text-sm text-center">
|
<p class="text-sm text-center">
|
||||||
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
|
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-sm text-center">
|
||||||
|
<a class="link touch-target" href="about" data-about-offer>@Localizer["About this server"]</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (CascadingState.Personas.Count == 0)
|
else if (CascadingState.Personas.Count == 0)
|
||||||
@@ -94,11 +139,18 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] AboutService AboutService { get; set; }
|
||||||
|
|
||||||
readonly LoginForm form = new();
|
readonly LoginForm form = new();
|
||||||
string error;
|
string error;
|
||||||
bool isWorking;
|
bool isWorking;
|
||||||
bool setupPending;
|
bool setupPending;
|
||||||
|
// the server's own name, once read
|
||||||
|
string serverTitle;
|
||||||
|
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
|
||||||
|
bool needsCode;
|
||||||
|
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
|
||||||
|
bool useRecoveryCode;
|
||||||
|
|
||||||
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
|
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
|
||||||
|
|
||||||
@@ -116,22 +168,40 @@
|
|||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task LoadData() => setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending();
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
serverTitle = (await AboutService.Instance())?.Title;
|
||||||
|
setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending();
|
||||||
|
}
|
||||||
|
|
||||||
async Task SignIn()
|
async Task SignIn()
|
||||||
{
|
{
|
||||||
error = default;
|
error = default;
|
||||||
isWorking = true;
|
isWorking = true;
|
||||||
|
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
|
||||||
var result = await AuthService.Login(form);
|
var result = await AuthService.Login(form);
|
||||||
if (result.IsInvalid)
|
if (AuthService.NeedsOneTimeCode(result))
|
||||||
{
|
{
|
||||||
error = result.ErrorMessage;
|
needsCode = true;
|
||||||
|
form.OneTimeCode = default;
|
||||||
isWorking = false;
|
isWorking = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CascadingState.LoadSession();
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
// a wrong code is PrivaPub's 400, like a wrong password, with its own message; the code is typed again
|
||||||
|
error = result.ErrorMessage;
|
||||||
|
form.OneTimeCode = default;
|
||||||
|
isWorking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||||
isWorking = false;
|
isWorking = false;
|
||||||
|
needsCode = false;
|
||||||
|
useRecoveryCode = false;
|
||||||
|
form.OneTimeCode = default;
|
||||||
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
|
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
|
||||||
Continue();
|
Continue();
|
||||||
}
|
}
|
||||||
@@ -151,6 +221,23 @@
|
|||||||
Continue();
|
Continue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ToggleRecoveryCode()
|
||||||
|
{
|
||||||
|
useRecoveryCode = !useRecoveryCode;
|
||||||
|
form.OneTimeCode = default;
|
||||||
|
error = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Back to the name and the password, for another login: the password is typed again.</summary>
|
||||||
|
void Back()
|
||||||
|
{
|
||||||
|
needsCode = false;
|
||||||
|
useRecoveryCode = false;
|
||||||
|
form.OneTimeCode = default;
|
||||||
|
form.Password = default;
|
||||||
|
error = default;
|
||||||
|
}
|
||||||
|
|
||||||
async Task SignOut() => await CascadingState.SignOut();
|
async Task SignOut() => await CascadingState.SignOut();
|
||||||
|
|
||||||
/// <summary>Back where the reader was sent from, never off the app.</summary>
|
/// <summary>Back where the reader was sent from, never off the app.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
@page "/password-recovery"
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Forgot your password?"]</Title>
|
||||||
|
|
||||||
|
@* A forgotten password (PrivaPub's /clientapi/user/recover): the login's name, or its verified address, asks for a link,
|
||||||
|
and PrivaPub answers everyone with the same sentence, so the page never tells whether a login or an address exists.
|
||||||
|
The link comes back here with its code (?rc=), which leaves the address bar and the history at once and stays in the
|
||||||
|
page; a new password set with it ends every session the login had, and the reader signs in with it. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-signed-in>
|
||||||
|
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
|
||||||
|
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (recoveryCode is null && sentence is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-sent>
|
||||||
|
<h1 class="text-xl font-bold">@Localizer["Check your inbox"]</h1>
|
||||||
|
<p class="text-sm">@sentence</p>
|
||||||
|
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (recoveryCode is null)
|
||||||
|
{
|
||||||
|
<EditForm Model="request" OnValidSubmit="Request" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-request>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<h1 class="text-xl font-bold">@Localizer["Forgot your password?"]</h1>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it."]</p>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Username"]</span>
|
||||||
|
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
|
||||||
|
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username>
|
||||||
|
<Prefix><i class="ion-md-person"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-center opacity-70">@Localizer["or"]</span>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Email address"]</span>
|
||||||
|
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
|
||||||
|
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
|
||||||
|
<Prefix><i class="ion-md-mail"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
|
||||||
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-submit>
|
||||||
|
@if (isWorking)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Send the recovery link"]</span>
|
||||||
|
</DButton>
|
||||||
|
<a class="link text-sm self-center touch-target" href="login">@Localizer["I remember it: sign in"]</a>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else if (IsLoading)
|
||||||
|
{
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else if (isCodeValid is false)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-invalid>
|
||||||
|
<h1 class="text-xl font-bold">@Localizer["This recovery link doesn't work"]</h1>
|
||||||
|
<p class="text-sm">@Localizer["A link works for one hour, and only once. Ask for a new one."]</p>
|
||||||
|
<DButton Color="DColor.Primary" OnClick="AskAgain" data-recovery-again>@Localizer["Ask for a new link"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@
|
||||||
|
<EditForm Model="newPassword" OnValidSubmit="Recover" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<h1 class="text-xl font-bold">@Localizer["Choose a new password"]</h1>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Every device signed in to this login is signed out."]</p>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["New password"]</span>
|
||||||
|
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.NewPassword" autocomplete="new-password" data-recovery-password>
|
||||||
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Repeat the new password"]</span>
|
||||||
|
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.RepeatedPassword" autocomplete="new-password" data-recovery-repeat>
|
||||||
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
|
||||||
|
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-new-submit>
|
||||||
|
@if (isWorking)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Change the password"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
|
||||||
|
readonly PasswordRecoveryForm request = new();
|
||||||
|
NewPasswordForm newPassword = new();
|
||||||
|
/// <summary>The code of the link the page was opened from, kept here once it left the address bar.</summary>
|
||||||
|
string recoveryCode;
|
||||||
|
/// <summary>Whether the code works: null until PrivaPub said.</summary>
|
||||||
|
bool? isCodeValid;
|
||||||
|
/// <summary>PrivaPub's answer to a request, the same for everyone.</summary>
|
||||||
|
string sentence;
|
||||||
|
string error;
|
||||||
|
bool isWorking;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "rc")] public string LinkCode { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (LinkCode is { Length: > 0 } code)
|
||||||
|
{
|
||||||
|
recoveryCode = code.Trim();
|
||||||
|
newPassword.RecoveryCode = recoveryCode;
|
||||||
|
// the code is as good as the password for an hour: out of the address bar and the history at once
|
||||||
|
Navigation.NavigateTo("password-recovery", replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
if (recoveryCode is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = await AuthService.CheckRecoveryCode(recoveryCode);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
error = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCodeValid = result.Data is true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
async Task Request()
|
||||||
|
{
|
||||||
|
error = default;
|
||||||
|
isWorking = true;
|
||||||
|
request.UserName = request.UserName?.Trim();
|
||||||
|
request.Email = request.Email?.Trim().ToLowerInvariant();
|
||||||
|
var result = await AuthService.RequestRecovery(request);
|
||||||
|
isWorking = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
error = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sentence = result.Data is string { Length: > 0 } said
|
||||||
|
? said
|
||||||
|
: Localizer["If that account has an email address, a recovery link is on its way. It works for one hour."];
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskAgain()
|
||||||
|
{
|
||||||
|
recoveryCode = null;
|
||||||
|
isCodeValid = null;
|
||||||
|
newPassword = new();
|
||||||
|
error = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Recover()
|
||||||
|
{
|
||||||
|
error = default;
|
||||||
|
isWorking = true;
|
||||||
|
var result = await AuthService.Recover(newPassword);
|
||||||
|
isWorking = false;
|
||||||
|
if (result.StatusCode is (int)System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
// used or expired meanwhile
|
||||||
|
isCodeValid = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
error = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Password changed: sign in with the new one."]);
|
||||||
|
Navigation.NavigateTo("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-4
@@ -6,7 +6,9 @@
|
|||||||
@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is
|
@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is
|
||||||
to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts,
|
to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts,
|
||||||
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
|
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
|
||||||
never from its server directly. *@
|
never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows
|
||||||
|
(AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow
|
||||||
|
requests waiting for it show above its posts. *@
|
||||||
<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">
|
||||||
|
|
||||||
@@ -142,13 +144,34 @@
|
|||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
|
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
|
||||||
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
||||||
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
@if (IsOwn)
|
||||||
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
{
|
||||||
|
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = false" data-profile-following>
|
||||||
|
<b>@account.FollowingCount</b> @Localizer["following"]
|
||||||
|
</button>
|
||||||
|
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = true" data-profile-followers>
|
||||||
|
<b>@account.FollowersCount</b> @Localizer["followers"]
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
||||||
|
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
@if (IsOwn)
|
||||||
|
{
|
||||||
|
@if (showAccounts is { } followers)
|
||||||
|
{
|
||||||
|
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
|
||||||
|
}
|
||||||
|
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
|
||||||
|
}
|
||||||
|
|
||||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
|
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
|
||||||
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
|
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
|
||||||
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
|
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
|
||||||
@@ -212,6 +235,8 @@
|
|||||||
bool isLoadingMore;
|
bool isLoadingMore;
|
||||||
bool isListMenuOpen;
|
bool isListMenuOpen;
|
||||||
bool isReporting;
|
bool isReporting;
|
||||||
|
// the persona's own followers (true) or the accounts it follows (false), while that list is open
|
||||||
|
bool? showAccounts;
|
||||||
List<MastodonList> allLists = [];
|
List<MastodonList> allLists = [];
|
||||||
HashSet<string> inLists = [];
|
HashSet<string> inLists = [];
|
||||||
|
|
||||||
@@ -359,6 +384,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
|
||||||
|
|
||||||
void Remove(FeedPost post)
|
void Remove(FeedPost post)
|
||||||
{
|
{
|
||||||
posts.RemoveAll(p => p.Key == post.Key);
|
posts.RemoveAll(p => p.Key == post.Key);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||||
|
@* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@
|
||||||
|
<DToggle LabelText="@Localizer["Keep the theme the same on every device signed in to this login"]" Value="CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||||
|
ValueChanged="v => Update(s => s.SyncWithLogin = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||||
|
data-sync-with-login />
|
||||||
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
data-remind-alt-text />
|
data-remind-alt-text />
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CascadingState.LoadSession();
|
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||||
isWorking = false;
|
isWorking = false;
|
||||||
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
|
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
|
||||||
Navigation.NavigateTo("");
|
Navigation.NavigateTo("");
|
||||||
|
|||||||
File renamed without changes.
+4
-4
@@ -11,7 +11,6 @@ using Toolbelt.Blazor.Extensions.DependencyInjection;
|
|||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
builder.RootComponents.Add<App>("#app");
|
builder.RootComponents.Add<App>("#app");
|
||||||
@@ -45,6 +44,9 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
.AddScoped<PortabilityService>()
|
.AddScoped<PortabilityService>()
|
||||||
.AddScoped<AdminService>()
|
.AddScoped<AdminService>()
|
||||||
|
.AddScoped<InstanceAdminService>()
|
||||||
|
.AddScoped<AboutService>()
|
||||||
|
.AddScoped<AnnouncementService>()
|
||||||
.AddScoped<ArchiveService>()
|
.AddScoped<ArchiveService>()
|
||||||
.AddScoped<ListService>()
|
.AddScoped<ListService>()
|
||||||
.AddScoped<FilterService>()
|
.AddScoped<FilterService>()
|
||||||
@@ -63,9 +65,7 @@ builder.Services.AddOptions()
|
|||||||
.AddTransient<IHttpService, HttpService>()
|
.AddTransient<IHttpService, HttpService>()
|
||||||
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
|
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
|
||||||
.AddLocalization()
|
.AddLocalization()
|
||||||
.AddTransient<IStorage, Storage>()
|
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug));
|
||||||
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug))
|
|
||||||
.AddIndexedDb();
|
|
||||||
|
|
||||||
var apiBaseAddress = builder.Configuration["AppConfiguration:ApiBaseAddress"];
|
var apiBaseAddress = builder.Configuration["AppConfiguration:ApiBaseAddress"];
|
||||||
builder.Services.AddHttpClient("default", client =>
|
builder.Services.AddHttpClient("default", client =>
|
||||||
|
|||||||
+823
-43
@@ -41,9 +41,6 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
<data name="ActivityPub?" xml:space="preserve">
|
|
||||||
<value>ActivityPub?</value>
|
|
||||||
</data>
|
|
||||||
<data name="Administration" xml:space="preserve">
|
<data name="Administration" xml:space="preserve">
|
||||||
<value>Amministrazione</value>
|
<value>Amministrazione</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -53,9 +50,6 @@
|
|||||||
<data name="Audio" xml:space="preserve">
|
<data name="Audio" xml:space="preserve">
|
||||||
<value>Audio</value>
|
<value>Audio</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Authentication" xml:space="preserve">
|
|
||||||
<value>Autenticazione</value>
|
|
||||||
</data>
|
|
||||||
<data name="Block" xml:space="preserve">
|
<data name="Block" xml:space="preserve">
|
||||||
<value>Blocca</value>
|
<value>Blocca</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -83,12 +77,6 @@
|
|||||||
<data name="Direct" xml:space="preserve">
|
<data name="Direct" xml:space="preserve">
|
||||||
<value>Diretto</value>
|
<value>Diretto</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Documents" xml:space="preserve">
|
|
||||||
<value>Documenti</value>
|
|
||||||
</data>
|
|
||||||
<data name="Emoji?" xml:space="preserve">
|
|
||||||
<value>Emoji?</value>
|
|
||||||
</data>
|
|
||||||
<data name="Empty" xml:space="preserve">
|
<data name="Empty" xml:space="preserve">
|
||||||
<value>Vuoto</value>
|
<value>Vuoto</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -98,18 +86,12 @@
|
|||||||
<data name="Favourite" xml:space="preserve">
|
<data name="Favourite" xml:space="preserve">
|
||||||
<value>Preferito</value>
|
<value>Preferito</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Federation" xml:space="preserve">
|
|
||||||
<value>Federazione</value>
|
|
||||||
</data>
|
|
||||||
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
||||||
<value>Segui l'impostazione chiara o scura del sistema</value>
|
<value>Segui l'impostazione chiara o scura del sistema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="FollowersOnly" xml:space="preserve">
|
<data name="FollowersOnly" xml:space="preserve">
|
||||||
<value>Solo follower</value>
|
<value>Solo follower</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Frontend" xml:space="preserve">
|
|
||||||
<value>Frontend</value>
|
|
||||||
</data>
|
|
||||||
<data name="General" xml:space="preserve">
|
<data name="General" xml:space="preserve">
|
||||||
<value>Generale</value>
|
<value>Generale</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -131,9 +113,6 @@
|
|||||||
<data name="Light theme colour" xml:space="preserve">
|
<data name="Light theme colour" xml:space="preserve">
|
||||||
<value>Colore del tema chiaro</value>
|
<value>Colore del tema chiaro</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Link formatter" xml:space="preserve">
|
|
||||||
<value>Formattazione dei link</value>
|
|
||||||
</data>
|
|
||||||
<data name="loading" xml:space="preserve">
|
<data name="loading" xml:space="preserve">
|
||||||
<value>caricamento</value>
|
<value>caricamento</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -155,9 +134,6 @@
|
|||||||
<data name="Message scope type: {0}" xml:space="preserve">
|
<data name="Message scope type: {0}" xml:space="preserve">
|
||||||
<value>Visibilità del messaggio: {0}</value>
|
<value>Visibilità del messaggio: {0}</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Metadata" xml:space="preserve">
|
|
||||||
<value>Metadati</value>
|
|
||||||
</data>
|
|
||||||
<data name="Missing content, either message or media" xml:space="preserve">
|
<data name="Missing content, either message or media" xml:space="preserve">
|
||||||
<value>Manca il contenuto: scrivi un messaggio o allega un file</value>
|
<value>Manca il contenuto: scrivi un messaggio o allega un file</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -173,9 +149,6 @@
|
|||||||
<data name="Other" xml:space="preserve">
|
<data name="Other" xml:space="preserve">
|
||||||
<value>Altro</value>
|
<value>Altro</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Policies" xml:space="preserve">
|
|
||||||
<value>Regole</value>
|
|
||||||
</data>
|
|
||||||
<data name="Post" xml:space="preserve">
|
<data name="Post" xml:space="preserve">
|
||||||
<value>Pubblica</value>
|
<value>Pubblica</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -260,15 +233,6 @@
|
|||||||
<data name="{0} blocked." xml:space="preserve">
|
<data name="{0} blocked." xml:space="preserve">
|
||||||
<value>Hai bloccato {0}.</value>
|
<value>Hai bloccato {0}.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="{0} day/s" xml:space="preserve">
|
|
||||||
<value>{0} giorno/i</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} hour/s" xml:space="preserve">
|
|
||||||
<value>{0} ora/e</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} minute/s" xml:space="preserve">
|
|
||||||
<value>{0} minuto/i</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} muted." xml:space="preserve">
|
<data name="{0} muted." xml:space="preserve">
|
||||||
<value>Hai silenziato {0}.</value>
|
<value>Hai silenziato {0}.</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -462,7 +426,7 @@
|
|||||||
<value>condivisioni per preferito</value>
|
<value>condivisioni per preferito</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="camera" xml:space="preserve">
|
<data name="camera" xml:space="preserve">
|
||||||
<value>camera</value>
|
<value>punto di vista</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="centre of view" xml:space="preserve">
|
<data name="centre of view" xml:space="preserve">
|
||||||
<value>centro della vista</value>
|
<value>centro della vista</value>
|
||||||
@@ -677,12 +641,6 @@
|
|||||||
<data name="solar time {0}" xml:space="preserve">
|
<data name="solar time {0}" xml:space="preserve">
|
||||||
<value>ora solare {0}</value>
|
<value>ora solare {0}</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="northern hemisphere" xml:space="preserve">
|
|
||||||
<value>emisfero boreale</value>
|
|
||||||
</data>
|
|
||||||
<data name="southern hemisphere" xml:space="preserve">
|
|
||||||
<value>emisfero australe</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||||
<value>{0} a nord, {1} a sud</value>
|
<value>{0} a nord, {1} a sud</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -2306,4 +2264,826 @@
|
|||||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||||
<value>Link d'invito, per chi non ha un accesso qui</value>
|
<value>Link d'invito, per chi non ha un accesso qui</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Forgot your password?" xml:space="preserve">
|
||||||
|
<value>Hai dimenticato la password?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Check your inbox" xml:space="preserve">
|
||||||
|
<value>Controlla la posta in arrivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
|
||||||
|
<value>Indica il nome utente del tuo accesso o il suo indirizzo email: se l'accesso ha un indirizzo verificato, a quell'indirizzo arriva un link per scegliere una nuova password.</value>
|
||||||
|
</data>
|
||||||
|
<data name="or" xml:space="preserve">
|
||||||
|
<value>oppure</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address" xml:space="preserve">
|
||||||
|
<value>Indirizzo email</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send the recovery link" xml:space="preserve">
|
||||||
|
<value>Invia il link di recupero</value>
|
||||||
|
</data>
|
||||||
|
<data name="I remember it: sign in" xml:space="preserve">
|
||||||
|
<value>Me la ricordo: accedi</value>
|
||||||
|
</data>
|
||||||
|
<data name="This recovery link doesn't work" xml:space="preserve">
|
||||||
|
<value>Questo link di recupero non funziona</value>
|
||||||
|
</data>
|
||||||
|
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
|
||||||
|
<value>Un link funziona per un'ora, e una volta sola. Chiedine uno nuovo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ask for a new link" xml:space="preserve">
|
||||||
|
<value>Chiedi un nuovo link</value>
|
||||||
|
</data>
|
||||||
|
<data name="Choose a new password" xml:space="preserve">
|
||||||
|
<value>Scegli una nuova password</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every device signed in to this login is signed out." xml:space="preserve">
|
||||||
|
<value>Ogni dispositivo collegato a questo accesso viene disconnesso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
|
||||||
|
<value>Se quell'accesso ha un indirizzo email, un link di recupero è in arrivo. Funziona per un'ora.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Password changed: sign in with the new one." xml:space="preserve">
|
||||||
|
<value>Password cambiata: accedi con quella nuova.</value>
|
||||||
|
</data>
|
||||||
|
<data name="verified" xml:space="preserve">
|
||||||
|
<value>verificato</value>
|
||||||
|
</data>
|
||||||
|
<data name="not verified" xml:space="preserve">
|
||||||
|
<value>non verificato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
|
||||||
|
<value>Solo un indirizzo verificato permette di recuperare una password dimenticata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||||
|
<value>Ancora nessun indirizzo email: senza, una password dimenticata non si può recuperare.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enter the code sent to {0}." xml:space="preserve">
|
||||||
|
<value>Inserisci il codice inviato a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Code" xml:space="preserve">
|
||||||
|
<value>Codice</value>
|
||||||
|
</data>
|
||||||
|
<data name="Verify" xml:space="preserve">
|
||||||
|
<value>Verifica</value>
|
||||||
|
</data>
|
||||||
|
<data name="New email address" xml:space="preserve">
|
||||||
|
<value>Nuovo indirizzo email</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send the code" xml:space="preserve">
|
||||||
|
<value>Invia il codice</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the address" xml:space="preserve">
|
||||||
|
<value>Togli l'indirizzo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Change" xml:space="preserve">
|
||||||
|
<value>Cambia</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add an address" xml:space="preserve">
|
||||||
|
<value>Aggiungi un indirizzo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
|
||||||
|
<value>Ogni altro dispositivo collegato a questo accesso viene disconnesso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign out everywhere" xml:space="preserve">
|
||||||
|
<value>Esci ovunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>Si esce da questo accesso su ogni dispositivo, anche questo, con ogni persona: per un dispositivo perso, o una sessione che non è tua.</value>
|
||||||
|
</data>
|
||||||
|
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
|
||||||
|
<value>Se quell'indirizzo si può usare, un codice è in arrivo. Funziona per un'ora.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address verified." xml:space="preserve">
|
||||||
|
<value>Indirizzo email verificato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the email address?" xml:space="preserve">
|
||||||
|
<value>Togliere l'indirizzo email?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||||
|
<value>Senza, una password dimenticata non si può recuperare.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address removed." xml:space="preserve">
|
||||||
|
<value>Indirizzo email tolto.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign out everywhere?" xml:space="preserve">
|
||||||
|
<value>Uscire ovunque?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
|
||||||
|
<value>Ogni dispositivo e ogni persona di questo accesso escono, anche questo. Per rientrare serve la password.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wrong password." xml:space="preserve">
|
||||||
|
<value>Password errata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
|
||||||
|
<value>Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
|
||||||
|
<value>Inserisci il codice dell'app di autenticazione, o un codice di recupero.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery code" xml:space="preserve">
|
||||||
|
<value>Codice di recupero</value>
|
||||||
|
</data>
|
||||||
|
<data name="Code from the authenticator app" xml:space="preserve">
|
||||||
|
<value>Codice dell'app di autenticazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use the authenticator app instead" xml:space="preserve">
|
||||||
|
<value>Usa invece l'app di autenticazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use a recovery code instead" xml:space="preserve">
|
||||||
|
<value>Usa invece un codice di recupero</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in" xml:space="preserve">
|
||||||
|
<value>Verifica in due passaggi</value>
|
||||||
|
</data>
|
||||||
|
<data name="active" xml:space="preserve">
|
||||||
|
<value>attiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="not active" xml:space="preserve">
|
||||||
|
<value>non attiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
|
||||||
|
<value>La verifica in due passaggi è attiva. Ogni altro dispositivo collegato a questo accesso è stato disconnesso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
|
||||||
|
<value>Salva subito questi codici di recupero: non verranno più mostrati.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
|
||||||
|
<value>Ognuno vale per un accesso al posto del codice dell'app, se perdi il telefono.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Copy" xml:space="preserve">
|
||||||
|
<value>Copia</value>
|
||||||
|
</data>
|
||||||
|
<data name="I saved them" xml:space="preserve">
|
||||||
|
<value>Li ho salvati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
|
||||||
|
<value>Per accedere servono la password e un codice dell'app di autenticazione, o uno dei codici di recupero.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery codes left: {0}" xml:space="preserve">
|
||||||
|
<value>Codici di recupero rimasti: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
|
||||||
|
<value>Restano pochi codici di recupero: disattiva e riattiva la verifica in due passaggi per averne dieci nuovi.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn off" xml:space="preserve">
|
||||||
|
<value>Disattiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
|
||||||
|
<value>Per accedere serve, oltre alla password, un codice da un'app di autenticazione sul telefono: così una password rubata non basta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
|
||||||
|
<value>Inquadra questo codice QR con l'app di autenticazione, o inserisci la chiave a mano.</value>
|
||||||
|
</data>
|
||||||
|
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
|
||||||
|
<value>Codice QR della chiave, per l'app di autenticazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Key" xml:space="preserve">
|
||||||
|
<value>Chiave</value>
|
||||||
|
</data>
|
||||||
|
<data name="Open in the authenticator app" xml:space="preserve">
|
||||||
|
<value>Apri nell'app di autenticazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
|
||||||
|
<value>Poi inserisci il codice di 6 cifre che mostra l'app.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn on" xml:space="preserve">
|
||||||
|
<value>Attiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn on two-factor sign-in" xml:space="preserve">
|
||||||
|
<value>Attiva la verifica in due passaggi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery codes copied." xml:space="preserve">
|
||||||
|
<value>Codici di recupero copiati.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn off two-factor sign-in?" xml:space="preserve">
|
||||||
|
<value>Disattivare la verifica in due passaggi?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
|
||||||
|
<value>Per accedere tornerà a bastare la password, e i codici di recupero smetteranno di funzionare.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||||
|
<value>La verifica in due passaggi è disattivata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follow requests" xml:space="preserve">
|
||||||
|
<value>Richieste di follow</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} follow requests waiting" xml:space="preserve">
|
||||||
|
<value>{0} richieste di follow in attesa</value>
|
||||||
|
</data>
|
||||||
|
<data name="No follow request is waiting." xml:space="preserve">
|
||||||
|
<value>Nessuna richiesta di follow in attesa.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody yet." xml:space="preserve">
|
||||||
|
<value>Ancora nessuno.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from followers" xml:space="preserve">
|
||||||
|
<value>Togli dai follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove {0} from your followers?" xml:space="preserve">
|
||||||
|
<value>Togliere {0} dai tuoi follower?</value>
|
||||||
|
</data>
|
||||||
|
<data name="They no longer see your followers-only posts, and may ask to follow you again." xml:space="preserve">
|
||||||
|
<value>Non vedrà più i tuoi post per soli follower, e potrà chiedere di nuovo di seguirti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from your followers." xml:space="preserve">
|
||||||
|
<value>{0} tolto dai tuoi follower.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Downvote" xml:space="preserve">
|
||||||
|
<value>Voto negativo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the downvote" xml:space="preserve">
|
||||||
|
<value>Togli il voto negativo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter this post" xml:space="preserve">
|
||||||
|
<value>Filtra questo post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Already in it" xml:space="preserve">
|
||||||
|
<value>Già presente</value>
|
||||||
|
</data>
|
||||||
|
<data name="Added to {0}." xml:space="preserve">
|
||||||
|
<value>Aggiunto a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote it" xml:space="preserve">
|
||||||
|
<value>Chi può citarlo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your followers" xml:space="preserve">
|
||||||
|
<value>I tuoi follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke quote" xml:space="preserve">
|
||||||
|
<value>Revoca la citazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke this quote of your post?" xml:space="preserve">
|
||||||
|
<value>Revocare questa citazione del tuo post?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your post no longer shows in it." xml:space="preserve">
|
||||||
|
<value>Il tuo post non vi comparirà più.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quote revoked." xml:space="preserve">
|
||||||
|
<value>Citazione revocata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post no longer available" xml:space="preserve">
|
||||||
|
<value>Post non più disponibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe again" xml:space="preserve">
|
||||||
|
<value>Rileggi la descrizione</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub will read what {0} says of itself again." xml:space="preserve">
|
||||||
|
<value>PrivaPub rileggerà ciò che {0} dice di sé.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Day" xml:space="preserve">
|
||||||
|
<value>Giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="live" xml:space="preserve">
|
||||||
|
<value>in corso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last events" xml:space="preserve">
|
||||||
|
<value>Ultimi eventi</value>
|
||||||
|
</data>
|
||||||
|
<data name="When" xml:space="preserve">
|
||||||
|
<value>Quando</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channel" xml:space="preserve">
|
||||||
|
<value>Canale</value>
|
||||||
|
</data>
|
||||||
|
<data name="Activity" xml:space="preserve">
|
||||||
|
<value>Attività</value>
|
||||||
|
</data>
|
||||||
|
<data name="Outcome" xml:space="preserve">
|
||||||
|
<value>Esito</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reason" xml:space="preserve">
|
||||||
|
<value>Motivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Events" xml:space="preserve">
|
||||||
|
<value>Eventi</value>
|
||||||
|
</data>
|
||||||
|
<data name="All" xml:space="preserve">
|
||||||
|
<value>Tutti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server days" xml:space="preserve">
|
||||||
|
<value>Giorni del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Client API" xml:space="preserve">
|
||||||
|
<value>API client</value>
|
||||||
|
</data>
|
||||||
|
<data name="Served" xml:space="preserve">
|
||||||
|
<value>Serviti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ledger" xml:space="preserve">
|
||||||
|
<value>Registro</value>
|
||||||
|
</data>
|
||||||
|
<data name="written / dropped / failed" xml:space="preserve">
|
||||||
|
<value>scritti / scartati / falliti</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a date." xml:space="preserve">
|
||||||
|
<value>"{0}" non è una data.</value>
|
||||||
|
</data>
|
||||||
|
<data name="(set in the server's configuration, Registrations:Mode)" xml:space="preserve">
|
||||||
|
<value>(nella configurazione del server, Registrations:Mode)</value>
|
||||||
|
</data>
|
||||||
|
<data name="0: no quota" xml:space="preserve">
|
||||||
|
<value>0: nessuna quota</value>
|
||||||
|
</data>
|
||||||
|
<data name="A length goes from 1 second to {0}." xml:space="preserve">
|
||||||
|
<value>Una durata va da 1 secondo a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A picture's limit goes from 1 MB to {0}." xml:space="preserve">
|
||||||
|
<value>Il limite di un'immagine va da 1 MB a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it." xml:space="preserve">
|
||||||
|
<value>Un blocco pubblicato compare nella pagina Informazioni e nell'API dell'istanza: il server bloccato, e chiunque, può leggerlo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A rule no longer kept" xml:space="preserve">
|
||||||
|
<value>Una regola non più conservata</value>
|
||||||
|
</data>
|
||||||
|
<data name="A server has {0} rules at most." xml:space="preserve">
|
||||||
|
<value>Un server ha al massimo {0} regole.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A video's limit goes from 1 MB to {0}." xml:space="preserve">
|
||||||
|
<value>Il limite di un video va da 1 MB a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="About this server" xml:space="preserve">
|
||||||
|
<value>Informazioni sul server</value>
|
||||||
|
</data>
|
||||||
|
<data name="About {0}" xml:space="preserve">
|
||||||
|
<value>Informazioni su {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add a rule" xml:space="preserve">
|
||||||
|
<value>Aggiungi una regola</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add the relay" xml:space="preserve">
|
||||||
|
<value>Aggiungi il relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="All day" xml:space="preserve">
|
||||||
|
<value>Tutto il giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="An announcement can't end before it starts." xml:space="preserve">
|
||||||
|
<value>Un annuncio non può finire prima di cominciare.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement" xml:space="preserve">
|
||||||
|
<value>Annuncio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement changed" xml:space="preserve">
|
||||||
|
<value>Annuncio modificato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement deleted" xml:space="preserve">
|
||||||
|
<value>Annuncio eliminato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement written" xml:space="preserve">
|
||||||
|
<value>Annuncio scritto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcements" xml:space="preserve">
|
||||||
|
<value>Annunci</value>
|
||||||
|
</data>
|
||||||
|
<data name="Asked {0} ago" xml:space="preserve">
|
||||||
|
<value>Chiesto {0} fa</value>
|
||||||
|
</data>
|
||||||
|
<data name="At most {0}" xml:space="preserve">
|
||||||
|
<value>Al massimo {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Back to the default picture" xml:space="preserve">
|
||||||
|
<value>Torna all'immagine predefinita</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup deleted" xml:space="preserve">
|
||||||
|
<value>Backup eliminato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup made" xml:space="preserve">
|
||||||
|
<value>Backup fatto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup restored" xml:space="preserve">
|
||||||
|
<value>Backup ripristinato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Both are optional: once its end is past, it is no longer shown." xml:space="preserve">
|
||||||
|
<value>Entrambi facoltativi: passata la fine, non è più mostrato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Bring it back" xml:space="preserve">
|
||||||
|
<value>Ripristinala</value>
|
||||||
|
</data>
|
||||||
|
<data name="Changed {0} ago" xml:space="preserve">
|
||||||
|
<value>Modificato {0} fa</value>
|
||||||
|
</data>
|
||||||
|
<data name="Configured" xml:space="preserve">
|
||||||
|
<value>Configurato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Configured: {0}" xml:space="preserve">
|
||||||
|
<value>Configurato: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact" xml:space="preserve">
|
||||||
|
<value>Contatto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact address" xml:space="preserve">
|
||||||
|
<value>Indirizzo di contatto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact persona" xml:space="preserve">
|
||||||
|
<value>Persona di contatto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete the announcement?" xml:space="preserve">
|
||||||
|
<value>Eliminare l'annuncio?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Description (Markdown)" xml:space="preserve">
|
||||||
|
<value>Descrizione (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Discard changes" xml:space="preserve">
|
||||||
|
<value>Annulla le modifiche</value>
|
||||||
|
</data>
|
||||||
|
<data name="Dismissed by {0}" xml:space="preserve">
|
||||||
|
<value>Ignorato da {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Draft" xml:space="preserve">
|
||||||
|
<value>Bozza</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edit the announcement" xml:space="preserve">
|
||||||
|
<value>Modifica l'annuncio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload." xml:space="preserve">
|
||||||
|
<value>Vuoto, un limite è quello della configurazione del server. Ogni modifica vale subito, dal prossimo caricamento.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Empty, the server has no terms. Without a date, new terms take effect today." xml:space="preserve">
|
||||||
|
<value>Vuoti, il server non ha termini. Senza una data, i nuovi termini valgono da oggi.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ended" xml:space="preserve">
|
||||||
|
<value>Concluso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Everyone reads them on the About page, and a report of a violation names the ones broken." xml:space="preserve">
|
||||||
|
<value>Chiunque le legge nella pagina Informazioni, e una segnalazione di violazione indica quelle infrante.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Explanation" xml:space="preserve">
|
||||||
|
<value>Spiegazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0}" xml:space="preserve">
|
||||||
|
<value>Dal {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0} to {1}" xml:space="preserve">
|
||||||
|
<value>Dal {0} al {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Hide part of its name" xml:space="preserve">
|
||||||
|
<value>Nascondi parte del nome</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect from" xml:space="preserve">
|
||||||
|
<value>In vigore dal</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect from {0}" xml:space="preserve">
|
||||||
|
<value>In vigore dal {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect since {0}" xml:space="preserve">
|
||||||
|
<value>In vigore dal {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="In force: {0}" xml:space="preserve">
|
||||||
|
<value>In vigore: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="It disappears for everyone, with its reactions." xml:space="preserve">
|
||||||
|
<value>Sparisce per tutti, con le sue reazioni.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its actor: {0}" xml:space="preserve">
|
||||||
|
<value>Il suo attore: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Largest picture" xml:space="preserve">
|
||||||
|
<value>Immagine più grande</value>
|
||||||
|
</data>
|
||||||
|
<data name="Largest video or sound" xml:space="preserve">
|
||||||
|
<value>Video o audio più grande</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last changed on {0}" xml:space="preserve">
|
||||||
|
<value>Modificato il {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Limited" xml:space="preserve">
|
||||||
|
<value>Limitato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login deleted" xml:space="preserve">
|
||||||
|
<value>Login eliminato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login made" xml:space="preserve">
|
||||||
|
<value>Login creato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login made administrator" xml:space="preserve">
|
||||||
|
<value>Login reso amministratore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login no longer administrator" xml:space="preserve">
|
||||||
|
<value>Login non più amministratore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Longest video or sound" xml:space="preserve">
|
||||||
|
<value>Video o audio più lungo</value>
|
||||||
|
</data>
|
||||||
|
<data name="MB" xml:space="preserve">
|
||||||
|
<value>MB</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media a login may keep in all" xml:space="preserve">
|
||||||
|
<value>Media che un login può tenere in tutto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media limits" xml:space="preserve">
|
||||||
|
<value>Limiti dei media</value>
|
||||||
|
</data>
|
||||||
|
<data name="Moderated servers" xml:space="preserve">
|
||||||
|
<value>Server moderati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Moderation log" xml:space="preserve">
|
||||||
|
<value>Registro di moderazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move down" xml:space="preserve">
|
||||||
|
<value>Sposta giù</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move up" xml:space="preserve">
|
||||||
|
<value>Sposta su</value>
|
||||||
|
</data>
|
||||||
|
<data name="Named by the configuration (Federation:Relays): removed only there." xml:space="preserve">
|
||||||
|
<value>Indicato dalla configurazione (Federation:Relays): si toglie solo lì.</value>
|
||||||
|
</data>
|
||||||
|
<data name="New announcement" xml:space="preserve">
|
||||||
|
<value>Nuovo annuncio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Next announcement" xml:space="preserve">
|
||||||
|
<value>Annuncio successivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Previous announcement" xml:space="preserve">
|
||||||
|
<value>Annuncio precedente</value>
|
||||||
|
</data>
|
||||||
|
<data name="No announcement yet." xml:space="preserve">
|
||||||
|
<value>Ancora nessun annuncio.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No contact persona" xml:space="preserve">
|
||||||
|
<value>Nessuna persona di contatto</value>
|
||||||
|
</data>
|
||||||
|
<data name="No persona of this server matches." xml:space="preserve">
|
||||||
|
<value>Nessuna persona di questo server corrisponde.</value>
|
||||||
|
</data>
|
||||||
|
<data name="None" xml:space="preserve">
|
||||||
|
<value>Nessuno</value>
|
||||||
|
</data>
|
||||||
|
<data name="None: the server names nobody." xml:space="preserve">
|
||||||
|
<value>Nessuna: il server non indica nessuno.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Not asked yet" xml:space="preserve">
|
||||||
|
<value>Non ancora chiesto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing was done yet." xml:space="preserve">
|
||||||
|
<value>Non è stato ancora fatto nulla.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Persona moved to another login" xml:space="preserve">
|
||||||
|
<value>Persona spostata a un altro login</value>
|
||||||
|
</data>
|
||||||
|
<data name="Plain text, as the instance API, NodeInfo and the server's own actor give it." xml:space="preserve">
|
||||||
|
<value>Testo semplice, come lo danno l'API dell'istanza, NodeInfo e l'attore del server.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Privacy policy" xml:space="preserve">
|
||||||
|
<value>Informativa sulla privacy</value>
|
||||||
|
</data>
|
||||||
|
<data name="Privacy policy (Markdown)" xml:space="preserve">
|
||||||
|
<value>Informativa sulla privacy (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish it" xml:space="preserve">
|
||||||
|
<value>Pubblicalo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish the weekly activity" xml:space="preserve">
|
||||||
|
<value>Pubblica l'attività settimanale</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published" xml:space="preserve">
|
||||||
|
<value>Pubblicato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published, partly hidden" xml:space="preserve">
|
||||||
|
<value>Pubblicato, in parte nascosto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published: shown to every persona" xml:space="preserve">
|
||||||
|
<value>Pubblicato: mostrato a ogni persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Read again" xml:space="preserve">
|
||||||
|
<value>Rileggi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refused by the relay" xml:space="preserve">
|
||||||
|
<value>Rifiutato dal relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relay added" xml:space="preserve">
|
||||||
|
<value>Relay aggiunto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relay removed" xml:space="preserve">
|
||||||
|
<value>Relay tolto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relays" xml:space="preserve">
|
||||||
|
<value>Relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the rule" xml:space="preserve">
|
||||||
|
<value>Togli la regola</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the thumbnail?" xml:space="preserve">
|
||||||
|
<value>Togliere l'anteprima?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Removed rules, kept as old reports name them" xml:space="preserve">
|
||||||
|
<value>Regole tolte, conservate perché le vecchie segnalazioni le indicano</value>
|
||||||
|
</data>
|
||||||
|
<data name="Removed {0} ago" xml:space="preserve">
|
||||||
|
<value>Tolta {0} fa</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report resolved" xml:space="preserve">
|
||||||
|
<value>Segnalazione risolta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report: {0}" xml:space="preserve">
|
||||||
|
<value>Segnalazione: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule added" xml:space="preserve">
|
||||||
|
<value>Regola aggiunta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule changed" xml:space="preserve">
|
||||||
|
<value>Regola modificata</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule removed" xml:space="preserve">
|
||||||
|
<value>Regola tolta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rules" xml:space="preserve">
|
||||||
|
<value>Regole</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rules it names" xml:space="preserve">
|
||||||
|
<value>Regole indicate</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save the limits" xml:space="preserve">
|
||||||
|
<value>Salva i limiti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save the rules" xml:space="preserve">
|
||||||
|
<value>Salva le regole</value>
|
||||||
|
</data>
|
||||||
|
<data name="Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata." xml:space="preserve">
|
||||||
|
<value>Salvata appena scelta: al massimo {0}, adattata a 1200×630 e privata dei metadati.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Saved {0} ago by {1}" xml:space="preserve">
|
||||||
|
<value>Salvato {0} fa da {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search this server's personas" xml:space="preserve">
|
||||||
|
<value>Cerca le persone di questo server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server blocked" xml:space="preserve">
|
||||||
|
<value>Server bloccato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server name" xml:space="preserve">
|
||||||
|
<value>Nome del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server unblocked" xml:space="preserve">
|
||||||
|
<value>Server sbloccato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings saved" xml:space="preserve">
|
||||||
|
<value>Impostazioni salvate</value>
|
||||||
|
</data>
|
||||||
|
<data name="Short description" xml:space="preserve">
|
||||||
|
<value>Descrizione breve</value>
|
||||||
|
</data>
|
||||||
|
<data name="Shown now" xml:space="preserve">
|
||||||
|
<value>Mostrato ora</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign in to {0}" xml:space="preserve">
|
||||||
|
<value>Accedi a {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign-ups" xml:space="preserve">
|
||||||
|
<value>Iscrizioni</value>
|
||||||
|
</data>
|
||||||
|
<data name="Subscribed" xml:space="preserve">
|
||||||
|
<value>Iscritto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Terms of service" xml:space="preserve">
|
||||||
|
<value>Termini di servizio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Terms of service (Markdown)" xml:space="preserve">
|
||||||
|
<value>Termini di servizio (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="The announcement is deleted." xml:space="preserve">
|
||||||
|
<value>L'annuncio è eliminato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The announcement is saved." xml:space="preserve">
|
||||||
|
<value>L'annuncio è salvato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The contact persona is shown to everyone as the one who runs this server." xml:space="preserve">
|
||||||
|
<value>La persona di contatto è mostrata a tutti come chi gestisce questo server.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>L'indirizzo https dell'attore del relay, o della sua inbox. I post pubblici di questo server gli arrivano, e i suoi arrivano qui.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The log could not be read." xml:space="preserve">
|
||||||
|
<value>Non è stato possibile leggere il registro.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The media limits are saved." xml:space="preserve">
|
||||||
|
<value>I limiti dei media sono salvati.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The picture uploaded is deleted, and the server shows its default picture again." xml:space="preserve">
|
||||||
|
<value>L'immagine caricata viene eliminata, e il server torna a mostrare quella predefinita.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The quota can't be below 0." xml:space="preserve">
|
||||||
|
<value>La quota non può essere sotto 0.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay is added: the server subscribes to it now." xml:space="preserve">
|
||||||
|
<value>Il relay è aggiunto: il server ora si iscrive.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay is removed: the server unsubscribes from it now." xml:space="preserve">
|
||||||
|
<value>Il relay è tolto: il server ora annulla l'iscrizione.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay's address" xml:space="preserve">
|
||||||
|
<value>L'indirizzo del relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="The rule" xml:space="preserve">
|
||||||
|
<value>La regola</value>
|
||||||
|
</data>
|
||||||
|
<data name="The rules are saved." xml:space="preserve">
|
||||||
|
<value>Le regole sono salvate.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server has no rules." xml:space="preserve">
|
||||||
|
<value>Il server non ha regole.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server subscribes to no relay." xml:space="preserve">
|
||||||
|
<value>Il server non è iscritto a nessun relay.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's description could not be read." xml:space="preserve">
|
||||||
|
<value>Non è stato possibile leggere la descrizione del server.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's settings are saved." xml:space="preserve">
|
||||||
|
<value>Le impostazioni del server sono salvate.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's settings could not be read." xml:space="preserve">
|
||||||
|
<value>Non è stato possibile leggere le impostazioni del server.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This server does not exchange with these, or keeps them out of its public feeds." xml:space="preserve">
|
||||||
|
<value>Questo server non scambia con questi, o li tiene fuori dai suoi feed pubblici.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail" xml:space="preserve">
|
||||||
|
<value>Anteprima</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail changed" xml:space="preserve">
|
||||||
|
<value>Anteprima cambiata</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail removed." xml:space="preserve">
|
||||||
|
<value>Anteprima tolta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail saved." xml:space="preserve">
|
||||||
|
<value>Anteprima salvata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tick the rules it breaks." xml:space="preserve">
|
||||||
|
<value>Spunta le regole che infrange.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>Dodici settimane di post locali e di nuovi login, leggibili da chiunque. Su un server piccolo quei conteggi dicono chi si è iscritto e quando si scrive: di norma è spento.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in reset" xml:space="preserve">
|
||||||
|
<value>Accesso a due fattori azzerato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Until" xml:space="preserve">
|
||||||
|
<value>Fino al</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use the configured values" xml:space="preserve">
|
||||||
|
<value>Usa i valori configurati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Violation" xml:space="preserve">
|
||||||
|
<value>Viola le regole del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Waiting for the relay" xml:space="preserve">
|
||||||
|
<value>In attesa del relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="What it means, if it needs saying (optional)" xml:space="preserve">
|
||||||
|
<value>Cosa significa, se serve dirlo (facoltativo)</value>
|
||||||
|
</data>
|
||||||
|
<data name="What it says (Markdown)" xml:space="preserve">
|
||||||
|
<value>Cosa dice (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Which of the server's rules does it break?" xml:space="preserve">
|
||||||
|
<value>Quali regole del server infrange?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Write an announcement" xml:space="preserve">
|
||||||
|
<value>Scrivi un annuncio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Written {0} ago" xml:space="preserve">
|
||||||
|
<value>Scritto {0} fa</value>
|
||||||
|
</data>
|
||||||
|
<data name="answered {0} ago" xml:space="preserve">
|
||||||
|
<value>risposto {0} fa</value>
|
||||||
|
</data>
|
||||||
|
<data name="by invitation only" xml:space="preserve">
|
||||||
|
<value>solo su invito</value>
|
||||||
|
</data>
|
||||||
|
<data name="by someone not named" xml:space="preserve">
|
||||||
|
<value>da qualcuno non nominato</value>
|
||||||
|
</data>
|
||||||
|
<data name="by the command line" xml:space="preserve">
|
||||||
|
<value>dalla riga di comando</value>
|
||||||
|
</data>
|
||||||
|
<data name="by you" xml:space="preserve">
|
||||||
|
<value>da te</value>
|
||||||
|
</data>
|
||||||
|
<data name="by {0}" xml:space="preserve">
|
||||||
|
<value>da {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="as configured" xml:space="preserve">
|
||||||
|
<value>come configurato</value>
|
||||||
|
</data>
|
||||||
|
<data name="https://relay.example/actor" xml:space="preserve">
|
||||||
|
<value>https://relay.example/actor</value>
|
||||||
|
</data>
|
||||||
|
<data name="no quota" xml:space="preserve">
|
||||||
|
<value>nessuna quota</value>
|
||||||
|
</data>
|
||||||
|
<data name="open to anyone" xml:space="preserve">
|
||||||
|
<value>aperte a chiunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="seconds" xml:space="preserve">
|
||||||
|
<value>secondi</value>
|
||||||
|
</data>
|
||||||
|
<data name="someone" xml:space="preserve">
|
||||||
|
<value>qualcuno</value>
|
||||||
|
</data>
|
||||||
|
<data name="the command line" xml:space="preserve">
|
||||||
|
<value>la riga di comando</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
+822
-42
@@ -41,9 +41,6 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
<data name="ActivityPub?" xml:space="preserve">
|
|
||||||
<value>ActivityPub?</value>
|
|
||||||
</data>
|
|
||||||
<data name="Administration" xml:space="preserve">
|
<data name="Administration" xml:space="preserve">
|
||||||
<value>Administration</value>
|
<value>Administration</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -53,9 +50,6 @@
|
|||||||
<data name="Audio" xml:space="preserve">
|
<data name="Audio" xml:space="preserve">
|
||||||
<value>Audio</value>
|
<value>Audio</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Authentication" xml:space="preserve">
|
|
||||||
<value>Authentication</value>
|
|
||||||
</data>
|
|
||||||
<data name="Block" xml:space="preserve">
|
<data name="Block" xml:space="preserve">
|
||||||
<value>Block</value>
|
<value>Block</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -83,12 +77,6 @@
|
|||||||
<data name="Direct" xml:space="preserve">
|
<data name="Direct" xml:space="preserve">
|
||||||
<value>Direct</value>
|
<value>Direct</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Documents" xml:space="preserve">
|
|
||||||
<value>Documents</value>
|
|
||||||
</data>
|
|
||||||
<data name="Emoji?" xml:space="preserve">
|
|
||||||
<value>Emoji?</value>
|
|
||||||
</data>
|
|
||||||
<data name="Empty" xml:space="preserve">
|
<data name="Empty" xml:space="preserve">
|
||||||
<value>Empty</value>
|
<value>Empty</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -98,18 +86,12 @@
|
|||||||
<data name="Favourite" xml:space="preserve">
|
<data name="Favourite" xml:space="preserve">
|
||||||
<value>Favourite</value>
|
<value>Favourite</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Federation" xml:space="preserve">
|
|
||||||
<value>Federation</value>
|
|
||||||
</data>
|
|
||||||
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
||||||
<value>Follow the system's light or dark setting</value>
|
<value>Follow the system's light or dark setting</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="FollowersOnly" xml:space="preserve">
|
<data name="FollowersOnly" xml:space="preserve">
|
||||||
<value>Followers only</value>
|
<value>Followers only</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Frontend" xml:space="preserve">
|
|
||||||
<value>Frontend</value>
|
|
||||||
</data>
|
|
||||||
<data name="General" xml:space="preserve">
|
<data name="General" xml:space="preserve">
|
||||||
<value>General</value>
|
<value>General</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -131,9 +113,6 @@
|
|||||||
<data name="Light theme colour" xml:space="preserve">
|
<data name="Light theme colour" xml:space="preserve">
|
||||||
<value>Light theme colour</value>
|
<value>Light theme colour</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Link formatter" xml:space="preserve">
|
|
||||||
<value>Link formatter</value>
|
|
||||||
</data>
|
|
||||||
<data name="loading" xml:space="preserve">
|
<data name="loading" xml:space="preserve">
|
||||||
<value>loading</value>
|
<value>loading</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -155,9 +134,6 @@
|
|||||||
<data name="Message scope type: {0}" xml:space="preserve">
|
<data name="Message scope type: {0}" xml:space="preserve">
|
||||||
<value>Message scope type: {0}</value>
|
<value>Message scope type: {0}</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Metadata" xml:space="preserve">
|
|
||||||
<value>Metadata</value>
|
|
||||||
</data>
|
|
||||||
<data name="Missing content, either message or media" xml:space="preserve">
|
<data name="Missing content, either message or media" xml:space="preserve">
|
||||||
<value>Missing content, either message or media</value>
|
<value>Missing content, either message or media</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -173,9 +149,6 @@
|
|||||||
<data name="Other" xml:space="preserve">
|
<data name="Other" xml:space="preserve">
|
||||||
<value>Other</value>
|
<value>Other</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Policies" xml:space="preserve">
|
|
||||||
<value>Policies</value>
|
|
||||||
</data>
|
|
||||||
<data name="Post" xml:space="preserve">
|
<data name="Post" xml:space="preserve">
|
||||||
<value>Post</value>
|
<value>Post</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -260,15 +233,6 @@
|
|||||||
<data name="{0} blocked." xml:space="preserve">
|
<data name="{0} blocked." xml:space="preserve">
|
||||||
<value>{0} blocked.</value>
|
<value>{0} blocked.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="{0} day/s" xml:space="preserve">
|
|
||||||
<value>{0} day/s</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} hour/s" xml:space="preserve">
|
|
||||||
<value>{0} hour/s</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} minute/s" xml:space="preserve">
|
|
||||||
<value>{0} minute/s</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} muted." xml:space="preserve">
|
<data name="{0} muted." xml:space="preserve">
|
||||||
<value>{0} muted.</value>
|
<value>{0} muted.</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -677,12 +641,6 @@
|
|||||||
<data name="solar time {0}" xml:space="preserve">
|
<data name="solar time {0}" xml:space="preserve">
|
||||||
<value>solar time {0}</value>
|
<value>solar time {0}</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="northern hemisphere" xml:space="preserve">
|
|
||||||
<value>northern hemisphere</value>
|
|
||||||
</data>
|
|
||||||
<data name="southern hemisphere" xml:space="preserve">
|
|
||||||
<value>southern hemisphere</value>
|
|
||||||
</data>
|
|
||||||
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||||
<value>{0} in the north, {1} in the south</value>
|
<value>{0} in the north, {1} in the south</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -2306,4 +2264,826 @@
|
|||||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||||
<value>Invitation link, for someone without a login here</value>
|
<value>Invitation link, for someone without a login here</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Forgot your password?" xml:space="preserve">
|
||||||
|
<value>Forgot your password?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Check your inbox" xml:space="preserve">
|
||||||
|
<value>Check your inbox</value>
|
||||||
|
</data>
|
||||||
|
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
|
||||||
|
<value>Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="or" xml:space="preserve">
|
||||||
|
<value>or</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address" xml:space="preserve">
|
||||||
|
<value>Email address</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send the recovery link" xml:space="preserve">
|
||||||
|
<value>Send the recovery link</value>
|
||||||
|
</data>
|
||||||
|
<data name="I remember it: sign in" xml:space="preserve">
|
||||||
|
<value>I remember it: sign in</value>
|
||||||
|
</data>
|
||||||
|
<data name="This recovery link doesn't work" xml:space="preserve">
|
||||||
|
<value>This recovery link doesn't work</value>
|
||||||
|
</data>
|
||||||
|
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
|
||||||
|
<value>A link works for one hour, and only once. Ask for a new one.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ask for a new link" xml:space="preserve">
|
||||||
|
<value>Ask for a new link</value>
|
||||||
|
</data>
|
||||||
|
<data name="Choose a new password" xml:space="preserve">
|
||||||
|
<value>Choose a new password</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every device signed in to this login is signed out." xml:space="preserve">
|
||||||
|
<value>Every device signed in to this login is signed out.</value>
|
||||||
|
</data>
|
||||||
|
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
|
||||||
|
<value>If that account has an email address, a recovery link is on its way. It works for one hour.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Password changed: sign in with the new one." xml:space="preserve">
|
||||||
|
<value>Password changed: sign in with the new one.</value>
|
||||||
|
</data>
|
||||||
|
<data name="verified" xml:space="preserve">
|
||||||
|
<value>verified</value>
|
||||||
|
</data>
|
||||||
|
<data name="not verified" xml:space="preserve">
|
||||||
|
<value>not verified</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
|
||||||
|
<value>Only a verified address can recover a forgotten password.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||||
|
<value>No email address yet: without one, a forgotten password can't be recovered.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enter the code sent to {0}." xml:space="preserve">
|
||||||
|
<value>Enter the code sent to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Code" xml:space="preserve">
|
||||||
|
<value>Code</value>
|
||||||
|
</data>
|
||||||
|
<data name="Verify" xml:space="preserve">
|
||||||
|
<value>Verify</value>
|
||||||
|
</data>
|
||||||
|
<data name="New email address" xml:space="preserve">
|
||||||
|
<value>New email address</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send the code" xml:space="preserve">
|
||||||
|
<value>Send the code</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the address" xml:space="preserve">
|
||||||
|
<value>Remove the address</value>
|
||||||
|
</data>
|
||||||
|
<data name="Change" xml:space="preserve">
|
||||||
|
<value>Change</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add an address" xml:space="preserve">
|
||||||
|
<value>Add an address</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
|
||||||
|
<value>Every other device signed in to this login is signed out.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign out everywhere" xml:space="preserve">
|
||||||
|
<value>Sign out everywhere</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
|
||||||
|
<value>If that address can be used, a code is on its way to it. It works for one hour.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address verified." xml:space="preserve">
|
||||||
|
<value>Email address verified.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the email address?" xml:space="preserve">
|
||||||
|
<value>Remove the email address?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||||
|
<value>Without one, a forgotten password can't be recovered.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Email address removed." xml:space="preserve">
|
||||||
|
<value>Email address removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign out everywhere?" xml:space="preserve">
|
||||||
|
<value>Sign out everywhere?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
|
||||||
|
<value>Every device and every persona of this login signs out, this one too. Signing in again takes the password.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wrong password." xml:space="preserve">
|
||||||
|
<value>Wrong password.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
|
||||||
|
<value>Keep the theme the same on every device signed in to this login</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
|
||||||
|
<value>Enter the code from your authenticator app, or a recovery code.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery code" xml:space="preserve">
|
||||||
|
<value>Recovery code</value>
|
||||||
|
</data>
|
||||||
|
<data name="Code from the authenticator app" xml:space="preserve">
|
||||||
|
<value>Code from the authenticator app</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use the authenticator app instead" xml:space="preserve">
|
||||||
|
<value>Use the authenticator app instead</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use a recovery code instead" xml:space="preserve">
|
||||||
|
<value>Use a recovery code instead</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in" xml:space="preserve">
|
||||||
|
<value>Two-factor sign-in</value>
|
||||||
|
</data>
|
||||||
|
<data name="active" xml:space="preserve">
|
||||||
|
<value>active</value>
|
||||||
|
</data>
|
||||||
|
<data name="not active" xml:space="preserve">
|
||||||
|
<value>not active</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
|
||||||
|
<value>Two-factor sign-in is on. Every other device signed in to this login was signed out.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
|
||||||
|
<value>Save these recovery codes now: they won't be shown again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
|
||||||
|
<value>Each one signs you in once in place of the app's code, if you lose your phone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Copy" xml:space="preserve">
|
||||||
|
<value>Copy</value>
|
||||||
|
</data>
|
||||||
|
<data name="I saved them" xml:space="preserve">
|
||||||
|
<value>I saved them</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
|
||||||
|
<value>Signing in takes the password and a code from your authenticator app, or one of your recovery codes.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery codes left: {0}" xml:space="preserve">
|
||||||
|
<value>Recovery codes left: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
|
||||||
|
<value>Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn off" xml:space="preserve">
|
||||||
|
<value>Turn off</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
|
||||||
|
<value>Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
|
||||||
|
<value>Scan this QR code with your authenticator app, or type the key into it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
|
||||||
|
<value>QR code of the key, for your authenticator app</value>
|
||||||
|
</data>
|
||||||
|
<data name="Key" xml:space="preserve">
|
||||||
|
<value>Key</value>
|
||||||
|
</data>
|
||||||
|
<data name="Open in the authenticator app" xml:space="preserve">
|
||||||
|
<value>Open in the authenticator app</value>
|
||||||
|
</data>
|
||||||
|
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
|
||||||
|
<value>Then enter the 6-digit code the app shows.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn on" xml:space="preserve">
|
||||||
|
<value>Turn on</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn on two-factor sign-in" xml:space="preserve">
|
||||||
|
<value>Turn on two-factor sign-in</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recovery codes copied." xml:space="preserve">
|
||||||
|
<value>Recovery codes copied.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Turn off two-factor sign-in?" xml:space="preserve">
|
||||||
|
<value>Turn off two-factor sign-in?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
|
||||||
|
<value>Signing in takes only the password again, and the recovery codes stop working.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||||
|
<value>Two-factor sign-in is off.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follow requests" xml:space="preserve">
|
||||||
|
<value>Follow requests</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} follow requests waiting" xml:space="preserve">
|
||||||
|
<value>{0} follow requests waiting</value>
|
||||||
|
</data>
|
||||||
|
<data name="No follow request is waiting." xml:space="preserve">
|
||||||
|
<value>No follow request is waiting.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody yet." xml:space="preserve">
|
||||||
|
<value>Nobody yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from followers" xml:space="preserve">
|
||||||
|
<value>Remove from followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove {0} from your followers?" xml:space="preserve">
|
||||||
|
<value>Remove {0} from your followers?</value>
|
||||||
|
</data>
|
||||||
|
<data name="They no longer see your followers-only posts, and may ask to follow you again." xml:space="preserve">
|
||||||
|
<value>They no longer see your followers-only posts, and may ask to follow you again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from your followers." xml:space="preserve">
|
||||||
|
<value>{0} removed from your followers.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Downvote" xml:space="preserve">
|
||||||
|
<value>Downvote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the downvote" xml:space="preserve">
|
||||||
|
<value>Remove the downvote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter this post" xml:space="preserve">
|
||||||
|
<value>Filter this post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Already in it" xml:space="preserve">
|
||||||
|
<value>Already in it</value>
|
||||||
|
</data>
|
||||||
|
<data name="Added to {0}." xml:space="preserve">
|
||||||
|
<value>Added to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote it" xml:space="preserve">
|
||||||
|
<value>Who may quote it</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your followers" xml:space="preserve">
|
||||||
|
<value>Your followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke quote" xml:space="preserve">
|
||||||
|
<value>Revoke quote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke this quote of your post?" xml:space="preserve">
|
||||||
|
<value>Revoke this quote of your post?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your post no longer shows in it." xml:space="preserve">
|
||||||
|
<value>Your post no longer shows in it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quote revoked." xml:space="preserve">
|
||||||
|
<value>Quote revoked.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post no longer available" xml:space="preserve">
|
||||||
|
<value>Post no longer available</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe again" xml:space="preserve">
|
||||||
|
<value>Describe again</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub will read what {0} says of itself again." xml:space="preserve">
|
||||||
|
<value>PrivaPub will read what {0} says of itself again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Day" xml:space="preserve">
|
||||||
|
<value>Day</value>
|
||||||
|
</data>
|
||||||
|
<data name="live" xml:space="preserve">
|
||||||
|
<value>live</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last events" xml:space="preserve">
|
||||||
|
<value>Last events</value>
|
||||||
|
</data>
|
||||||
|
<data name="When" xml:space="preserve">
|
||||||
|
<value>When</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channel" xml:space="preserve">
|
||||||
|
<value>Channel</value>
|
||||||
|
</data>
|
||||||
|
<data name="Activity" xml:space="preserve">
|
||||||
|
<value>Activity</value>
|
||||||
|
</data>
|
||||||
|
<data name="Outcome" xml:space="preserve">
|
||||||
|
<value>Outcome</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reason" xml:space="preserve">
|
||||||
|
<value>Reason</value>
|
||||||
|
</data>
|
||||||
|
<data name="Events" xml:space="preserve">
|
||||||
|
<value>Events</value>
|
||||||
|
</data>
|
||||||
|
<data name="All" xml:space="preserve">
|
||||||
|
<value>All</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server days" xml:space="preserve">
|
||||||
|
<value>Server days</value>
|
||||||
|
</data>
|
||||||
|
<data name="Client API" xml:space="preserve">
|
||||||
|
<value>Client API</value>
|
||||||
|
</data>
|
||||||
|
<data name="Served" xml:space="preserve">
|
||||||
|
<value>Served</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ledger" xml:space="preserve">
|
||||||
|
<value>Ledger</value>
|
||||||
|
</data>
|
||||||
|
<data name="written / dropped / failed" xml:space="preserve">
|
||||||
|
<value>written / dropped / failed</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a date." xml:space="preserve">
|
||||||
|
<value>"{0}" is not a date.</value>
|
||||||
|
</data>
|
||||||
|
<data name="(set in the server's configuration, Registrations:Mode)" xml:space="preserve">
|
||||||
|
<value>(set in the server's configuration, Registrations:Mode)</value>
|
||||||
|
</data>
|
||||||
|
<data name="0: no quota" xml:space="preserve">
|
||||||
|
<value>0: no quota</value>
|
||||||
|
</data>
|
||||||
|
<data name="A length goes from 1 second to {0}." xml:space="preserve">
|
||||||
|
<value>A length goes from 1 second to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A picture's limit goes from 1 MB to {0}." xml:space="preserve">
|
||||||
|
<value>A picture's limit goes from 1 MB to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it." xml:space="preserve">
|
||||||
|
<value>A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A rule no longer kept" xml:space="preserve">
|
||||||
|
<value>A rule no longer kept</value>
|
||||||
|
</data>
|
||||||
|
<data name="A server has {0} rules at most." xml:space="preserve">
|
||||||
|
<value>A server has {0} rules at most.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A video's limit goes from 1 MB to {0}." xml:space="preserve">
|
||||||
|
<value>A video's limit goes from 1 MB to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="About this server" xml:space="preserve">
|
||||||
|
<value>About this server</value>
|
||||||
|
</data>
|
||||||
|
<data name="About {0}" xml:space="preserve">
|
||||||
|
<value>About {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add a rule" xml:space="preserve">
|
||||||
|
<value>Add a rule</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add the relay" xml:space="preserve">
|
||||||
|
<value>Add the relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="All day" xml:space="preserve">
|
||||||
|
<value>All day</value>
|
||||||
|
</data>
|
||||||
|
<data name="An announcement can't end before it starts." xml:space="preserve">
|
||||||
|
<value>An announcement can't end before it starts.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement" xml:space="preserve">
|
||||||
|
<value>Announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement changed" xml:space="preserve">
|
||||||
|
<value>Announcement changed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement deleted" xml:space="preserve">
|
||||||
|
<value>Announcement deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcement written" xml:space="preserve">
|
||||||
|
<value>Announcement written</value>
|
||||||
|
</data>
|
||||||
|
<data name="Announcements" xml:space="preserve">
|
||||||
|
<value>Announcements</value>
|
||||||
|
</data>
|
||||||
|
<data name="Asked {0} ago" xml:space="preserve">
|
||||||
|
<value>Asked {0} ago</value>
|
||||||
|
</data>
|
||||||
|
<data name="At most {0}" xml:space="preserve">
|
||||||
|
<value>At most {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Back to the default picture" xml:space="preserve">
|
||||||
|
<value>Back to the default picture</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup deleted" xml:space="preserve">
|
||||||
|
<value>Backup deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup made" xml:space="preserve">
|
||||||
|
<value>Backup made</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backup restored" xml:space="preserve">
|
||||||
|
<value>Backup restored</value>
|
||||||
|
</data>
|
||||||
|
<data name="Both are optional: once its end is past, it is no longer shown." xml:space="preserve">
|
||||||
|
<value>Both are optional: once its end is past, it is no longer shown.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Bring it back" xml:space="preserve">
|
||||||
|
<value>Bring it back</value>
|
||||||
|
</data>
|
||||||
|
<data name="Changed {0} ago" xml:space="preserve">
|
||||||
|
<value>Changed {0} ago</value>
|
||||||
|
</data>
|
||||||
|
<data name="Configured" xml:space="preserve">
|
||||||
|
<value>Configured</value>
|
||||||
|
</data>
|
||||||
|
<data name="Configured: {0}" xml:space="preserve">
|
||||||
|
<value>Configured: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact" xml:space="preserve">
|
||||||
|
<value>Contact</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact address" xml:space="preserve">
|
||||||
|
<value>Contact address</value>
|
||||||
|
</data>
|
||||||
|
<data name="Contact persona" xml:space="preserve">
|
||||||
|
<value>Contact persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete the announcement?" xml:space="preserve">
|
||||||
|
<value>Delete the announcement?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Description (Markdown)" xml:space="preserve">
|
||||||
|
<value>Description (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Discard changes" xml:space="preserve">
|
||||||
|
<value>Discard changes</value>
|
||||||
|
</data>
|
||||||
|
<data name="Dismissed by {0}" xml:space="preserve">
|
||||||
|
<value>Dismissed by {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Draft" xml:space="preserve">
|
||||||
|
<value>Draft</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edit the announcement" xml:space="preserve">
|
||||||
|
<value>Edit the announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload." xml:space="preserve">
|
||||||
|
<value>Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Empty, the server has no terms. Without a date, new terms take effect today." xml:space="preserve">
|
||||||
|
<value>Empty, the server has no terms. Without a date, new terms take effect today.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ended" xml:space="preserve">
|
||||||
|
<value>Ended</value>
|
||||||
|
</data>
|
||||||
|
<data name="Everyone reads them on the About page, and a report of a violation names the ones broken." xml:space="preserve">
|
||||||
|
<value>Everyone reads them on the About page, and a report of a violation names the ones broken.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Explanation" xml:space="preserve">
|
||||||
|
<value>Explanation</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0}" xml:space="preserve">
|
||||||
|
<value>From {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0} to {1}" xml:space="preserve">
|
||||||
|
<value>From {0} to {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Hide part of its name" xml:space="preserve">
|
||||||
|
<value>Hide part of its name</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect from" xml:space="preserve">
|
||||||
|
<value>In effect from</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect from {0}" xml:space="preserve">
|
||||||
|
<value>In effect from {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="In effect since {0}" xml:space="preserve">
|
||||||
|
<value>In effect since {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="In force: {0}" xml:space="preserve">
|
||||||
|
<value>In force: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="It disappears for everyone, with its reactions." xml:space="preserve">
|
||||||
|
<value>It disappears for everyone, with its reactions.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its actor: {0}" xml:space="preserve">
|
||||||
|
<value>Its actor: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Largest picture" xml:space="preserve">
|
||||||
|
<value>Largest picture</value>
|
||||||
|
</data>
|
||||||
|
<data name="Largest video or sound" xml:space="preserve">
|
||||||
|
<value>Largest video or sound</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last changed on {0}" xml:space="preserve">
|
||||||
|
<value>Last changed on {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Limited" xml:space="preserve">
|
||||||
|
<value>Limited</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login deleted" xml:space="preserve">
|
||||||
|
<value>Login deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login made" xml:space="preserve">
|
||||||
|
<value>Login made</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login made administrator" xml:space="preserve">
|
||||||
|
<value>Login made administrator</value>
|
||||||
|
</data>
|
||||||
|
<data name="Login no longer administrator" xml:space="preserve">
|
||||||
|
<value>Login no longer administrator</value>
|
||||||
|
</data>
|
||||||
|
<data name="Longest video or sound" xml:space="preserve">
|
||||||
|
<value>Longest video or sound</value>
|
||||||
|
</data>
|
||||||
|
<data name="MB" xml:space="preserve">
|
||||||
|
<value>MB</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media a login may keep in all" xml:space="preserve">
|
||||||
|
<value>Media a login may keep in all</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media limits" xml:space="preserve">
|
||||||
|
<value>Media limits</value>
|
||||||
|
</data>
|
||||||
|
<data name="Moderated servers" xml:space="preserve">
|
||||||
|
<value>Moderated servers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Moderation log" xml:space="preserve">
|
||||||
|
<value>Moderation log</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move down" xml:space="preserve">
|
||||||
|
<value>Move down</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move up" xml:space="preserve">
|
||||||
|
<value>Move up</value>
|
||||||
|
</data>
|
||||||
|
<data name="Named by the configuration (Federation:Relays): removed only there." xml:space="preserve">
|
||||||
|
<value>Named by the configuration (Federation:Relays): removed only there.</value>
|
||||||
|
</data>
|
||||||
|
<data name="New announcement" xml:space="preserve">
|
||||||
|
<value>New announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="Next announcement" xml:space="preserve">
|
||||||
|
<value>Next announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="Previous announcement" xml:space="preserve">
|
||||||
|
<value>Previous announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="No announcement yet." xml:space="preserve">
|
||||||
|
<value>No announcement yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No contact persona" xml:space="preserve">
|
||||||
|
<value>No contact persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="No persona of this server matches." xml:space="preserve">
|
||||||
|
<value>No persona of this server matches.</value>
|
||||||
|
</data>
|
||||||
|
<data name="None" xml:space="preserve">
|
||||||
|
<value>None</value>
|
||||||
|
</data>
|
||||||
|
<data name="None: the server names nobody." xml:space="preserve">
|
||||||
|
<value>None: the server names nobody.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Not asked yet" xml:space="preserve">
|
||||||
|
<value>Not asked yet</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing was done yet." xml:space="preserve">
|
||||||
|
<value>Nothing was done yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Persona moved to another login" xml:space="preserve">
|
||||||
|
<value>Persona moved to another login</value>
|
||||||
|
</data>
|
||||||
|
<data name="Plain text, as the instance API, NodeInfo and the server's own actor give it." xml:space="preserve">
|
||||||
|
<value>Plain text, as the instance API, NodeInfo and the server's own actor give it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Privacy policy" xml:space="preserve">
|
||||||
|
<value>Privacy policy</value>
|
||||||
|
</data>
|
||||||
|
<data name="Privacy policy (Markdown)" xml:space="preserve">
|
||||||
|
<value>Privacy policy (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish it" xml:space="preserve">
|
||||||
|
<value>Publish it</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish the weekly activity" xml:space="preserve">
|
||||||
|
<value>Publish the weekly activity</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published" xml:space="preserve">
|
||||||
|
<value>Published</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published, partly hidden" xml:space="preserve">
|
||||||
|
<value>Published, partly hidden</value>
|
||||||
|
</data>
|
||||||
|
<data name="Published: shown to every persona" xml:space="preserve">
|
||||||
|
<value>Published: shown to every persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Read again" xml:space="preserve">
|
||||||
|
<value>Read again</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refused by the relay" xml:space="preserve">
|
||||||
|
<value>Refused by the relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relay added" xml:space="preserve">
|
||||||
|
<value>Relay added</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relay removed" xml:space="preserve">
|
||||||
|
<value>Relay removed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Relays" xml:space="preserve">
|
||||||
|
<value>Relays</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the rule" xml:space="preserve">
|
||||||
|
<value>Remove the rule</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the thumbnail?" xml:space="preserve">
|
||||||
|
<value>Remove the thumbnail?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Removed rules, kept as old reports name them" xml:space="preserve">
|
||||||
|
<value>Removed rules, kept as old reports name them</value>
|
||||||
|
</data>
|
||||||
|
<data name="Removed {0} ago" xml:space="preserve">
|
||||||
|
<value>Removed {0} ago</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report resolved" xml:space="preserve">
|
||||||
|
<value>Report resolved</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report: {0}" xml:space="preserve">
|
||||||
|
<value>Report: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule added" xml:space="preserve">
|
||||||
|
<value>Rule added</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule changed" xml:space="preserve">
|
||||||
|
<value>Rule changed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rule removed" xml:space="preserve">
|
||||||
|
<value>Rule removed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rules" xml:space="preserve">
|
||||||
|
<value>Rules</value>
|
||||||
|
</data>
|
||||||
|
<data name="Rules it names" xml:space="preserve">
|
||||||
|
<value>Rules it names</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save the limits" xml:space="preserve">
|
||||||
|
<value>Save the limits</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save the rules" xml:space="preserve">
|
||||||
|
<value>Save the rules</value>
|
||||||
|
</data>
|
||||||
|
<data name="Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata." xml:space="preserve">
|
||||||
|
<value>Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Saved {0} ago by {1}" xml:space="preserve">
|
||||||
|
<value>Saved {0} ago by {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search this server's personas" xml:space="preserve">
|
||||||
|
<value>Search this server's personas</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server blocked" xml:space="preserve">
|
||||||
|
<value>Server blocked</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server name" xml:space="preserve">
|
||||||
|
<value>Server name</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server unblocked" xml:space="preserve">
|
||||||
|
<value>Server unblocked</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings saved" xml:space="preserve">
|
||||||
|
<value>Settings saved</value>
|
||||||
|
</data>
|
||||||
|
<data name="Short description" xml:space="preserve">
|
||||||
|
<value>Short description</value>
|
||||||
|
</data>
|
||||||
|
<data name="Shown now" xml:space="preserve">
|
||||||
|
<value>Shown now</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign in to {0}" xml:space="preserve">
|
||||||
|
<value>Sign in to {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sign-ups" xml:space="preserve">
|
||||||
|
<value>Sign-ups</value>
|
||||||
|
</data>
|
||||||
|
<data name="Subscribed" xml:space="preserve">
|
||||||
|
<value>Subscribed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Terms of service" xml:space="preserve">
|
||||||
|
<value>Terms of service</value>
|
||||||
|
</data>
|
||||||
|
<data name="Terms of service (Markdown)" xml:space="preserve">
|
||||||
|
<value>Terms of service (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="The announcement is deleted." xml:space="preserve">
|
||||||
|
<value>The announcement is deleted.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The announcement is saved." xml:space="preserve">
|
||||||
|
<value>The announcement is saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The contact persona is shown to everyone as the one who runs this server." xml:space="preserve">
|
||||||
|
<value>The contact persona is shown to everyone as the one who runs this server.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The log could not be read." xml:space="preserve">
|
||||||
|
<value>The log could not be read.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The media limits are saved." xml:space="preserve">
|
||||||
|
<value>The media limits are saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The picture uploaded is deleted, and the server shows its default picture again." xml:space="preserve">
|
||||||
|
<value>The picture uploaded is deleted, and the server shows its default picture again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The quota can't be below 0." xml:space="preserve">
|
||||||
|
<value>The quota can't be below 0.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay is added: the server subscribes to it now." xml:space="preserve">
|
||||||
|
<value>The relay is added: the server subscribes to it now.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay is removed: the server unsubscribes from it now." xml:space="preserve">
|
||||||
|
<value>The relay is removed: the server unsubscribes from it now.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The relay's address" xml:space="preserve">
|
||||||
|
<value>The relay's address</value>
|
||||||
|
</data>
|
||||||
|
<data name="The rule" xml:space="preserve">
|
||||||
|
<value>The rule</value>
|
||||||
|
</data>
|
||||||
|
<data name="The rules are saved." xml:space="preserve">
|
||||||
|
<value>The rules are saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server has no rules." xml:space="preserve">
|
||||||
|
<value>The server has no rules.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server subscribes to no relay." xml:space="preserve">
|
||||||
|
<value>The server subscribes to no relay.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's description could not be read." xml:space="preserve">
|
||||||
|
<value>The server's description could not be read.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's settings are saved." xml:space="preserve">
|
||||||
|
<value>The server's settings are saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The server's settings could not be read." xml:space="preserve">
|
||||||
|
<value>The server's settings could not be read.</value>
|
||||||
|
</data>
|
||||||
|
<data name="This server does not exchange with these, or keeps them out of its public feeds." xml:space="preserve">
|
||||||
|
<value>This server does not exchange with these, or keeps them out of its public feeds.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail" xml:space="preserve">
|
||||||
|
<value>Thumbnail</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail changed" xml:space="preserve">
|
||||||
|
<value>Thumbnail changed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail removed." xml:space="preserve">
|
||||||
|
<value>Thumbnail removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Thumbnail saved." xml:space="preserve">
|
||||||
|
<value>Thumbnail saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tick the rules it breaks." xml:space="preserve">
|
||||||
|
<value>Tick the rules it breaks.</value>
|
||||||
|
</data>
|
||||||
|
<data name="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." xml:space="preserve">
|
||||||
|
<value>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.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Two-factor sign-in reset" xml:space="preserve">
|
||||||
|
<value>Two-factor sign-in reset</value>
|
||||||
|
</data>
|
||||||
|
<data name="Until" xml:space="preserve">
|
||||||
|
<value>Until</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use the configured values" xml:space="preserve">
|
||||||
|
<value>Use the configured values</value>
|
||||||
|
</data>
|
||||||
|
<data name="Violation" xml:space="preserve">
|
||||||
|
<value>Breaks the server's rules</value>
|
||||||
|
</data>
|
||||||
|
<data name="Waiting for the relay" xml:space="preserve">
|
||||||
|
<value>Waiting for the relay</value>
|
||||||
|
</data>
|
||||||
|
<data name="What it means, if it needs saying (optional)" xml:space="preserve">
|
||||||
|
<value>What it means, if it needs saying (optional)</value>
|
||||||
|
</data>
|
||||||
|
<data name="What it says (Markdown)" xml:space="preserve">
|
||||||
|
<value>What it says (Markdown)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Which of the server's rules does it break?" xml:space="preserve">
|
||||||
|
<value>Which of the server's rules does it break?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Write an announcement" xml:space="preserve">
|
||||||
|
<value>Write an announcement</value>
|
||||||
|
</data>
|
||||||
|
<data name="Written {0} ago" xml:space="preserve">
|
||||||
|
<value>Written {0} ago</value>
|
||||||
|
</data>
|
||||||
|
<data name="answered {0} ago" xml:space="preserve">
|
||||||
|
<value>answered {0} ago</value>
|
||||||
|
</data>
|
||||||
|
<data name="by invitation only" xml:space="preserve">
|
||||||
|
<value>by invitation only</value>
|
||||||
|
</data>
|
||||||
|
<data name="by someone not named" xml:space="preserve">
|
||||||
|
<value>by someone not named</value>
|
||||||
|
</data>
|
||||||
|
<data name="by the command line" xml:space="preserve">
|
||||||
|
<value>by the command line</value>
|
||||||
|
</data>
|
||||||
|
<data name="by you" xml:space="preserve">
|
||||||
|
<value>by you</value>
|
||||||
|
</data>
|
||||||
|
<data name="by {0}" xml:space="preserve">
|
||||||
|
<value>by {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="as configured" xml:space="preserve">
|
||||||
|
<value>as configured</value>
|
||||||
|
</data>
|
||||||
|
<data name="https://relay.example/actor" xml:space="preserve">
|
||||||
|
<value>https://relay.example/actor</value>
|
||||||
|
</data>
|
||||||
|
<data name="no quota" xml:space="preserve">
|
||||||
|
<value>no quota</value>
|
||||||
|
</data>
|
||||||
|
<data name="open to anyone" xml:space="preserve">
|
||||||
|
<value>open to anyone</value>
|
||||||
|
</data>
|
||||||
|
<data name="seconds" xml:space="preserve">
|
||||||
|
<value>seconds</value>
|
||||||
|
</data>
|
||||||
|
<data name="someone" xml:space="preserve">
|
||||||
|
<value>someone</value>
|
||||||
|
</data>
|
||||||
|
<data name="the command line" xml:space="preserve">
|
||||||
|
<value>the command line</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
Generated
+18
@@ -60,6 +60,24 @@ namespace decePubClient.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to The {0} field is not a valid date..
|
||||||
|
/// </summary>
|
||||||
|
public static string InvalidDate {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("InvalidDate", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to The {0} field is not valid..
|
||||||
|
/// </summary>
|
||||||
|
public static string InvalidValue {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("InvalidValue", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
|
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
|
<data name="InvalidDate" xml:space="preserve">
|
||||||
|
<value>Il campo {0} non è una data valida.</value>
|
||||||
|
</data>
|
||||||
|
<data name="InvalidValue" xml:space="preserve">
|
||||||
|
<value>Il campo {0} non è valido.</value>
|
||||||
|
</data>
|
||||||
<data name="MaxLength" xml:space="preserve">
|
<data name="MaxLength" xml:space="preserve">
|
||||||
<value>Il campo {0} può contenere al massimo {1} caratteri.</value>
|
<value>Il campo {0} può contenere al massimo {1} caratteri.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -117,6 +117,12 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</resheader>
|
||||||
|
<data name="InvalidDate" xml:space="preserve">
|
||||||
|
<value>The {0} field is not a valid date.</value>
|
||||||
|
</data>
|
||||||
|
<data name="InvalidValue" xml:space="preserve">
|
||||||
|
<value>The {0} field is not valid.</value>
|
||||||
|
</data>
|
||||||
<data name="MaxLength" xml:space="preserve">
|
<data name="MaxLength" xml:space="preserve">
|
||||||
<value>The field {0} can be at most {1} characters long.</value>
|
<value>The field {0} can be at most {1} characters long.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What this server says of itself to anyone, through the Mastodon instance API, never with credentials: its title,
|
||||||
|
/// descriptions, contact and rules (/api/v2/instance, read once a session and forgotten when the administrator saves),
|
||||||
|
/// its long description, privacy policy and terms of service (sanitized HTML from the administrator's Markdown), and
|
||||||
|
/// the domain blocks it publishes. Values come back null and lists empty on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class AboutService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
InstanceV2 instance;
|
||||||
|
|
||||||
|
/// <summary>The instance's description, kept for the session; read again when `fresh`.</summary>
|
||||||
|
public async Task<InstanceV2> Instance(bool fresh = false, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (instance is not null && !fresh)
|
||||||
|
return instance;
|
||||||
|
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Instance), cancellationToken);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance = await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) ?? instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops what was read, after the administrator changed it.</summary>
|
||||||
|
public void Forget() => instance = null;
|
||||||
|
|
||||||
|
/// <summary>The server's rules in their order (not the ones its administrator removed).</summary>
|
||||||
|
public async Task<List<InstanceRule>> Rules(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceRules, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<InstanceRule>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Rules), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<InstanceDocument> ExtendedDescription(CancellationToken cancellationToken = default) =>
|
||||||
|
Document(APIs.Mastodon.GET_InstanceExtendedDescription, nameof(ExtendedDescription), cancellationToken);
|
||||||
|
|
||||||
|
public Task<InstanceDocument> PrivacyPolicy(CancellationToken cancellationToken = default) =>
|
||||||
|
Document(APIs.Mastodon.GET_InstancePrivacyPolicy, nameof(PrivacyPolicy), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>The terms of service, or null while the server has none (its 404).</summary>
|
||||||
|
public async Task<TermsOfService> Terms(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceTermsOfService, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<TermsOfService>(cancellationToken);
|
||||||
|
|
||||||
|
if (response.StatusCode is not HttpStatusCode.NotFound)
|
||||||
|
await LogFailure(response, nameof(Terms), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The blocks the administrator published, as the server shows them (an obfuscated one partly starred).</summary>
|
||||||
|
public async Task<List<PublicDomainBlock>> DomainBlocks(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceDomainBlocks, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<PublicDomainBlock>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<InstanceDocument> Document(string route, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetTotallyAnon(route, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<InstanceDocument>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, caller, cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AboutService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-17
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
@@ -32,7 +30,7 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
|
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
|
||||||
/// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is
|
/// blocking and muting it, the persona's follow requests, followers and following, and search across the fediverse (a remote handle or URL is
|
||||||
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
|
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
@@ -156,16 +154,10 @@ namespace decePubClient.Services
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
|
/// <summary>A page of the accounts waiting for the persona to accept their follow, newest first; Data is an
|
||||||
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
|
/// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one).</summary>
|
||||||
{
|
public Task<WebResult> FollowRequests(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
|
Accounts(APIs.Mastodon.GET_FollowRequests, maxId, cancellationToken);
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
|
||||||
|
|
||||||
await LogFailure(response, nameof(FollowRequests), cancellationToken);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
|
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
|
||||||
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
||||||
@@ -239,6 +231,21 @@ namespace decePubClient.Services
|
|||||||
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>A page of the persona's followers (only the persona itself may read it: PrivaPub answers [] for anyone
|
||||||
|
/// else's); Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Followers(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_M_AccountFollowers(accountId), maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Removes an account from the persona's followers (its server is told); Data is the new Relationship.</summary>
|
||||||
|
public async Task<WebResult> RemoveFromFollowers(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_RemoveFromFollowers(accountId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
||||||
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
||||||
@@ -279,10 +286,11 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
|
/// <summary>Reports an account, with the posts named, to the persona's server's moderators (a violation with the
|
||||||
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
|
/// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming
|
||||||
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
|
/// the persona.</summary>
|
||||||
CancellationToken cancellationToken = default)
|
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, List<string> ruleIds,
|
||||||
|
bool forward, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
|
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
|
||||||
{
|
{
|
||||||
@@ -290,6 +298,7 @@ namespace decePubClient.Services
|
|||||||
StatusIds = statusIds,
|
StatusIds = statusIds,
|
||||||
Comment = comment?.Trim(),
|
Comment = comment?.Trim(),
|
||||||
Category = category.ToString().ToLowerInvariant(),
|
Category = category.ToString().ToLowerInvariant(),
|
||||||
|
RuleIds = category is ReportCategory.Violation ? ruleIds : [],
|
||||||
Forward = forward
|
Forward = forward
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken: cancellationToken);
|
||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
@@ -11,9 +9,11 @@ using PrivaPub.ClientModels.Admin;
|
|||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
|
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
|
||||||
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
|
/// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
|
||||||
/// call. Lists come back empty and values null on failure; nothing throws.
|
/// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings,
|
||||||
|
/// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back
|
||||||
|
/// empty and values null on failure; nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
@@ -35,6 +35,21 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
|
||||||
|
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
|
||||||
|
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { $"limit={limit}" };
|
||||||
|
if (before is { Length: > 0 })
|
||||||
|
parameters.Add($"before={Uri.EscapeDataString(before)}");
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorLog, [.. parameters], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewAdminActionsPage>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Log), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The servers blocked for everyone here, by name.</summary>
|
/// <summary>The servers blocked for everyone here, by name.</summary>
|
||||||
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
|
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -162,6 +177,61 @@ namespace decePubClient.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One server: what it says of itself, its days (counters keyed as the ledger keys them) and its latest 100
|
||||||
|
/// events; null when PrivaPub knows nothing of it or it cannot be read.</summary>
|
||||||
|
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_M_StatisticsHost(host), [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewHostDetail>(cancellationToken);
|
||||||
|
|
||||||
|
if (response.StatusCode is not HttpStatusCode.NotFound)
|
||||||
|
await LogFailure(response, nameof(Host), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks PrivaPub to read the server's NodeInfo and instance description again (a queued job, answered 202).</summary>
|
||||||
|
public async Task<WebResult> Describe(string host, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_StatisticsDescribe(host), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The ledger's events, newest first, those before `before` when given (the next page starts at the last
|
||||||
|
/// one's time); each filter is left out when null.</summary>
|
||||||
|
public async Task<List<ViewInteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { $"limit={limit}" };
|
||||||
|
if (host is { Length: > 0 })
|
||||||
|
parameters.Add($"host={Uri.EscapeDataString(host)}");
|
||||||
|
if (channel is { Length: > 0 })
|
||||||
|
parameters.Add($"channel={Uri.EscapeDataString(channel)}");
|
||||||
|
if (outcome is { Length: > 0 })
|
||||||
|
parameters.Add($"outcome={Uri.EscapeDataString(outcome)}");
|
||||||
|
if (reason is { Length: > 0 })
|
||||||
|
parameters.Add($"reason={Uri.EscapeDataString(reason)}");
|
||||||
|
if (before is { } at)
|
||||||
|
parameters.Add($"before={Uri.EscapeDataString(at.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))}");
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsEvents, [.. parameters], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewInteractionEvent>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Events), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's own days, oldest first: its client API, the documents it served and its ledger.</summary>
|
||||||
|
public async Task<List<ViewServerDay>> ServerDays(int days, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsServer, [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewServerDay>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(ServerDays), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now,
|
||||||
|
/// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and
|
||||||
|
/// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones
|
||||||
|
/// (StreamingService.Announced). The administrators write them through InstanceAdminService.
|
||||||
|
/// </summary>
|
||||||
|
public class AnnouncementService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The announcements shown now, with whether the persona dismissed each.</summary>
|
||||||
|
public async Task<List<Announcement>> List(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Announcements, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Announcement>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AnnouncementService)}.{nameof(List)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Dismiss(string announcementId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissAnnouncement(announcementId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422).</summary>
|
||||||
|
public async Task<WebResult> React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji);
|
||||||
|
using var response = add
|
||||||
|
? await httpService.Put(route, cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Delete(route, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
@@ -12,29 +12,12 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
|||||||
public async ValueTask<bool> IsOnline(CancellationToken cancellationToken = default) =>
|
public async ValueTask<bool> IsOnline(CancellationToken cancellationToken = default) =>
|
||||||
await jsRuntime.InvokeAsync<bool>("isOnline", cancellationToken);
|
await jsRuntime.InvokeAsync<bool>("isOnline", cancellationToken);
|
||||||
|
|
||||||
public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke<bool>("isServiceWorkerAvailable");
|
|
||||||
|
|
||||||
public string Language() => JsSyncRuntime.Invoke<string>("getLanguage");
|
public string Language() => JsSyncRuntime.Invoke<string>("getLanguage");
|
||||||
|
|
||||||
public void Language(string language) => JsSyncRuntime.InvokeVoid("setLanguage", language);
|
|
||||||
|
|
||||||
public long GetHeight(string classId) => JsSyncRuntime.Invoke<long>("getHeight", classId);
|
|
||||||
|
|
||||||
public void SetAppBadge(int counter) => JsSyncRuntime.InvokeVoid("setAppBadge", counter);
|
public void SetAppBadge(int counter) => JsSyncRuntime.InvokeVoid("setAppBadge", counter);
|
||||||
|
|
||||||
public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge");
|
public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge");
|
||||||
|
|
||||||
public async ValueTask ClearCache(CancellationToken cancellationToken = default) =>
|
|
||||||
await jsRuntime.InvokeVoidAsync("clearCache", cancellationToken);
|
|
||||||
|
|
||||||
public async ValueTask ClearLocalStorage(CancellationToken cancellationToken = default) =>
|
|
||||||
await jsRuntime.InvokeVoidAsync("clearLocalStorage", cancellationToken);
|
|
||||||
|
|
||||||
public void ReloadPage()
|
|
||||||
{
|
|
||||||
JsSyncRuntime.InvokeVoid("reloadPage");
|
|
||||||
}
|
|
||||||
|
|
||||||
// wwwroot/js/theme.js
|
// wwwroot/js/theme.js
|
||||||
public void ApplyTheme(PageSettings pageSettings) => JsSyncRuntime.InvokeVoid("neoTheme.apply", pageSettings);
|
public void ApplyTheme(PageSettings pageSettings) => JsSyncRuntime.InvokeVoid("neoTheme.apply", pageSettings);
|
||||||
|
|
||||||
@@ -53,14 +36,10 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
|||||||
|
|
||||||
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
||||||
|
|
||||||
|
// puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs
|
||||||
|
public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) =>
|
||||||
|
await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text);
|
||||||
|
|
||||||
// a sideways-scrolling strip of tabs shows its selected tab
|
// a sideways-scrolling strip of tabs shows its selected tab
|
||||||
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||||
|
|
||||||
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
|
|
||||||
|
|
||||||
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
|
|
||||||
await jsRuntime.InvokeVoidAsync("shareTextUrl", cancellationToken, title, text, uri.ToString());
|
|
||||||
|
|
||||||
public async ValueTask ShareText(string title, string text, CancellationToken cancellationToken = default) =>
|
|
||||||
await jsRuntime.InvokeVoidAsync("shareText", cancellationToken, title, text);
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
|||||||
+249
-5
@@ -1,6 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Helpers;
|
using decePubClient.Helpers;
|
||||||
@@ -24,13 +23,25 @@ namespace decePubClient.Services
|
|||||||
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
|
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
|
||||||
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
|
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
|
||||||
|
|
||||||
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary>
|
/// <summary>
|
||||||
|
/// Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one). With two-factor
|
||||||
|
/// sign-in on, a right password without LoginForm.OneTimeCode comes back invalid with StatusCode 428
|
||||||
|
/// (NeedsOneTimeCode): the caller asks for the code and sends the same form again with it. A wrong code is a 400, like a
|
||||||
|
/// wrong password, with PrivaPub's own message.
|
||||||
|
/// </summary>
|
||||||
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
|
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
|
||||||
|
if (response.StatusCode is HttpStatusCode.PreconditionRequired)
|
||||||
|
return new WebResult().Invalidate(localizer["Enter the code from your authenticator app, or a recovery code."],
|
||||||
|
(int)HttpStatusCode.PreconditionRequired);
|
||||||
|
|
||||||
return await StartSession(response, cancellationToken);
|
return await StartSession(response, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a sign-in failed only for want of the authenticator app's code or a recovery code (PrivaPub's 428).</summary>
|
||||||
|
public static bool NeedsOneTimeCode(WebResult result) => result.IsInvalid && result.StatusCode is (int)HttpStatusCode.PreconditionRequired;
|
||||||
|
|
||||||
/// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary>
|
/// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary>
|
||||||
public async Task<bool> SetupPending(CancellationToken cancellationToken = default)
|
public async Task<bool> SetupPending(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -147,13 +158,220 @@ namespace decePubClient.Services
|
|||||||
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Changes the root's password, the current one given.</summary>
|
/// <summary>
|
||||||
|
/// Changes the root's password, the current one given. PrivaPub ends every session of the login with it, and revokes
|
||||||
|
/// every persona's token, this device's too; it answers with a new JWT, so the session goes on with it and the current
|
||||||
|
/// persona gets a new token at once (the others when they are next used).
|
||||||
|
/// </summary>
|
||||||
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
|
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
|
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
// a server from before sessions answered with nothing, and ended none
|
||||||
|
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(jwtUser?.Token))
|
||||||
|
return new WebResult();
|
||||||
|
|
||||||
|
await ContinueSession(jwtUser, nameof(ChangePassword), cancellationToken);
|
||||||
|
return new WebResult { Data = jwtUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Begins two-factor sign-in, the password given (a wrong one is PrivaPub's 403, read as such). Data is what the
|
||||||
|
/// authenticator app needs (ViewTotpSetup): the secret as text, as an otpauth:// URI and as that URI's QR code. PrivaPub
|
||||||
|
/// keeps it an hour for TotpEnable; a login with two-factor on already is a 409.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> TotpSetup(string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_TotpSetup, new PasswordForm { Password = password },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await ReadPasswordFailure(response, cancellationToken);
|
||||||
|
|
||||||
|
var setup = await response.DefaultReadFromJsonAsync<ViewTotpSetup>(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(setup?.Secret))
|
||||||
|
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
|
||||||
|
|
||||||
|
return new WebResult { Data = setup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns two-factor sign-in on with the app's first code (a wrong one is a 400; a setup older than an hour a 409, to
|
||||||
|
/// begin again). Data is the ten recovery codes (List<string>), which PrivaPub never shows again. PrivaPub ends every
|
||||||
|
/// session of the login with it and revokes every persona's token, this device's too, and answers with this device's new
|
||||||
|
/// JWT: the session goes on with it, as after a password change (ContinueSession).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> TotpEnable(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_TotpEnable, new TotpCodeForm { Code = code },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var enabled = await response.DefaultReadFromJsonAsync<ViewTotpEnabled>(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(enabled?.Session?.Token))
|
||||||
|
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
|
||||||
|
|
||||||
|
await ContinueSession(enabled.Session, nameof(TotpEnable), cancellationToken);
|
||||||
|
return new WebResult { Data = enabled.RecoveryCodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns two-factor sign-in off with the password (a wrong one is PrivaPub's 403) and a code: the app's, or a recovery
|
||||||
|
/// code (a wrong one is a 400). Data is the login as it is now (ViewAccount).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> TotpDisable(string password, string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_TotpDisable, new TotpDisableForm { Password = password, Code = code },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await ReadPasswordFailure(response, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Goes on with the JWT PrivaPub answered after it ended every session of the login and revoked every persona's token,
|
||||||
|
/// this device's too (a password change, two-factor sign-in turned on): the stored persona tokens are dropped and the
|
||||||
|
/// current persona gets a new one at once, the others when they are next used.
|
||||||
|
/// </summary>
|
||||||
|
async Task ContinueSession(JwtUser jwtUser, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
||||||
|
await authStateProvider.ForgetPersonaTokens(cancellationToken);
|
||||||
|
var session = await authStateProvider.GetAuthData(cancellationToken);
|
||||||
|
if (session.PersonaId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken);
|
||||||
|
if (!exchanged.IsSuccessStatusCode)
|
||||||
|
await LogFailure(exchanged, caller, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks PrivaPub to mail a recovery link to the login named, or to the login whose verified address is given. Data is
|
||||||
|
/// PrivaPub's answer, the same sentence whether or not there is such a login. Sent with no credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> RequestRecovery(PasswordRecoveryForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverPassword, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once.</summary>
|
||||||
|
public async Task<WebResult> CheckRecoveryCode(string recoveryCode, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// [FromBody] string: the code goes as a JSON string
|
||||||
|
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverValid, recoveryCode, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<bool>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets a new password through a recovery link's code; PrivaPub ends every session the login had.</summary>
|
||||||
|
public async Task<WebResult> Recover(NewPasswordForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverUpdatePassword, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code, whether
|
||||||
|
/// two-factor sign-in is on and how many recovery codes are left), or null.</summary>
|
||||||
|
public async Task<ViewAccount> Account(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Account), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for the login's address to become another, the password given: PrivaPub mails a code to it, which VerifyEmail
|
||||||
|
/// brings back. Data is PrivaPub's answer, the same sentence whether or not the address is free.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> ChangeEmail(EmailChangeForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_Email, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await ReadPasswordFailure(response, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the address waiting for its code the login's, verified; Data is the login as it is now.</summary>
|
||||||
|
public async Task<WebResult> VerifyEmail(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_EmailVerify, new EmailCodeForm { Code = code }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the login's address, the password given; without one a forgotten password can't be recovered.
|
||||||
|
/// Data is the login as it is now.</summary>
|
||||||
|
public async Task<WebResult> RemoveEmail(string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_EmailRemove, new PasswordForm { Password = password },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await ReadPasswordFailure(response, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends every session of the login, this device's too, and PrivaPub revokes every persona's token with them. The client
|
||||||
|
/// forgets its session at once; the caller signs out, which then has nothing left to tell PrivaPub.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> LogoutEverywhere(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_LogoutEverywhere, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
await authStateProvider.LogoutAsync(cancellationToken);
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them.</summary>
|
||||||
|
public async Task<ViewUserSettings> Settings(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_UserSettings, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Settings), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewUserSettings>(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves the theme and language as the login's, for every device signed in to it. A failure is logged here.</summary>
|
||||||
|
public async Task<WebResult> SaveSettings(ViewUserSettings settings, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_UpdateUserSettings, settings, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(SaveSettings)}", cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
return new WebResult();
|
return new WebResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +408,33 @@ namespace decePubClient.Services
|
|||||||
await LogFailure(response, nameof(Logout), cancellationToken);
|
await LogFailure(response, nameof(Logout), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await authStateProvider.LogoutAsync(deleteDb: true, cancellationToken);
|
await authStateProvider.LogoutAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would
|
||||||
|
/// take for a missing permission.
|
||||||
|
/// </summary>
|
||||||
|
async Task<WebResult> ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for).</summary>
|
||||||
|
static async Task<string> ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return text.StartsWith('"') ? JsonSerializer.Deserialize<string>(text, SUtility.DefaultSerializer) : text;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is OperationCanceledException or JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Localization;
|
|||||||
|
|
||||||
using PrivaPub.ClientModels.Resources;
|
using PrivaPub.ClientModels.Resources;
|
||||||
|
|
||||||
namespace collAnon.Client.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The app's localizer: looks a key up in Resources/AllStrings, then in PrivaPub.ClientModels' FieldsNameResource and
|
/// The app's localizer: looks a key up in Resources/AllStrings, then in PrivaPub.ClientModels' FieldsNameResource and
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ using System.Globalization;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Helpers;
|
using decePubClient.Helpers;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
@@ -35,8 +33,6 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
@@ -47,9 +43,6 @@ namespace decePubClient.Services
|
|||||||
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exchanges the root's JWT for the persona's Mastodon token (PrivaPub's PersonaExchange) and makes the persona
|
/// Exchanges the root's JWT for the persona's Mastodon token (PrivaPub's PersonaExchange) and makes the persona
|
||||||
/// current. A failure comes back like any other.
|
/// current. A failure comes back like any other.
|
||||||
@@ -88,10 +81,6 @@ namespace decePubClient.Services
|
|||||||
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
|
||||||
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
|
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
|
||||||
@@ -101,11 +90,6 @@ namespace decePubClient.Services
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
|
||||||
CancellationToken cancellationToken = default) =>
|
|
||||||
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAnonymousRequest, retryOnError,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var jwt = await authStateProvider.GetToken(cancellationToken);
|
var jwt = await authStateProvider.GetToken(cancellationToken);
|
||||||
|
|||||||
+11
-35
@@ -11,51 +11,27 @@ public interface ILoggingService
|
|||||||
void Process(string message, string where = default, LogLevel level = LogLevel.Information);
|
void Process(string message, string where = default, LogLevel level = LogLevel.Information);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Writes to the browser console through ILogger.</summary>
|
||||||
/// Writes to the browser console through ILogger and keeps errors and warnings in the IndexedDB client log, so they
|
public class LoggingService(ILogger<LoggingService> logger) : ILoggingService
|
||||||
/// survive a reload. Never throws: logging must not break the code that is reporting a failure.
|
|
||||||
/// </summary>
|
|
||||||
public class LoggingService(IStorage storage, ILogger<LoggingService> logger) : ILoggingService
|
|
||||||
{
|
{
|
||||||
public void Process(string message, string where = default, LogLevel level = LogLevel.Information) =>
|
public void Process(string message, string where = default, LogLevel level = LogLevel.Information) =>
|
||||||
logger.Log(level, "[{Where}] {Message}", where ?? "N/A", message);
|
logger.Log(level, "[{Where}] {Message}", where ?? "N/A", message);
|
||||||
|
|
||||||
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
|
public ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
logger.LogError(exception, "[{Where}]", where);
|
||||||
{
|
return ValueTask.CompletedTask;
|
||||||
logger.LogError(exception, "[{Where}]", where);
|
|
||||||
await storage.AddLog(exception, where, cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
|
public ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
logger.LogError("[{Where}] {Message}", where, message);
|
||||||
{
|
return ValueTask.CompletedTask;
|
||||||
logger.LogError("[{Where}] {Message}", where, message);
|
|
||||||
await storage.AddLog(message, where, cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
|
public ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
logger.LogWarning("[{Where}] {Message}", where, message);
|
||||||
{
|
return ValueTask.CompletedTask;
|
||||||
logger.LogWarning("[{Where}] {Message}", where, message);
|
|
||||||
await storage.AddLog(message, where, cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessWarning)}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
using decePubClient.Helpers;
|
|
||||||
using decePubClient.Models;
|
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
|
||||||
|
|
||||||
namespace decePubClient.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): the client log. Lists come back empty on
|
|
||||||
/// failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger only, because
|
|
||||||
/// ILoggingService writes its log through this class.
|
|
||||||
/// </summary>
|
|
||||||
public interface IStorage
|
|
||||||
{
|
|
||||||
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task AddLog(string message, string where, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Storage(IndexedDb db, ILogger<Storage> logger) : IStorage
|
|
||||||
{
|
|
||||||
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
|
|
||||||
const int MaxLogs = 100;
|
|
||||||
|
|
||||||
#region Logs
|
|
||||||
|
|
||||||
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await db.OpenIndexedDb();
|
|
||||||
var logs = await db.GetAll<ClientLogs>(nameof(ClientLogs)) ?? [];
|
|
||||||
return [.. logs.OrderByDescending(log => log.TimeStamp)];
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetClientLogs)}");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default) =>
|
|
||||||
AddLog(new ClientLogs
|
|
||||||
{
|
|
||||||
Where = where,
|
|
||||||
Exception = new()
|
|
||||||
{
|
|
||||||
Type = exception.GetType().ToString(),
|
|
||||||
HelpLink = exception.HelpLink,
|
|
||||||
HResult = exception.HResult.ToString(),
|
|
||||||
InnerExceptionMessage = exception.InnerException?.Message,
|
|
||||||
Message = exception.Message,
|
|
||||||
Source = exception.Source,
|
|
||||||
StackTrace = exception.StackTrace,
|
|
||||||
TargetSiteName = exception.TargetSite?.Name
|
|
||||||
}
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
public Task AddLog(string message, string where, CancellationToken cancellationToken = default) =>
|
|
||||||
AddLog(new ClientLogs { Where = where, WarningMessage = message }, cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Numbers the entry after the newest one and trims the log; never throws.</summary>
|
|
||||||
async Task AddLog(ClientLogs log, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await db.OpenIndexedDb();
|
|
||||||
|
|
||||||
var maxKey = await db.GetMaxKey<long>(nameof(ClientLogs));
|
|
||||||
var minKey = await db.GetMinKey<long>(nameof(ClientLogs));
|
|
||||||
if (maxKey - minKey >= MaxLogs)
|
|
||||||
for (var key = minKey; key < minKey + MaxLogs / 2; key++)
|
|
||||||
_ = await db.DeleteByKey(nameof(ClientLogs), key);
|
|
||||||
|
|
||||||
log.Id = maxKey + 1;
|
|
||||||
var result = await db.AddItems<ClientLogs>(nameof(ClientLogs), [log]);
|
|
||||||
logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(Storage)}.{nameof(AddLog)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
|
|
||||||
Run(nameof(RemoveAll), async () =>
|
|
||||||
{
|
|
||||||
if (!includeClientLogs)
|
|
||||||
return "nothing to remove";
|
|
||||||
return await db.DeleteAll(nameof(ClientLogs));
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Runs a write and turns its outcome into a WebResult.</summary>
|
|
||||||
async Task<WebResult> Run(string caller, Func<Task<string>> write, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = new WebResult();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await db.OpenIndexedDb();
|
|
||||||
logger.LogInformation("{Caller}: {Result}", caller, await write());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return SUtility.DefaultTaskCancelledResult;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, $"{nameof(Storage)}.{caller}");
|
|
||||||
return result.Invalidate(ex.Message, exception: ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using DnetIndexedDb;
|
|
||||||
|
|
||||||
using Microsoft.JSInterop;
|
|
||||||
|
|
||||||
namespace decePubClient.Services
|
|
||||||
{
|
|
||||||
public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options) : IndexedDbInterop(jsRuntime, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The server's own settings from the administrator's page (PrivaPub's InstanceAdminController, /clientapi/admin): what
|
||||||
|
/// it says of itself, its rules, its thumbnail, its media limits, whether its weekly activity is published, its
|
||||||
|
/// announcements and the relays it subscribes to. PrivaPub checks the administrator against its database on every call.
|
||||||
|
/// A save replaces everything the form carries (a rule left out is removed), so SaveSettings always starts from the
|
||||||
|
/// settings as PrivaPub has them now and changes only what the caller changes. Lists come back empty and values null on
|
||||||
|
/// failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class InstanceAdminService(IHttpService httpService, AboutService aboutService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The settings as the page shows them, deleted rules too; null on failure.</summary>
|
||||||
|
public async Task<ViewServerSettings> Settings(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Settings), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form that saves the settings exactly as they are: the rules not deleted in their order, the media
|
||||||
|
/// overrides, every text.</summary>
|
||||||
|
public static ServerSettingsForm FormOf(ViewServerSettings settings) => new()
|
||||||
|
{
|
||||||
|
Title = settings.Title,
|
||||||
|
ShortDescription = settings.ShortDescription,
|
||||||
|
Description = settings.Description,
|
||||||
|
ContactEmail = settings.ContactEmail,
|
||||||
|
ContactAccountId = settings.ContactAccountId,
|
||||||
|
PrivacyPolicy = settings.PrivacyPolicy,
|
||||||
|
TermsOfService = settings.TermsOfService,
|
||||||
|
TermsEffectiveAt = settings.TermsEffectiveAt,
|
||||||
|
Rules = [.. settings.Rules.Where(r => !r.IsDeleted).OrderBy(r => r.Position).Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text, Hint = r.Hint })],
|
||||||
|
Media = new()
|
||||||
|
{
|
||||||
|
QuotaBytesPerRoot = settings.Media.QuotaBytesPerRoot,
|
||||||
|
MaxImageBytes = settings.Media.MaxImageBytes,
|
||||||
|
MaxVideoBytes = settings.Media.MaxVideoBytes,
|
||||||
|
MaxSeconds = settings.Media.MaxSeconds
|
||||||
|
},
|
||||||
|
PublishActivity = settings.PublishActivity
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the settings as PrivaPub has them now, lets `change` edit the form made of them, and saves the whole form;
|
||||||
|
/// Data is the ViewServerSettings saved. What another panel or another administrator saved meanwhile stays.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WebResult> SaveSettings(Action<ServerSettingsForm> change, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var current = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
|
||||||
|
if (!current.IsSuccessStatusCode)
|
||||||
|
return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken);
|
||||||
|
if (await current.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) is not { } latest)
|
||||||
|
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
|
||||||
|
|
||||||
|
var form = FormOf(latest);
|
||||||
|
change(form);
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminSettings, form, cancellationToken: cancellationToken);
|
||||||
|
return await Saved(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A new thumbnail, streamed from the browser's file; PrivaPub checks it, strips it and fits it within
|
||||||
|
/// 1200×630. Data is the ViewServerSettings.</summary>
|
||||||
|
public async Task<WebResult> SaveThumbnail(IBrowserFile file, long maximumSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminThumbnail, (Func<HttpContent>)(() =>
|
||||||
|
{
|
||||||
|
var picture = new StreamContent(file.OpenReadStream(maximumSize, cancellationToken));
|
||||||
|
picture.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
|
||||||
|
return new MultipartFormDataContent { { picture, "file", file.Name } };
|
||||||
|
}), cancellationToken: cancellationToken);
|
||||||
|
return await Saved(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Back to the default picture; the uploaded one is trashed. Data is the ViewServerSettings.</summary>
|
||||||
|
public async Task<WebResult> RemoveThumbnail(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminThumbnail, cancellationToken: cancellationToken);
|
||||||
|
return await Saved(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every announcement, published or not, with its reads and reactions.</summary>
|
||||||
|
public async Task<List<ViewAnnouncement>> Announcements(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminAnnouncements, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewAnnouncement>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Announcements), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes a new announcement (no Id) or changes one; Data is the ViewAnnouncement.</summary>
|
||||||
|
public async Task<WebResult> SaveAnnouncement(AnnouncementForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var route = form.Id is { Length: > 0 } ? APIs.ClientApi.POST_AdminAnnouncementUpdate : APIs.ClientApi.POST_AdminAnnouncementInsert;
|
||||||
|
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAnnouncement>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes an announcement, its reads and reactions with it; every persona's stream hears it go.</summary>
|
||||||
|
public async Task<WebResult> DeleteAnnouncement(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminAnnouncementDelete(id), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The relays the server subscribes to: the configuration's, then the settings'.</summary>
|
||||||
|
public async Task<List<ViewRelay>> Relays(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRelays, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Relays), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Subscribes to a relay by its actor's or inbox's https address. PrivaPub answers 202 and subscribes in the
|
||||||
|
/// background, so the list it answers with (Data) shows the relay before its state does; read it again shortly.</summary>
|
||||||
|
public async Task<WebResult> AddRelay(string address, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminRelayInsert, new RelayForm { Address = address?.Trim() },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return await RelayList(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes from a relay the settings named (a configured one is refused with 409); answered 202 like
|
||||||
|
/// AddRelay.</summary>
|
||||||
|
public async Task<WebResult> RemoveRelay(string address, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRelayDelete(address), cancellationToken: cancellationToken);
|
||||||
|
return await RelayList(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Saved(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
// the title and the rules everyone reads have changed
|
||||||
|
aboutService.Forget();
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> RelayList(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(InstanceAdminService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,12 +41,6 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
|
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
|
||||||
|
|
||||||
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await Resolve([SelfHost], cancellationToken);
|
|
||||||
return Known(SelfHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
|
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
|
||||||
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
|
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
@@ -13,9 +11,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
||||||
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
|
/// (audio and video: 202, then 206 until ready); describes and deletes what isn't posted yet; and knows what PrivaPub
|
||||||
|
/// takes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
public class MediaService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
static readonly MediaConfiguration Fallback = new()
|
static readonly MediaConfiguration Fallback = new()
|
||||||
{
|
{
|
||||||
@@ -81,6 +80,20 @@ namespace decePubClient.Services
|
|||||||
return new WebResult();
|
return new WebResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes an upload of the persona's that no post holds (DELETE /api/v1/media/:id; PrivaPub refuses a posted
|
||||||
|
/// one with 422): the composer drops what was removed before posting. A failure is logged.</summary>
|
||||||
|
public async Task<WebResult> Discard(string mediaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Media(mediaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return new WebResult();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(MediaService)}.{nameof(Discard)}", cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using collAnon.Client.Services;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
|
|||||||
@@ -45,16 +45,6 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The newest request to a route template, or null.</summary>
|
|
||||||
public RequestStat LastOf(string route) => Latest().FirstOrDefault(stat => stat.Route == route);
|
|
||||||
|
|
||||||
/// <summary>The mean duration of the successful requests recorded, in milliseconds.</summary>
|
|
||||||
public double? AverageMs()
|
|
||||||
{
|
|
||||||
var answered = Latest().Where(stat => stat.Status is >= 200 and < 400).ToList();
|
|
||||||
return answered.Count == 0 ? null : answered.Average(stat => stat.DurationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
|
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
|
||||||
public static string Template(string uri)
|
public static string Template(string uri)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
@@ -23,12 +21,16 @@ namespace decePubClient.Services
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Unbookmark,
|
Unbookmark,
|
||||||
Pin,
|
Pin,
|
||||||
Unpin
|
Unpin,
|
||||||
|
/// <summary>PrivaPub's own, for the threadiverse: a downvote replaces the persona's favourite (its upvote).</summary>
|
||||||
|
Downvote,
|
||||||
|
Undownvote
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
|
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
|
||||||
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
|
/// favourites, boosts, bookmarks, downvotes, deleting, threads, who may quote the persona's posts (and revoking a quote
|
||||||
|
/// of one), and muting or blocking an author. Results are WebResults whose
|
||||||
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
|
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
@@ -53,7 +55,7 @@ namespace decePubClient.Services
|
|||||||
InReplyToId = form.InReplyToId,
|
InReplyToId = form.InReplyToId,
|
||||||
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.MastodonVisibility,
|
||||||
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
|
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
|
||||||
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
|
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
|
||||||
@@ -188,10 +190,10 @@ namespace decePubClient.Services
|
|||||||
AnsweringToPostId = form.InReplyToId,
|
AnsweringToPostId = form.InReplyToId,
|
||||||
HasContentWarning = form.HasContentWarning,
|
HasContentWarning = form.HasContentWarning,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.MessageType switch
|
Visibility = form.Visibility switch
|
||||||
{
|
{
|
||||||
MessageType.FollowersOnly => "followersonly",
|
Visibility.FollowersOnly => "followersonly",
|
||||||
MessageType.Unlisted => "unlisted",
|
Visibility.Unlisted => "unlisted",
|
||||||
_ => "public"
|
_ => "public"
|
||||||
},
|
},
|
||||||
Latitude = form.IsLocated ? form.Latitude : null,
|
Latitude = form.IsLocated ? form.Latitude : null,
|
||||||
@@ -230,6 +232,39 @@ namespace decePubClient.Services
|
|||||||
return await StatusResult(response, cancellationToken);
|
return await StatusResult(response, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who may quote the persona's own public or unlisted post (PUT /statuses/:id/interaction_policy); its
|
||||||
|
/// followers are told of the change. Data is the updated Status.</summary>
|
||||||
|
public async Task<WebResult> SetQuotePolicy(string statusId, QuotePolicy policy, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_StatusInteractionPolicy(statusId),
|
||||||
|
new[] { new KeyValuePair<string, string>("quote_approval_policy", QuotePolicyValue(policy)) }, cancellationToken: cancellationToken);
|
||||||
|
return await StatusResult(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The quote policy a status states (quote_approval.automatic): who may quote it without asking.</summary>
|
||||||
|
public static QuotePolicy QuotePolicyOf(Status status) => status.QuoteApproval?.Automatic switch
|
||||||
|
{
|
||||||
|
{ } automatic when automatic.Contains("public") => QuotePolicy.Public,
|
||||||
|
{ } automatic when automatic.Contains("followers") => QuotePolicy.Followers,
|
||||||
|
_ => QuotePolicy.Nobody
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A quote policy as the Mastodon API names it.</summary>
|
||||||
|
public static string QuotePolicyValue(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => "followers",
|
||||||
|
QuotePolicy.Nobody => "nobody",
|
||||||
|
_ => "public"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Takes back the permission to quote the persona's own post from a post quoting it (FEP-044f); Data is the
|
||||||
|
/// quoting Status as it is now, its quote revoked.</summary>
|
||||||
|
public async Task<WebResult> RevokeQuote(string quotedId, string quotingId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_RevokeQuote(quotedId, quotingId), cancellationToken: cancellationToken);
|
||||||
|
return await StatusResult(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
|
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
|
||||||
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,24 +16,37 @@ namespace decePubClient.Services
|
|||||||
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
|
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
|
||||||
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
|
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
|
||||||
|
|
||||||
|
/// <summary>What the user stream told of the administrators' announcements: one new or changed (announcement), one
|
||||||
|
/// gone (announcement.delete, its id), or a reaction's count now (announcement.reaction).</summary>
|
||||||
|
public sealed record AnnouncementMessage(string Event, Announcement Announcement = default, string DeletedId = default,
|
||||||
|
AnnouncementReactionEvent Reaction = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
|
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
|
||||||
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
|
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
|
||||||
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
|
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
|
||||||
/// page to poll meanwhile. Nothing throws.
|
/// page to poll meanwhile. Nothing throws.
|
||||||
|
/// The announcements come on the user stream, which HearAnnouncements keeps subscribed whatever feed is followed; what
|
||||||
|
/// else it tells is raised only while the feed followed is the user stream itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
|
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
|
||||||
ILoggingService logger) : IAsyncDisposable
|
ILoggingService logger) : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
const string UserStream = "user";
|
||||||
|
|
||||||
ClientWebSocket socket;
|
ClientWebSocket socket;
|
||||||
CancellationTokenSource running;
|
CancellationTokenSource running;
|
||||||
StreamSubscription current;
|
StreamSubscription current;
|
||||||
|
bool announcements;
|
||||||
|
|
||||||
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
|
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
|
||||||
|
|
||||||
/// <summary>Raised for each message of the stream subscribed now.</summary>
|
/// <summary>Raised for each message of the stream subscribed now.</summary>
|
||||||
public event Func<StreamMessage, Task> Received;
|
public event Func<StreamMessage, Task> Received;
|
||||||
|
|
||||||
|
/// <summary>Raised for each announcement event, while HearAnnouncements is on.</summary>
|
||||||
|
public event Func<AnnouncementMessage, Task> Announced;
|
||||||
|
|
||||||
Uri Endpoint
|
Uri Endpoint
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -47,23 +60,49 @@ namespace decePubClient.Services
|
|||||||
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
|
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
|
||||||
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
|
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (subscription == current && (running is not null || subscription is null))
|
if (subscription == current && (running is not null || subscription is null && !announcements))
|
||||||
return;
|
return;
|
||||||
var previous = current;
|
var previous = current;
|
||||||
current = subscription;
|
current = subscription;
|
||||||
if (socket?.State == WebSocketState.Open)
|
if (socket?.State == WebSocketState.Open)
|
||||||
{
|
{
|
||||||
if (previous is not null)
|
// the user stream stays while the announcements are heard on it
|
||||||
|
if (previous is not null && !(announcements && previous.Stream == UserStream))
|
||||||
await Say("unsubscribe", previous, cancellationToken);
|
await Say("unsubscribe", previous, cancellationToken);
|
||||||
if (subscription is not null)
|
if (subscription is not null)
|
||||||
await Say("subscribe", subscription, cancellationToken);
|
await Say("subscribe", subscription, cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (subscription is not null && running is null)
|
Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hears the announcements (the user stream) or stops hearing them; the socket opens for them alone when no
|
||||||
|
/// feed is followed.</summary>
|
||||||
|
public async Task HearAnnouncements(bool hear, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (hear == announcements)
|
||||||
{
|
{
|
||||||
running = new();
|
if (hear)
|
||||||
_ = Run(running.Token);
|
Start();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
announcements = hear;
|
||||||
|
if (socket?.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
if (current?.Stream != UserStream)
|
||||||
|
await Say(hear ? "subscribe" : "unsubscribe", new StreamSubscription(UserStream), cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hear)
|
||||||
|
Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Start()
|
||||||
|
{
|
||||||
|
if (running is not null || current is null && !announcements)
|
||||||
|
return;
|
||||||
|
running = new();
|
||||||
|
_ = Run(running.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
|
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
|
||||||
@@ -132,6 +171,8 @@ namespace decePubClient.Services
|
|||||||
await socket.ConnectAsync(Endpoint, cancellationToken);
|
await socket.ConnectAsync(Endpoint, cancellationToken);
|
||||||
if (current is not null)
|
if (current is not null)
|
||||||
await Say("subscribe", current, cancellationToken);
|
await Say("subscribe", current, cancellationToken);
|
||||||
|
if (announcements && current?.Stream != UserStream)
|
||||||
|
await Say("subscribe", new StreamSubscription(UserStream), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Read(CancellationToken cancellationToken)
|
async Task Read(CancellationToken cancellationToken)
|
||||||
@@ -152,6 +193,24 @@ namespace decePubClient.Services
|
|||||||
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|
||||||
|| message["payload"]?.GetValue<string>() is not { } payload)
|
|| message["payload"]?.GetValue<string>() is not { } payload)
|
||||||
continue;
|
continue;
|
||||||
|
if (name.StartsWith("announcement", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var announced = name switch
|
||||||
|
{
|
||||||
|
"announcement" => new AnnouncementMessage(name, Announcement: JsonSerializer.Deserialize<Announcement>(payload, SUtility.MastodonSerializer)),
|
||||||
|
"announcement.delete" => new AnnouncementMessage(name, DeletedId: payload),
|
||||||
|
"announcement.reaction" => new AnnouncementMessage(name,
|
||||||
|
Reaction: JsonSerializer.Deserialize<AnnouncementReactionEvent>(payload, SUtility.MastodonSerializer)),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (announced is not null && announcements && Announced is { } listeners)
|
||||||
|
await listeners(announced);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// the user stream held for the announcements tells the home feed's posts and notifications too: they are the
|
||||||
|
// feed's only while it is the one followed
|
||||||
|
if ((message["stream"] as JsonArray)?.FirstOrDefault()?.GetValue<string>() == UserStream && current?.Stream != UserStream)
|
||||||
|
continue;
|
||||||
var told = name switch
|
var told = name switch
|
||||||
{
|
{
|
||||||
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
|
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
|
||||||
@@ -164,7 +223,8 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Closes the socket; the page polls from then on.</summary>
|
/// <summary>Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens
|
||||||
|
/// the socket for them too.</summary>
|
||||||
public async Task Stop()
|
public async Task Stop()
|
||||||
{
|
{
|
||||||
current = null;
|
current = null;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using collAnon.Client.Services;
|
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
@@ -158,6 +156,14 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Dismisses a group of notifications (/api/v2/notifications/:group_key/dismiss): every notification of
|
||||||
|
/// the group goes; an ungrouped one's key is ungrouped-{id}.</summary>
|
||||||
|
public async Task<WebResult> DismissNotification(string groupKey, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissNotificationGroup(groupKey), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
|
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
|
||||||
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace decePubClient.Services
|
|||||||
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
|
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
|
||||||
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
|
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
|
public class TokenAuthStateProvider(ILocalStorageService storage, ILogger<TokenAuthStateProvider> logger)
|
||||||
: AuthenticationStateProvider
|
: AuthenticationStateProvider
|
||||||
{
|
{
|
||||||
/// <summary>The claim naming the persona the client acts as (an avatar id).</summary>
|
/// <summary>The claim naming the persona the client acts as (an avatar id).</summary>
|
||||||
@@ -89,14 +89,22 @@ namespace decePubClient.Services
|
|||||||
await Save(session, cancellationToken);
|
await Save(session, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default)
|
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
|
||||||
|
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var session = await GetAuthData(cancellationToken);
|
||||||
|
if (session.PersonaTokens.Count == 0)
|
||||||
|
return;
|
||||||
|
session.PersonaTokens.Clear();
|
||||||
|
await Save(session, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
logger.LogInformation($"set null({nameof(LogoutAsync)})");
|
logger.LogInformation($"set null({nameof(LogoutAsync)})");
|
||||||
authData = null;
|
authData = null;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
|
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
|
||||||
if (deleteDb)
|
|
||||||
await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-18
@@ -81,6 +81,10 @@
|
|||||||
<i class="ion-md-settings"></i>
|
<i class="ion-md-settings"></i>
|
||||||
<span>@Localizer["Settings"]</span>
|
<span>@Localizer["Settings"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="about" data-nav-about>
|
||||||
|
<i class="ion-md-information-circle"></i>
|
||||||
|
<span>@Localizer["About this server"]</span>
|
||||||
|
</NavLink>
|
||||||
<AuthorizeView Policy="@Policies.IsModerator">
|
<AuthorizeView Policy="@Policies.IsModerator">
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
||||||
<i class="ion-md-switch text-error"></i>
|
<i class="ion-md-switch text-error"></i>
|
||||||
@@ -224,12 +228,6 @@
|
|||||||
var isDark = IsDark;
|
var isDark = IsDark;
|
||||||
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark,
|
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark,
|
||||||
isDark ? PageSettings.DefaultDarkThemeIndexColour : PageSettings.DefaultLightThemeIndexColour));
|
isDark ? PageSettings.DefaultDarkThemeIndexColour : PageSettings.DefaultLightThemeIndexColour));
|
||||||
|
|
||||||
// if (AuthData?.User != null)
|
|
||||||
// {
|
|
||||||
// AuthData.User.UserSettings.ThemeIndexColour = ThemeIndexColour;
|
|
||||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picking a hue also leaves the grey themes, which otherwise ignore it.
|
// Picking a hue also leaves the grey themes, which otherwise ignore it.
|
||||||
@@ -237,23 +235,11 @@
|
|||||||
{
|
{
|
||||||
var isDark = IsDark;
|
var isDark = IsDark;
|
||||||
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark, indexColour));
|
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark, indexColour));
|
||||||
|
|
||||||
// if (AuthData?.User != null)
|
|
||||||
// {
|
|
||||||
// AuthData.User.UserSettings.ThemeIndexColour = ThemeIndexColour;
|
|
||||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choosing light or dark here leaves system theming; Settings can turn it back on.
|
// Choosing light or dark here leaves system theming; Settings can turn it back on.
|
||||||
protected async Task UpdateThemeDarkMode(bool isDarkMode)
|
protected async Task UpdateThemeDarkMode(bool isDarkMode)
|
||||||
{
|
{
|
||||||
await CascadingState.UpdatePageSettings(settings => settings.SetDarkMode(isDarkMode));
|
await CascadingState.UpdatePageSettings(settings => settings.SetDarkMode(isDarkMode));
|
||||||
|
|
||||||
// if (AuthData?.User != null)
|
|
||||||
// {
|
|
||||||
// AuthData.User.UserSettings.ThemeIsDarkMode = ThemeIsDarkMode;
|
|
||||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-3
@@ -1,12 +1,10 @@
|
|||||||
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
||||||
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
||||||
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
|
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
|
||||||
@import "tailwindcss" source("../");
|
@import "tailwindcss" source("../");
|
||||||
|
|
||||||
@source not "../wwwroot/vendor";
|
@source not "../wwwroot/vendor";
|
||||||
@source not "../Docs";
|
|
||||||
@source not "../tools";
|
@source not "../tools";
|
||||||
@source not "../tests";
|
|
||||||
|
|
||||||
@plugin "../tools/tailwind/daisyui.mjs" {
|
@plugin "../tools/tailwind/daisyui.mjs" {
|
||||||
themes: false;
|
themes: false;
|
||||||
|
|||||||
@@ -62,6 +62,44 @@
|
|||||||
padding-inline-start: 1.25rem;
|
padding-inline-start: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the server's own documents (About, announcements), rendered from its administrator's Markdown: headings and
|
||||||
|
lists spaced like a page, which a post never needs */
|
||||||
|
.doc-content h1,
|
||||||
|
.doc-content h2,
|
||||||
|
.doc-content h3,
|
||||||
|
.doc-content h4 {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-block: 1em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content h1 {
|
||||||
|
font-size: 1.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content h3,
|
||||||
|
.doc-content h4 {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content ul,
|
||||||
|
.doc-content ol {
|
||||||
|
margin-block: 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content hr {
|
||||||
|
margin-block: 1em;
|
||||||
|
border-color: var(--neo-strong);
|
||||||
|
}
|
||||||
|
|
||||||
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI
|
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI
|
||||||
only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen
|
only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen
|
||||||
it never shows: a tap opens the rows in a sheet instead (NerdTip). */
|
it never shows: a tap opens the rows in a sheet instead (NerdTip). */
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
||||||
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
||||||
|
|
||||||
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
|
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5). Buttons, tabs and
|
||||||
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
||||||
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
||||||
areas would overlap. */
|
areas would overlap. */
|
||||||
|
|||||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user