6 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 4c88980374 R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About
Build / Build (push) Successful in 1m5s
Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail,
privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to
Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every
save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the
server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements
show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:32:57 +02:00
thepraandClaude Opus 5.5 b8d7858acb R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s
Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it
follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice.
A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a
filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with
"describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting.
MediaType.Documents goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:02:20 +02:00
thepraandClaude Opus 5.5 b731d8c8c0 R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Build / Build (push) Successful in 1m3s
Completion program R3, with PrivaPub's TOTP routes of the same release.

- Settings, the login: TotpEditor turns two-factor sign-in on (the password, then the QR code, the secret and the
  otpauth:// link, then the app's first code; the ten recovery codes shown once with Copy), shows how many recovery
  codes are left (warned at three), and turns it off with the password and a code, asked first.
- Turning it on, like a password change, ends every other session: AuthService.ContinueSession takes the new JWT and
  exchanges the persona's token again.
- Sign-in: PrivaPub's 428 keeps the name and password and asks for the six digits (DOtp), or a recovery code instead;
  a wrong code says so; Back returns to the password.
- window.copyText (main.js) through AppStatusService.CopyText.
- 29 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:33:46 +02:00
thepraandClaude Opus 5.5 7d4b35f0d7 R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login
Completion program R2, with PrivaPub's routes of the same release.

- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
  sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
  there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
  (DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
  token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
  client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:21:59 +02:00
thepraandClaude Opus 5.5 fd2f2e7c65 R1 cleanup: collAnon and dead code gone, no IndexedDB, honest PWA files
Build / Build (push) Successful in 51s
Starts the completion program (SocialPub docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- The localizer's namespace is decePubClient.Services; collAnon is gone from the repo.
- Dead code: PagesBase, VConstants, SettingsOptions, ViewLanguage, unused APIs routes, InstanceService.Self,
  RequestStats.LastOf/AverageMs, PostAnon/DeleteTotallyAnon, unused AppStatusService methods and their main.js helpers
  (the app badge stays for Web Push), ExtensionMethods/SUtility leftovers (Markdig and BlazorZXingJs packages go),
  unused PublicCacheData/PageSettings/AppConfiguration fields, commented-out code.
- The client log in IndexedDB, which nothing ever read, goes with the whole IndexedDB layer (DnetIndexedDb, IStorage):
  logs go to the browser console; main.js deletes the old "data" database once.
- The solution names the real ClientModels project. Unused vendor assets (fontawesome, solid, open-iconic, rxjs) go.
- robots.txt disallows everything; manifest.json and index.html say decePub; the service worker's cache name follows
  each publish's assets version, so old caches are purged.
- MessageType is Visibility (member names kept: they are resx keys); ExpandMessage.razor is Thread.razor (same route).
- i18n: dead keys removed, Italian "camera" is "punto di vista", DInputBase/DCalendar validation messages come from
  ErrorMessages, DataTransfer's spinner has its label.
- CLAUDE.md follows: client storage, JS interop, PWA, the D fields' messages.

Verified: dotnet build decePubClient.csproj and decePubClient.sln -c Release (0 errors, no new warnings); a Pasture
publish served by the pasture's decepub peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00
thepraandClaude Opus 5.5 e4414f1ac9 No test project: tests/e2e goes
Build / Build (push) Successful in 42s
Owner decision 2026-10-17, with SocialPub's PrivaPub.Tests. The e2e project leaves the solution, the csproj's
exclusions, Tailwind's sources and .gitignore; comments no longer point at it, and CLAUDE.md's e2e section becomes
Verifying: a publish with the Pasture environment, which SocialPub's tools/pasture/peers/decepub.sh serves as
https://decepub.test. appsettings.Pasture.json stays for that, and the deploy still fails if it ever ships. The cards'
data-* hooks stay in the markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:28:34 +02:00
169 changed files with 7194 additions and 11718 deletions

No files matched your search

-3
View File
@@ -368,6 +368,3 @@ FodyWeavers.xsd
.tools/
wwwroot/css/app.css
# the e2e tests: the published app they serve and the browser state they keep
tests/e2e/.publish/
tests/e2e/.state/
+195 -96
View File
@@ -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`
(`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
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.
## 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
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
```sh
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
../SocialPub/tools/pasture/town.sh seed village
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
```
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
time and published only then; the deploy fails if it ever ships.
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
- **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.
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
ever ships.
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
127.0.0.1:6443"`), and must accept the pasture's CA.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -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`.
The deploy workflow checks for `css/app.css.gz`.
- `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
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.
- `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.
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:
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
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:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`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.
- **`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:
@@ -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 own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
phone sweep fails anything less.
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
- **Overlays on a phone rise from the bottom.**
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
close button (`ShowClose`, named by `CloseText`).
@@ -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.
- No history entries are pushed.
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and
signing out.
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
server's thumbnail.
- **On a touch screen:**
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
pinned to the globe with a mouse only;
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
- pictures open in `Components/MediaViewer.razor`.
- **The feed on a phone:**
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
stats' Details are in More, and the author's picture is in the header;
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
the nerd stats' Details are in More, and the author's picture is in the header;
- a new post starts from a floating button;
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
@@ -257,7 +220,7 @@ supplies:
- `AfterRenderAsyncJobs`, run once after the next render;
- `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.**
- `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
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
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
PrivaPub redirects it).
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
not carry a JWT: PrivaPub redirects them).
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
`HttpClient`'s own is off.
@@ -295,7 +258,19 @@ supplies:
- **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`,
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
forgets the session.
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub
answers with (`ContinueSession`), drop the stored persona tokens and exchange one for the current persona at once
(the others when next used), so the device stays signed in.
- **Two-factor sign-in** (TOTP, `/clientapi/user/totp/*`): with it on, a right password is answered with a 428
(`AuthService.NeedsOneTimeCode`). Login keeps the name and the password in the form, out of sight, and sends it again
with `LoginForm.OneTimeCode`: the authenticator app's six digits (`DOtp`, `data-login-code`) or, after "Use a recovery
code instead", a recovery code like `abcde-fghjk` (a text field, `data-login-recovery`). A wrong code is a 400, like a
wrong password, with PrivaPub's message. It is turned on and off in Settings (`TotpEditor`, see **Settings**).
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
- **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
@@ -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.
- Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
persona is sent from Login to Settings to make one.
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
All of it goes with no credentials.
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
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,
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
`/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`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator` 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`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
post's downvotes next to its favourites, 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
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from
Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long
description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules
(`data-about-rules`, `data-about-rule`), privacy policy (`data-about-privacy`), terms of service (`data-about-terms`,
none while PrivaPub answers 404) and the domain blocks the administrator published (`data-about-blocks`,
`data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized
HTML in `post-content doc-content` (`Styles/feed.css` spaces their headings). Login's heading names the server
(`data-login-title`, `/api/v2/instance` read without credentials and kept for the session).
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
@@ -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),
linking the new account's profile.
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
lists them to the persona alone (`hide_collections`), so another profile keeps plain counts. A follower is removed
there (`remove_from_followers`, `data-follower-remove`, asked first). The follow requests waiting for the persona show
above its posts (`Components/FollowRequestsPanel.razor`, `data-follow-request`, accepted or refused; hidden while none
waits), and the Notifications tab opens the same panel in a sheet while some wait (`data-follow-requests-open`).
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
@@ -353,7 +361,11 @@ supplies:
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur).
its media (blur). Another's post goes into a filter from its card's menu ("Filter this post", `data-filter-post`,
`Components/FilterPostDialog.razor`: `POST /api/v2/filters/:id/statuses`, a filter that holds it already says so), and
the card reads the post again so the filter applies at once. On /filters the posts a filter holds are read by id when
it opens (`TimelineService.Read`, `/api/v1/statuses?id[]=`) and show their author and words, linking their thread
(`data-filter-status`), or say "Post no longer available" (`data-filter-status-missing`).
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
post opened in its thread shows whole.
@@ -366,7 +378,9 @@ supplies:
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
- the ids are kept, so a post that fails never uploads its media again;
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
- a description changed after the upload is PUT before the post.
- a description changed after the upload is PUT before the post;
- a file taken out before posting is deleted from PrivaPub (`MediaService.Discard`, `DELETE /api/v1/media/:id`,
`data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
the reader turned `PageSettings.RemindAltText` off in Settings → General.
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
@@ -377,15 +391,31 @@ supplies:
(`/api/v2/search`, never resolving), once typing pauses.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
- **Threads** (`Pages/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
and adds what was found, in the thread's order.
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
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`:
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
`data-quote-policy-choice`: anyone, its followers or nobody, `PUT /api/v1/statuses/:id/interaction_policy` with
`quote_approval_policy`; the policy is read back from `quote_approval.automatic`, `StatusService.QuotePolicyOf`). A
post by someone else quoting one of them has "Revoke quote" in its menu (`data-revoke-quote`, asked first; `POST
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
everywhere", see **One sign-in**); or the whole of it
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
@@ -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
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators:
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until
then they have no section.
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
`AboutService` read the instance again.
- **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description
(`data-instance-short-description`), Markdown description (`data-instance-description`), contact address
(`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`;
picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
`data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy
policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the
weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the
configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The
thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`,
asked first) is saved as soon as it is picked, streamed from the file as multipart `file`.
- **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its
`data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`,
`data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
(`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id.
- **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`,
`data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions;
`data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`,
`-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`);
`data-announcement-delete` asks first.
- **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and
`/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars
part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a
block, since blocking a server again changes its block. `data-server-block-save`.
- **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source`
configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from
`Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
- **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows,
inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the
configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`,
`data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
- **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time
(`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
(`data-log-note`). `data-log-reload`.
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
texts, a removed rule's too.
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
ledger's events and the server's own days. Counted per server, never per account.
- A server's name opens `HostDetailDialog` (`data-statistics-host-open`, `/hosts/{host}?days=`): what it says of itself
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
ledger's `channel:…:…:outcome` keys like the servers table; `live` for a day not rolled up yet), its last events, and
"Describe again" (`data-host-describe`, `POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
- Events (`data-statistics-events`, `EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
time, and "Load more" asks for those `before` the last one's time.
- The server's days (`data-statistics-server`, `/server?days=`): its client API's requests by status class, the
documents it served, and its ledger written, dropped and failed.
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
@@ -411,7 +499,9 @@ supplies:
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait.
links there while requests wait. A notification is dismissed from its line (`data-notification-dismiss`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
one alone), and the page takes that item out (`Content.OnDismissed`).
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
@@ -432,6 +522,16 @@ supplies:
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
- **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones
shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`,
`-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to
(`data-announcement-reaction="{emoji}"` toggles the persona's own, `data-announcement-react` offers more,
`data-announcement-react-emoji`; `PUT`/`DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They
come live on the user stream (`announcement`, `announcement.reaction`, `announcement.delete`):
`StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it
alone on a polled feed), and raises what it tells as `Announced`; the user stream's posts and notifications are
the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the
feed's cards.
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
@@ -487,17 +587,14 @@ supplies:
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
`ErrorsResource`.
- 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`. A new key
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`, and by the
D fields' parse errors (`DInputBase`, `DCalendar`) through the static `ErrorMessages.InvalidValue` / `InvalidDate`. A new
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
**Client storage** comes in two layers:
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
server cache `ServerInfos`, keyed by `nameof(Type)`.
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
`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).
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`
and the server cache `ServerInfos`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
still holds it.
**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
@@ -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
synchronous `IJSInProcessRuntime`.
- 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`.
**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
+30 -2
View File
@@ -27,8 +27,6 @@ namespace decePubClient;
[JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ClientLogs))]
[JsonSerializable(typeof(ClientLogException))]
[JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
@@ -37,6 +35,20 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewServerSetup))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(PasswordRecoveryForm))]
[JsonSerializable(typeof(NewPasswordForm))]
[JsonSerializable(typeof(ViewAccount))]
[JsonSerializable(typeof(EmailChangeForm))]
[JsonSerializable(typeof(EmailCodeForm))]
[JsonSerializable(typeof(PasswordForm))]
[JsonSerializable(typeof(TotpCodeForm))]
[JsonSerializable(typeof(TotpDisableForm))]
[JsonSerializable(typeof(ViewTotpSetup))]
[JsonSerializable(typeof(ViewTotpEnabled))]
[JsonSerializable(typeof(ViewUserSettings))]
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
@@ -61,6 +73,13 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewHostDetail))]
[JsonSerializable(typeof(ViewHostInstance))]
[JsonSerializable(typeof(ViewHostDay))]
[JsonSerializable(typeof(ViewInteractionEvent))]
[JsonSerializable(typeof(List<ViewInteractionEvent>))]
[JsonSerializable(typeof(ViewServerDay))]
[JsonSerializable(typeof(List<ViewServerDay>))]
[JsonSerializable(typeof(ViewBackups))]
[JsonSerializable(typeof(ViewBackup))]
[JsonSerializable(typeof(BackupPasswordForm))]
@@ -71,6 +90,15 @@ namespace decePubClient;
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))]
[JsonSerializable(typeof(ViewServerSettings))]
[JsonSerializable(typeof(ServerSettingsForm))]
[JsonSerializable(typeof(ViewAnnouncement))]
[JsonSerializable(typeof(List<ViewAnnouncement>))]
[JsonSerializable(typeof(AnnouncementForm))]
[JsonSerializable(typeof(ViewRelay))]
[JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
+136
View File
@@ -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();
}
+245
View File
@@ -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."]);
}
}
+58
View File
@@ -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
};
}
+155
View File
@@ -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();
}
+399
View File
@@ -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."]);
}
}
+214
View File
@@ -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."]);
}
}
+161
View File
@@ -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
};
}
+191
View File
@@ -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();
}
}
+27 -1
View File
@@ -1,7 +1,8 @@
@inherits LocalizableComponentBase
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
server, about an account and some of its posts; 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>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@@ -57,6 +58,27 @@
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
@if (report.RuleIds.Count > 0)
{
<div class="flex flex-col gap-1 text-sm" data-report-rules>
<b>@Localizer["Rules it names"]</b>
<ol class="list-decimal ps-5">
@foreach (var (ruleId, text) in RulesOf(report))
{
<li class="break-words" data-report-rule-id="@ruleId">
@if (text is { Length: > 0 })
{
@text
}
else
{
<span class="opacity-70">@Localizer["A rule no longer kept"]</span>
}
</li>
}
</ol>
</div>
}
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
@@ -113,6 +135,10 @@
IsLoading = false;
}
// each rule the report names with its text, in the report's order (an empty text: the server no longer has it)
static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) =>
report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null));
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
+177
View File
@@ -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."]);
}
}
+39 -2
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins.
A block is published (/api/v1/instance/domain_blocks, the About page) only when asked, since that tells the blocked
server and everyone else; published, its name can be partly starred. Blocking a server again changes its block, so
Edit fills the form with it. *@
<div class="flex flex-col gap-3 w-full" data-server-blocks>
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<DataAnnotationsValidator />
@@ -13,13 +16,19 @@
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div>
<div class="flex flex-wrap gap-2 items-center">
<DToggle LabelText="@Localizer["Publish it"]" Value="form.Published" ValueChanged="Publish" ValueExpression="() => form.Published"
data-server-block-published />
<DToggle LabelText="@Localizer["Hide part of its name"]" @bind-Value="form.Obfuscate" disabled="@(!form.Published)" data-server-block-obfuscate />
</div>
<p class="text-xs opacity-70">@Localizer["A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it."]</p>
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for everyone to read"]" />
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for the admins only"]" />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save>
@Localizer["Block server"]
</DButton>
</EditForm>
@@ -48,7 +57,16 @@
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
}
@if (block.Published)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Info" data-server-block-public>
@(block.Obfuscate ? Localizer["Published, partly hidden"] : Localizer["Published"])
</DBadge>
}
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Edit(block)" data-server-block-edit>
@Localizer["Edit"]
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
@Localizer["Unblock"]
</DButton>
@@ -89,6 +107,25 @@
async Task LoadData() => blocks = await AdminService.ServerBlocks();
// a block that isn't published can't be partly hidden either
void Publish(bool published)
{
form.Published = published;
if (!published)
form.Obfuscate = false;
}
void Edit(ViewDomainBlock block) => form = new()
{
Domain = block.Domain,
Suspend = block.Severity == "Suspend",
RejectMedia = block.RejectMedia,
Published = block.Published,
Obfuscate = block.Obfuscate,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment
};
async Task Block()
{
isSaving = true;
+164 -4
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
(by traffic, failures, latency or name, 50 a page) 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-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
@@ -87,7 +90,10 @@
@foreach (var host in hosts.Hosts)
{
<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 class="text-right tabular-nums">@host.In</td>
<td class="text-right tabular-nums">@host.Out</td>
@@ -116,6 +122,11 @@
<p class="text-xs opacity-70">@hosts.Attribution</p>
}
@if (openHost is { } detailHost)
{
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
}
@if (crawler is not null)
{
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
@@ -141,6 +152,99 @@
}
</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>
@@ -148,11 +252,24 @@
[Inject] AdminService AdminService { get; set; }
const int PageSize = 50;
const int EventsPageSize = 100;
// the ledger's channels and outcomes, as it keys them
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
static readonly int[] Periods = [1, 7, 30, 90];
ViewStatisticsOverview overview;
ViewHostsPage hosts;
ViewCrawler crawler;
List<ViewInteractionEvent> events = [];
List<ViewServerDay> serverDays = [];
// the server whose detail is open
string openHost;
string eventsHost;
string eventsChannel;
string eventsOutcome;
bool hasMoreEvents;
bool isLoadingMore;
int days = 30;
string sort = "traffic";
int page = 1;
@@ -179,10 +296,53 @@
}
async Task LoadData()
{
await LoadDays();
crawler = await AdminService.Crawler();
await ReloadEvents();
}
// what the period chosen shows
async Task LoadDays()
{
overview = await AdminService.Overview(days);
hosts = await AdminService.Hosts(days, sort, page);
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() ?? "—";
@@ -191,7 +351,7 @@
{
days = value;
page = 1;
await LoadData();
await LoadDays();
}
async Task ChangeSort(string value)
+207
View File
@@ -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
View File
@@ -124,7 +124,8 @@
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
</div>
<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>
</DButton>
</div>
@@ -167,17 +168,17 @@
@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"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<i class="@form.Visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
</Trigger>
<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)">
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.Visibility == visibility" OnClick="() => SetVisibility(visibility)">
<i class="@visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[visibility.ToString()]</span>
</DButton>
}
</ChildContent>
@@ -215,7 +216,7 @@
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<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>
}
@@ -391,11 +392,11 @@
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
// 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,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.Public
};
DTextarea<string> textarea;
@@ -413,7 +414,7 @@
protected override async Task OnInitializedAsync()
{
form.GroupId = GroupId;
form.MessageType = DefaultVisibility;
form.Visibility = DefaultVisibility;
form.Language = DefaultLanguage;
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
form.QuotedStatusId = Quoting?.Id;
@@ -446,12 +447,12 @@
return;
form.InReplyToId = InReplyTo.Id;
form.MessageType = InReplyTo.Visibility switch
form.Visibility = InReplyTo.Visibility switch
{
"direct" => MessageType.Direct,
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"direct" => Visibility.Direct,
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.Public
};
// a reply is written in its thread's language, as a rule
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
@@ -462,9 +463,9 @@
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;
}
@@ -599,10 +600,15 @@
// 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 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();
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
@@ -688,14 +694,14 @@
if (result.Data is ScheduledStatus scheduled)
{
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);
return;
}
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
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);
}
+144 -13
View File
@@ -5,10 +5,13 @@
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
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,
which the e2e tests (tests/e2e) find it by. 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
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
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.
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; "Filter this
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)
{
<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 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>
}
else if (Post.BoostedBy is { } booster)
@@ -283,11 +293,24 @@
<span class="hidden md:inline">@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
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>
<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>
}
<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>
<span>@Localizer["Bookmark"]</span>
</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>
<i aria-hidden="true" class="ion-md-stats text-base"></i>
<span>@Localizer["Details"]</span>
@@ -376,6 +411,17 @@
}
@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>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
@@ -398,6 +444,11 @@
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</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>
<i aria-hidden="true" class="ion-md-create text-base"></i>
@@ -461,6 +512,14 @@
{
<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)
{
<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] 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<string> OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
@@ -547,6 +608,8 @@
bool isReadingServer;
bool isHistoryOpen;
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
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
// 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
// (tests/e2e/Perf/FeedPerfTests).
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
// through.
object renderedWith;
int handlingEvents;
@@ -824,6 +887,74 @@
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] PublicNoticeService Notices { get; set; }
@@ -1129,10 +1260,10 @@
string VisibilityText(string visibility) => visibility switch
{
"public" => Localizer[MessageType.Public.ToString()],
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
"private" => Localizer[MessageType.FollowersOnly.ToString()],
"direct" => Localizer[MessageType.Direct.ToString()],
"public" => Localizer[Visibility.Public.ToString()],
"unlisted" => Localizer[Visibility.Unlisted.ToString()],
"private" => Localizer[Visibility.FollowersOnly.ToString()],
"direct" => Localizer[Visibility.Direct.ToString()],
_ => Localizer["Located"]
};
+3 -1
View File
@@ -1,5 +1,7 @@
using System.Globalization;
using decePubClient.Resources;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
@@ -39,7 +41,7 @@ public abstract class DInputBase<TValue> : InputBase<TValue>
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not valid.";
validationErrorMessage = string.Format(ErrorMessages.InvalidValue, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
result = date;
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not a valid date.";
validationErrorMessage = string.Format(ErrorMessages.InvalidDate, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
+88
View File
@@ -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();
}
+147
View File
@@ -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);
}
}
+77
View File
@@ -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();
}
+60 -4
View File
@@ -1,7 +1,9 @@
@inherits LocalizableComponentBase
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A
violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the
server has rules. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
<ChildContent>
@@ -11,8 +13,31 @@
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
@if (choices.Count > 0)
{
<option value="@ReportCategory.Violation">@Localizer[nameof(ReportCategory.Violation)]</option>
}
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect>
@if (form.Category is ReportCategory.Violation)
{
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2" data-report-rules>
<legend class="text-sm mb-1">@Localizer["Which of the server's rules does it break?"]</legend>
@foreach (var choice in choices)
{
<label class="flex items-start gap-2 cursor-pointer pointer-coarse:py-2">
<DCheckbox @bind-Value="choice.Picked" class="mt-0.5 flex-none" data-report-rule="@choice.Rule.Id" />
<span class="flex flex-col min-w-0">
<span class="text-sm break-words">@choice.Rule.Text</span>
@if (choice.Rule.Hint is { Length: > 0 } hint)
{
<span class="text-xs opacity-70 break-words">@hint</span>
}
</span>
</label>
}
</fieldset>
}
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
@if (Host is { } host)
@@ -30,6 +55,7 @@
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] AboutService AboutService { get; set; }
[Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary>
@@ -39,17 +65,41 @@
ReportForm form = new();
bool isSending;
string error;
// the server's rules, each ticked or not, for a violation
List<RuleChoice> choices = [];
// the account's own server, for a remote account
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
protected override 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()
{
isSending = true;
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;
if (result.IsInvalid)
{
@@ -62,4 +112,10 @@
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
sealed class RuleChoice
{
public InstanceRule Rule { get; init; }
public bool Picked { get; set; }
}
}
+242 -3
View File
@@ -1,11 +1,112 @@
@inherits LocalizableComponentBase
@* The login itself (the root, /clientapi/user): its password changed, 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. *@
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
device's goes on with a new JWT); its two-factor sign-in (TotpEditor); every session of it ended at once; or the whole
of it deleted, every persona with it.
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
<div class="flex flex-col gap-4 w-full" data-account>
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
<b class="text-sm">@Localizer["Email address"]</b>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (account is not null)
{
@if (account.Email is { Length: > 0 } email)
{
<p class="flex flex-wrap items-center gap-2 text-sm">
<span class="break-all" data-account-email-address>@email</span>
@if (account.IsEmailValidated)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-account-email-verified>@Localizer["verified"]</DBadge>
}
else
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-email-unverified>@Localizer["not verified"]</DBadge>
}
</p>
@if (!account.IsEmailValidated)
{
<p class="text-xs opacity-70">@Localizer["Only a verified address can recover a forgotten password."]</p>
}
}
else
{
<p class="text-sm" data-account-email-none>@Localizer["No email address yet: without one, a forgotten password can't be recovered."]</p>
}
@if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail)
{
<EditForm Model="code" OnValidSubmit="VerifyEmail" class="flex flex-col gap-2" data-account-email-code>
<DataAnnotationsValidator />
@if (codeSentence is not null)
{
<p class="text-sm opacity-70">@codeSentence</p>
}
<p class="text-sm break-all">@Localizer["Enter the code sent to {0}.", pending]</p>
<DOtp @bind-Value="code.Code" Immediate="true" aria-label="@Localizer["Code"]" data-account-email-code-digits />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-verify>
<i class="ion-md-checkmark"></i><span>@Localizer["Verify"]</span>
</DButton>
</EditForm>
}
@if (isChangingEmail)
{
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
<DataAnnotationsValidator />
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none"
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-send>
<i class="ion-md-mail"></i><span>@Localizer["Send the code"]</span>
</DButton>
</div>
</EditForm>
}
else if (isRemovingEmail)
{
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-remove-confirm>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (account.Email is { Length: > 0 })
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailRemoval" data-account-email-remove-open>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailChange" data-account-email-change-open>
<i class="ion-md-create"></i><span>@(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"])</span>
</DButton>
</div>
}
}
</div>
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b>
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2">
@@ -20,6 +121,16 @@
</DButton>
</EditForm>
<TotpEditor />
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
<b class="text-sm">@Localizer["Sign out everywhere"]</b>
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
<DButton Size="DSize.Sm" Color="DColor.Warning" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="LogoutEverywhere" data-account-everywhere-confirm>
<i class="ion-md-log-out"></i><span>@Localizer["Sign out everywhere"]</span>
</DButton>
</div>
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
<DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
@@ -36,17 +147,125 @@
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
ViewAccount account;
EmailChangeForm change = new();
EmailCodeForm code = new();
PasswordForm emailRemoval = new();
UserPasswordForm password = new();
RemoveSelfForm removal = new();
/// <summary>PrivaPub's answer to the change asked here, the same whether or not the address is free.</summary>
string codeSentence;
bool isChangingEmail;
bool isRemovingEmail;
bool understood;
bool isSaving;
protected override 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()
{
isSaving = true;
// the session goes on with the JWT PrivaPub answers with, and the persona with a new token
var result = await AuthService.ChangePassword(password);
isSaving = false;
if (result.IsInvalid)
@@ -59,6 +278,26 @@
Toast.AddResponseSuccess(Localizer["Password changed."]);
}
async Task LogoutEverywhere()
{
if (!await Confirm.Ask(Localizer["Sign out everywhere?"],
Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."],
Localizer["Sign out everywhere"], true))
return;
isSaving = true;
var result = await AuthService.LogoutEverywhere();
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
async Task Delete()
{
isSaving = true;
+1 -1
View File
@@ -14,7 +14,7 @@
data-export-kind="@data">
@if (exporting == data)
{
<DLoading Size="DSize.Xs" />
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
else
{
+2 -2
View File
@@ -84,7 +84,7 @@ else
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Visibility"]</span>
<DSelect TValue="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)
{
<option value="@type">@Localizer[type.ToString()]</option>
@@ -130,7 +130,7 @@ else
[Inject] AuthService AuthService { 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;
ProfileForm form = new();
+306
View File
@@ -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."]);
}
}
-704
View File
@@ -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"
}
]
}
-68
View File
@@ -1,22 +1,15 @@
using Blazored.LocalStorage;
using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Services;
using Markdig;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PrivaPub.ClientModels;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
@@ -28,24 +21,6 @@ namespace decePubClient.Extensions
{
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
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
@@ -82,30 +57,6 @@ namespace decePubClient.Extensions
#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
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() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsAdmin, "true")
+7 -36
View File
@@ -1,5 +1,4 @@
using collAnon.Client.Services;
using System.Collections.Specialized;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
@@ -12,12 +11,6 @@ using decePubClient.Models.Types;
using decePubClient.Resources;
using decePubClient.Services;
using DnetIndexedDb;
using DnetIndexedDb.Fluent;
using DnetIndexedDb.Models;
using Markdig;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Localization;
@@ -26,26 +19,6 @@ namespace decePubClient.Extensions;
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)
{
var timeframe = DateTime.Now - dateTime.ToLocalTime();
@@ -72,17 +45,17 @@ public static class GenericExtensions
_ => 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";
case MessageType.Unlisted:
case Visibility.Unlisted:
return "ion-md-unlock";
case MessageType.FollowersOnly:
case Visibility.FollowersOnly:
return "ion-md-lock";
case MessageType.Direct:
case Visibility.Direct:
return "ion-md-paper-plane";
default:
return default;
@@ -113,8 +86,6 @@ public static class GenericExtensions
return "ion-md-images";
case MediaType.Video:
return "ion-md-videocam";
case MediaType.Documents:
return "ion-md-document";
case MediaType.Audio:
return "ion-md-volume-high";
default:
-54
View File
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Mail;
using System.Text.Json;
using System.Text.Json.Serialization;
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) =>
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;
}
}
}
}
+101 -2
View File
@@ -3,7 +3,8 @@
@* 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
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>
@ChildContent
</CascadingValue>
@@ -11,6 +12,8 @@
@code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
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; }
[Inject] ILocalStorageService Storage { get; set; }
@@ -42,6 +45,11 @@
PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer;
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()
{
@@ -59,6 +67,8 @@
_ = PollOnline(cancellationToken);
await LoadSession(cancellationToken);
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken);
}
@@ -77,13 +87,98 @@
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)
{
change(PublicCacheData.PageSettings);
SaveSettingsSoon();
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>
/// 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).
@@ -161,6 +256,7 @@
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default)
{
CancelSettingsSave();
await AuthService.Logout(cancellationToken);
ClearSession();
StateHasChanged();
@@ -168,6 +264,8 @@
void ClearSession()
{
CancelSettingsSave();
loginSettings = null;
IsSignedIn = false;
RootUserName = null;
Personas = [];
@@ -236,6 +334,7 @@
{
await disposeCancellation.CancelAsync();
disposeCancellation.Dispose();
CancelSettingsSave();
onlineTimer?.Dispose();
refreshTimer?.Dispose();
cascadingStateReference?.Dispose();
@@ -1,5 +1,3 @@
using collAnon.Client.Services;
using decePubClient.Services;
using Microsoft.AspNetCore.Components;
-6
View File
@@ -1,6 +0,0 @@
namespace decePubClient.LayerComponents
{
public class PagesBase : LocalizableComponentBase
{
}
}
+8
View File
@@ -59,6 +59,14 @@ namespace decePubClient;
[JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))]
[JsonSerializable(typeof(UnreadCount))]
[JsonSerializable(typeof(InstanceRule))]
[JsonSerializable(typeof(List<InstanceRule>))]
[JsonSerializable(typeof(InstanceDocument))]
[JsonSerializable(typeof(TermsOfService))]
[JsonSerializable(typeof(List<PublicDomainBlock>))]
[JsonSerializable(typeof(Announcement))]
[JsonSerializable(typeof(List<Announcement>))]
[JsonSerializable(typeof(AnnouncementReactionEvent))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+45 -4
View File
@@ -9,7 +9,6 @@ public static class APIs
{
public const string ClientApiPrefix = "clientapi/";
public const string MastodonPrefix = "api/";
public const string OAuthPrefix = "oauth/";
public static class ClientApi
{
@@ -20,8 +19,20 @@ public static class APIs
public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password";
public const string POST_LogoutEverywhere = "clientapi/user/logout/everywhere";
public const string POST_RecoverPassword = "clientapi/user/recover/password";
public const string POST_RecoverValid = "clientapi/user/recover/valid";
public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password";
public const string GET_Account = "clientapi/user/account";
public const string POST_Email = "clientapi/user/email";
public const string POST_EmailVerify = "clientapi/user/email/verify";
public const string POST_EmailRemove = "clientapi/user/email/remove";
public const string POST_TotpSetup = "clientapi/user/totp/setup";
public const string POST_TotpEnable = "clientapi/user/totp/enable";
public const string POST_TotpDisable = "clientapi/user/totp/disable";
public const string GET_UserSettings = "clientapi/user/settings";
public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_CurrentVersion = "clientapi/data/current-version";
public const string GET_Personas = "clientapi/avatar/private/list";
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
public const string GET_Groups = "clientapi/group/list";
@@ -39,12 +50,28 @@ public static class APIs
public const string POST_DeletePost = "clientapi/post/delete";
public const string GET_ModeratorReports = "clientapi/moderator/reports";
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
public const string GET_ModeratorLog = "clientapi/moderator/log";
public const string GET_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list";
public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert";
public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update";
public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}";
public const string GET_AdminRelays = "clientapi/admin/relays";
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
public static string GET_M_StatisticsHost(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}";
public static string POST_M_StatisticsDescribe(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}/describe";
public const string GET_StatisticsEvents = "clientapi/admin/statistics/events";
public const string GET_StatisticsServer = "clientapi/admin/statistics/server";
public const string GET_AdminBackups = "clientapi/admin/backups";
public const string POST_AdminBackup = "clientapi/admin/backups";
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
@@ -91,11 +118,20 @@ public static class APIs
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 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 static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public const string GET_InstanceV2 = "api/v2/instance";
public const string GET_InstanceRules = "api/v1/instance/rules";
public const string GET_InstanceExtendedDescription = "api/v1/instance/extended_description";
public const string GET_InstancePrivacyPolicy = "api/v1/instance/privacy_policy";
public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service";
public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks";
public const string GET_Announcements = "api/v1/announcements";
public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss";
public static string M_AnnouncementReaction(string announcementId, string emoji) =>
$"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}";
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
public const string DELETE_ProfileHeader = "api/v1/profile/header";
public const string GET_AccountLookup = "api/v1/accounts/lookup";
@@ -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_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
public static string GET_M_AccountFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/followers";
public static string POST_M_RemoveFromFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/remove_from_followers";
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
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) =>
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string PUT_M_StatusInteractionPolicy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/interaction_policy";
public static string POST_M_RevokeQuote(string quotedId, string quotingId) =>
$"api/v1/statuses/{Uri.EscapeDataString(quotedId)}/quotes/{Uri.EscapeDataString(quotingId)}/revoke";
public const string GET_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications";
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
public static string POST_M_DismissNotificationGroup(string groupKey) => $"api/v2/notifications/{Uri.EscapeDataString(groupKey)}/dismiss";
public const string POST_Markers = "api/v1/markers";
public const string GET_TrendingTags = "api/v1/trends/tags";
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
@@ -159,7 +201,6 @@ public static class APIs
public static class PrivaPub
{
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_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
-1
View File
@@ -2,7 +2,6 @@ namespace decePubClient.Models;
public class AppConfiguration
{
public string Version { 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>
public string ClientId { get; set; } = "decepub";
-37
View File
@@ -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; }
}
}
+6 -6
View File
@@ -22,7 +22,7 @@ public class ComposeForm
public string GroupId { get; set; }
[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; }
// 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; }
@@ -64,12 +64,12 @@ public class ComposeForm
[JsonIgnore, Bindable(false)]
public bool IsScopeOptionsOpen { get; set; }
/// <summary>The Mastodon visibility of MessageType.</summary>
public string Visibility => MessageType switch
/// <summary>The Mastodon visibility of Visibility.</summary>
public string MastodonVisibility => Visibility switch
{
MessageType.Direct => "direct",
MessageType.FollowersOnly => "private",
MessageType.Unlisted => "unlisted",
Visibility.Direct => "direct",
Visibility.FollowersOnly => "private",
Visibility.Unlisted => "unlisted",
_ => "public"
};
}
+35
View File
@@ -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; }
}
+60 -1
View File
@@ -1,11 +1,39 @@
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 string Domain { get; set; }
public string Title { get; set; }
/// <summary>The short description, as plain text.</summary>
public string Description { get; set; }
public InstanceThumbnail Thumbnail { get; set; } = new();
public InstanceContact Contact { get; set; } = new();
public List<InstanceRule> Rules { get; set; } = [];
public InstanceConfiguration Configuration { get; set; } = new();
}
public class InstanceThumbnail
{
public string Url { get; set; }
}
/// <summary>Who answers for the server: an address, and the persona its administrator named (null when none).</summary>
public class InstanceContact
{
public string Email { get; set; }
public Account Account { get; set; }
}
/// <summary>One of the server's rules (/api/v1/instance/rules and the instance's own), in their order.</summary>
public class InstanceRule
{
public string Id { get; set; }
public string Text { get; set; }
public string Hint { get; set; }
}
public class InstanceConfiguration
{
public MediaConfiguration MediaAttachments { get; set; } = new();
@@ -21,3 +49,34 @@ public class MediaConfiguration
public long VideoMatrixLimit { get; set; }
public int VideoFrameRateLimit { get; set; }
}
/// <summary>/api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it
/// last changed.</summary>
public class InstanceDocument
{
public DateTime? UpdatedAt { get; set; }
public string Content { get; set; }
}
/// <summary>/api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none
/// (404).</summary>
public class TermsOfService
{
/// <summary>yyyy-MM-dd.</summary>
public string EffectiveDate { get; set; }
/// <summary>Whether they apply already (false while their date is ahead).</summary>
public bool Effective { get; set; }
public string Content { get; set; }
public string SucceededBy { get; set; }
}
/// <summary>/api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is
/// obfuscated, its digest the SHA-256 of the whole name.</summary>
public class PublicDomainBlock
{
public string Domain { get; set; }
public string Digest { get; set; }
/// <summary>suspend, silence or noop.</summary>
public string Severity { get; set; }
public string Comment { get; set; }
}
+3 -1
View File
@@ -254,8 +254,10 @@ public class ReportRequest
public string AccountId { get; set; }
public List<string> StatusIds { 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; }
/// <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; }
}
+4
View File
@@ -94,8 +94,12 @@ public class PrivaPubPlace
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 int Up { 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
View File
@@ -1,5 +1,6 @@
using PrivaPub.ClientModels.Resources;
using PrivaPub.ClientModels.User;
using System.ComponentModel.DataAnnotations;
@@ -10,22 +11,19 @@ namespace decePubClient.Models
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "LanguageId", ResourceType = typeof(FieldsNameResource))]
public string CurrentLanguageCode { get; set; } = "en";
//public bool NativeNotificationsEnabled { get; set; } = false;
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
public short LightThemeIndexColour { get; set; } = 25;
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
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 ThemeIsDarkMode { get; set; } = false;
public bool ThemeIsLightGray { get; set; } = true;
public bool ThemeIsDarkGray { get; set; } = false;
public bool ShowDonatorBadge { get; set; } = false;
// before publishing pictures without a description, the composer asks once
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;
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
@@ -61,5 +59,33 @@ namespace decePubClient.Models
LightThemeIndexColour = hue;
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;
}
}
}
+5 -5
View File
@@ -21,7 +21,7 @@ namespace decePubClient.Models
public bool Bot { get; set; }
public bool Discoverable { 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 string Language { get; set; }
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
@@ -43,9 +43,9 @@ namespace decePubClient.Models
Indexable = account.Indexable == true,
Privacy = source.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.Public
},
Sensitive = source.Sensitive,
Language = source.Language,
@@ -71,7 +71,7 @@ namespace decePubClient.Models
{ new StringContent(Flag(Bot)), "bot" },
{ new StringContent(Flag(Discoverable)), "discoverable" },
{ 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(Language ?? ""), "source[language]" },
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
+1 -11
View File
@@ -1,17 +1,7 @@
using BlazorZXingJs;
using System.Collections.ObjectModel;
namespace decePubClient.Models
namespace decePubClient.Models
{
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 bool ShowLogs { get; set; } = false;
public IReadOnlyDictionary<string, string> Policies { get; set; } = ReadOnlyDictionary<string, string>.Empty;
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public class SettingsOptions
{
public bool IsOpen { get; set; }
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
{
Images = 1,
Video,
Audio,
Documents
Audio
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace decePubClient.Models.Types
{
public enum MessageType
{
Direct = 1,
FollowersOnly,
Unlisted,
Public
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
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
{
Other = 0,
Spam = 1,
Legal = 2
Legal = 2,
Violation = 3
}
}
+11
View File
@@ -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
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public static class VConstants
{
public const string HideClass = "hidden";
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace decePubClient.Models
{
public class ViewLanguage
{
public string Name { get; set; }
public string International2Code { get; set; }
}
}
+223
View File
@@ -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
View File
@@ -4,30 +4,15 @@
<Title>@Localizer["Administration"]</Title>
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
up and restore the server. The
other sections wait for PrivaPub endpoints that do not exist yet. *@
@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its
rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation
statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it.
Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<DCollapse Icon="DCollapseIcon.Arrow">
<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">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="reports">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
@@ -40,8 +25,60 @@
</ChildContent>
</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">
<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>
<p class="inline-flex items-center gap-2">
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
@@ -54,7 +91,33 @@
</ChildContent>
</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>
<p class="inline-flex items-center gap-2">
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
@@ -67,7 +130,7 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="backups">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
@@ -81,144 +144,6 @@
</DCollapse>
</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>
</section>
@code {
}
+61 -6
View File
@@ -6,7 +6,9 @@
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
(Content.FilterIn). 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">
<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)
{
<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)
{
<div class="flex items-center gap-2 text-sm">
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
@if (PostOf(item) is { } post)
{
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<span class="truncate text-xs">
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
</span>
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
</span>
</a>
}
else if (readPostIds.Contains(item.StatusId))
{
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
}
else
{
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
}
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
</DButton>
@@ -170,6 +194,7 @@
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
[Inject] FilterService FilterService { get; set; }
[Inject] TimelineService TimelineService { get; set; }
List<Filter> filters = [];
TitleForm draft = new();
@@ -178,6 +203,9 @@
string loadedPersonaId;
bool isSaving;
bool isConfirmingDelete;
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
Dictionary<string, Status> filteredPosts = new();
HashSet<string> readPostIds = [];
(int Seconds, string Label)[] Expiries =>
[
@@ -240,6 +268,8 @@
{
IsLoading = true;
open = null;
filteredPosts = new();
readPostIds = [];
filters = await FilterService.All();
IsLoading = false;
}
@@ -260,10 +290,10 @@
filters.Add(filter);
draft = new();
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
Toggle(filter);
await Toggle(filter);
}
void Toggle(Filter filter)
async Task Toggle(Filter filter)
{
isConfirmingDelete = false;
if (open?.Id == filter.Id)
@@ -281,6 +311,31 @@
ExpiresIn = KeepExpiry,
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
};
await ReadPosts(filter);
}
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
async Task ReadPosts(Filter filter)
{
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
if (ids.Count == 0)
return;
foreach (var (id, status) in await TimelineService.Read(ids))
filteredPosts[id] = status;
readPostIds.UnionWith(ids);
}
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
// what the post says, as plain text: its content warning when it has one, else the start of its words
static string Excerpt(Status status)
{
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
if (string.IsNullOrEmpty(html))
return null;
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
}
void ToggleContext(FilterContext context, bool on)
+48 -9
View File
@@ -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
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
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">
@* 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>
</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"
OnChanged="ChangeFeed" />
@@ -41,12 +43,36 @@
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</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>
<i class="ion-md-notifications-off"></i>
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
</a>
<div class="flex flex-wrap justify-center gap-2">
@if (followRequests > 0)
{
<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)
@@ -91,10 +117,9 @@
</p>
}
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
(tests/e2e/Perf/FeedPerfTests) *@
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
<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>
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@@ -128,6 +153,7 @@
[Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; }
[Inject] FeedMemory Memory { get; set; }
[Inject] AccountService AccountService { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
@@ -137,6 +163,9 @@
int unreadNotifications;
// the requests the persona's notification policy filtered, shown on the Notifications tab
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;
string maxId;
string minId;
@@ -432,8 +461,11 @@
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 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>
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);
}
// 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()
{
yield return NerdRow.Of(Localizer["items"], posts.Count);
+1 -1
View File
@@ -115,7 +115,7 @@
return;
}
await CascadingState.LoadSession();
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
Navigation.NavigateTo("");
+106 -19
View File
@@ -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 (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">
<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">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to 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>
<h1 class="text-xl font-bold break-words" data-login-title>@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]</h1>
@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">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
<ErrorViewer AdditionalError="@error" />
@@ -49,10 +81,23 @@
}
<span>@Localizer["Login"]</span>
</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>
<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>
</p>
<p class="text-sm text-center">
<a class="link touch-target" href="about" data-about-offer>@Localizer["About this server"]</a>
</p>
</div>
}
else if (CascadingState.Personas.Count == 0)
@@ -94,11 +139,18 @@
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] AboutService AboutService { get; set; }
readonly LoginForm form = new();
string error;
bool isWorking;
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; }
@@ -116,22 +168,40 @@
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()
{
error = default;
isWorking = true;
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
var result = await AuthService.Login(form);
if (result.IsInvalid)
if (AuthService.NeedsOneTimeCode(result))
{
error = result.ErrorMessage;
needsCode = true;
form.OneTimeCode = default;
isWorking = false;
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;
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue();
}
@@ -151,6 +221,23 @@
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();
/// <summary>Back where the reader was sent from, never off the app.</summary>
+216
View File
@@ -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
View File
@@ -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
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,
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">
<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>
}
<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.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
@if (IsOwn)
{
<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>
</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">
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
@@ -212,6 +235,8 @@
bool isLoadingMore;
bool isListMenuOpen;
bool isReporting;
// the persona's own followers (true) or the accounts it follows (false), while that list is open
bool? showAccounts;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
@@ -359,6 +384,8 @@
});
}
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
+4
View File
@@ -21,6 +21,10 @@
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
<DToggle LabelText="@Localizer["Grey"]" Value="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"
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
data-remind-alt-text />
+1 -1
View File
@@ -129,7 +129,7 @@
return;
}
await CascadingState.LoadSession();
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
Navigation.NavigateTo("");
File renamed without changes.
+4 -4
View File
@@ -11,7 +11,6 @@ using Toolbelt.Blazor.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using PrivaPub.ClientModels;
using collAnon.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
@@ -45,6 +44,9 @@ builder.Services.AddOptions()
.AddScoped<AccountService>()
.AddScoped<PortabilityService>()
.AddScoped<AdminService>()
.AddScoped<InstanceAdminService>()
.AddScoped<AboutService>()
.AddScoped<AnnouncementService>()
.AddScoped<ArchiveService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
@@ -63,9 +65,7 @@ builder.Services.AddOptions()
.AddTransient<IHttpService, HttpService>()
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
.AddLocalization()
.AddTransient<IStorage, Storage>()
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug))
.AddIndexedDb();
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug));
var apiBaseAddress = builder.Configuration["AppConfiguration:ApiBaseAddress"];
builder.Services.AddHttpClient("default", client =>
+823 -43
View File
@@ -41,9 +41,6 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ActivityPub?" xml:space="preserve">
<value>ActivityPub?</value>
</data>
<data name="Administration" xml:space="preserve">
<value>Amministrazione</value>
</data>
@@ -53,9 +50,6 @@
<data name="Audio" xml:space="preserve">
<value>Audio</value>
</data>
<data name="Authentication" xml:space="preserve">
<value>Autenticazione</value>
</data>
<data name="Block" xml:space="preserve">
<value>Blocca</value>
</data>
@@ -83,12 +77,6 @@
<data name="Direct" xml:space="preserve">
<value>Diretto</value>
</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">
<value>Vuoto</value>
</data>
@@ -98,18 +86,12 @@
<data name="Favourite" xml:space="preserve">
<value>Preferito</value>
</data>
<data name="Federation" xml:space="preserve">
<value>Federazione</value>
</data>
<data name="Follow the system's light or dark setting" xml:space="preserve">
<value>Segui l'impostazione chiara o scura del sistema</value>
</data>
<data name="FollowersOnly" xml:space="preserve">
<value>Solo follower</value>
</data>
<data name="Frontend" xml:space="preserve">
<value>Frontend</value>
</data>
<data name="General" xml:space="preserve">
<value>Generale</value>
</data>
@@ -131,9 +113,6 @@
<data name="Light theme colour" xml:space="preserve">
<value>Colore del tema chiaro</value>
</data>
<data name="Link formatter" xml:space="preserve">
<value>Formattazione dei link</value>
</data>
<data name="loading" xml:space="preserve">
<value>caricamento</value>
</data>
@@ -155,9 +134,6 @@
<data name="Message scope type: {0}" xml:space="preserve">
<value>Visibilità del messaggio: {0}</value>
</data>
<data name="Metadata" xml:space="preserve">
<value>Metadati</value>
</data>
<data name="Missing content, either message or media" xml:space="preserve">
<value>Manca il contenuto: scrivi un messaggio o allega un file</value>
</data>
@@ -173,9 +149,6 @@
<data name="Other" xml:space="preserve">
<value>Altro</value>
</data>
<data name="Policies" xml:space="preserve">
<value>Regole</value>
</data>
<data name="Post" xml:space="preserve">
<value>Pubblica</value>
</data>
@@ -260,15 +233,6 @@
<data name="{0} blocked." xml:space="preserve">
<value>Hai bloccato {0}.</value>
</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">
<value>Hai silenziato {0}.</value>
</data>
@@ -462,7 +426,7 @@
<value>condivisioni per preferito</value>
</data>
<data name="camera" xml:space="preserve">
<value>camera</value>
<value>punto di vista</value>
</data>
<data name="centre of view" xml:space="preserve">
<value>centro della vista</value>
@@ -677,12 +641,6 @@
<data name="solar time {0}" xml:space="preserve">
<value>ora solare {0}</value>
</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">
<value>{0} a nord, {1} a sud</value>
</data>
@@ -2306,4 +2264,826 @@
<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>
</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="&quot;{0}&quot; 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>
+822 -42
View File
@@ -41,9 +41,6 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ActivityPub?" xml:space="preserve">
<value>ActivityPub?</value>
</data>
<data name="Administration" xml:space="preserve">
<value>Administration</value>
</data>
@@ -53,9 +50,6 @@
<data name="Audio" xml:space="preserve">
<value>Audio</value>
</data>
<data name="Authentication" xml:space="preserve">
<value>Authentication</value>
</data>
<data name="Block" xml:space="preserve">
<value>Block</value>
</data>
@@ -83,12 +77,6 @@
<data name="Direct" xml:space="preserve">
<value>Direct</value>
</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">
<value>Empty</value>
</data>
@@ -98,18 +86,12 @@
<data name="Favourite" xml:space="preserve">
<value>Favourite</value>
</data>
<data name="Federation" xml:space="preserve">
<value>Federation</value>
</data>
<data name="Follow the system's light or dark setting" xml:space="preserve">
<value>Follow the system's light or dark setting</value>
</data>
<data name="FollowersOnly" xml:space="preserve">
<value>Followers only</value>
</data>
<data name="Frontend" xml:space="preserve">
<value>Frontend</value>
</data>
<data name="General" xml:space="preserve">
<value>General</value>
</data>
@@ -131,9 +113,6 @@
<data name="Light theme colour" xml:space="preserve">
<value>Light theme colour</value>
</data>
<data name="Link formatter" xml:space="preserve">
<value>Link formatter</value>
</data>
<data name="loading" xml:space="preserve">
<value>loading</value>
</data>
@@ -155,9 +134,6 @@
<data name="Message scope type: {0}" xml:space="preserve">
<value>Message scope type: {0}</value>
</data>
<data name="Metadata" xml:space="preserve">
<value>Metadata</value>
</data>
<data name="Missing content, either message or media" xml:space="preserve">
<value>Missing content, either message or media</value>
</data>
@@ -173,9 +149,6 @@
<data name="Other" xml:space="preserve">
<value>Other</value>
</data>
<data name="Policies" xml:space="preserve">
<value>Policies</value>
</data>
<data name="Post" xml:space="preserve">
<value>Post</value>
</data>
@@ -260,15 +233,6 @@
<data name="{0} blocked." xml:space="preserve">
<value>{0} blocked.</value>
</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">
<value>{0} muted.</value>
</data>
@@ -677,12 +641,6 @@
<data name="solar time {0}" xml:space="preserve">
<value>solar time {0}</value>
</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">
<value>{0} in the north, {1} in the south</value>
</data>
@@ -2306,4 +2264,826 @@
<data name="Invitation link, for someone without a login here" xml:space="preserve">
<value>Invitation link, for someone without a login here</value>
</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="&quot;{0}&quot; 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>
+18
View File
@@ -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>
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
/// </summary>
+6
View File
@@ -41,6 +41,12 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</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">
<value>Il campo {0} può contenere al massimo {1} caratteri.</value>
</data>
+6
View File
@@ -117,6 +117,12 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</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">
<value>The field {0} can be at most {1} characters long.</value>
</data>
+96
View File
@@ -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
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
@@ -32,7 +30,7 @@ namespace decePubClient.Services
/// <summary>
/// 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.
/// </summary>
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -156,16 +154,10 @@ namespace decePubClient.Services
return [];
}
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FollowRequests), cancellationToken);
return [];
}
/// <summary>A page of the accounts waiting for the persona to accept their follow, newest first; Data is an
/// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one).</summary>
public Task<WebResult> FollowRequests(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_FollowRequests, maxId, cancellationToken);
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
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) =>
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>
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
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);
}
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
CancellationToken cancellationToken = default)
/// <summary>Reports an account, with the posts named, to the persona's server's moderators (a violation with the
/// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming
/// the persona.</summary>
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
{
@@ -290,6 +298,7 @@ namespace decePubClient.Services
StatusIds = statusIds,
Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(),
RuleIds = category is ReportCategory.Violation ? ruleIds : [],
Forward = forward
}, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+75 -5
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
@@ -11,9 +9,11 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
/// call. Lists come back empty and values null on failure; nothing throws.
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
/// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
/// 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>
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);
}
/// <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>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{
@@ -162,6 +177,61 @@ namespace decePubClient.Services
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)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+48
View File
@@ -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);
}
}
}
+5 -26
View File
@@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Components;
using decePubClient.Models;
using decePubClient.Models;
using Microsoft.JSInterop;
@@ -12,29 +12,12 @@ public class AppStatusService(IJSRuntime jsRuntime)
public async ValueTask<bool> IsOnline(CancellationToken cancellationToken = default) =>
await jsRuntime.InvokeAsync<bool>("isOnline", cancellationToken);
public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke<bool>("isServiceWorkerAvailable");
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 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
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");
// 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
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);
}
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
+249 -5
View File
@@ -1,6 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using System.Text.Json;
using decePubClient.Extensions;
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>
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)
{
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);
}
/// <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>
public async Task<bool> SetupPending(CancellationToken cancellationToken = default)
{
@@ -147,13 +158,220 @@ namespace decePubClient.Services
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)
{
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
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&lt;string&gt;), 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();
}
@@ -190,7 +408,33 @@ namespace decePubClient.Services
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)
+1 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels.Resources;
namespace collAnon.Client.Services
namespace decePubClient.Services
{
/// <summary>
/// The app's localizer: looks a key up in Resources/AllStrings, then in PrivaPub.ClientModels' FieldsNameResource and
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
-16
View File
@@ -3,8 +3,6 @@ using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models;
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> 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> 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,
CancellationToken cancellationToken = default);
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>
/// 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.
@@ -88,10 +81,6 @@ namespace decePubClient.Services
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);
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,
CancellationToken cancellationToken = default) =>
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
@@ -101,11 +90,6 @@ namespace decePubClient.Services
CancellationToken cancellationToken = default) =>
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)
{
var jwt = await authStateProvider.GetToken(cancellationToken);
+11 -35
View File
@@ -11,51 +11,27 @@ public interface ILoggingService
void Process(string message, string where = default, LogLevel level = LogLevel.Information);
}
/// <summary>
/// Writes to the browser console through ILogger and keeps errors and warnings in the IndexedDB client log, so they
/// 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
/// <summary>Writes to the browser console through ILogger.</summary>
public class LoggingService(ILogger<LoggingService> logger) : ILoggingService
{
public void Process(string message, string where = default, LogLevel level = LogLevel.Information) =>
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);
await storage.AddLog(exception, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
logger.LogError(exception, "[{Where}]", where);
return ValueTask.CompletedTask;
}
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);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
logger.LogError("[{Where}] {Message}", where, message);
return ValueTask.CompletedTask;
}
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);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessWarning)}");
}
logger.LogWarning("[{Where}] {Message}", where, message);
return ValueTask.CompletedTask;
}
}
-123
View File
@@ -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);
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using DnetIndexedDb;
using Microsoft.JSInterop;
namespace decePubClient.Services
{
public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options) : IndexedDbInterop(jsRuntime, options)
{
}
}
+178
View File
@@ -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);
}
}
}
-6
View File
@@ -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 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>
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
{
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
+17 -4
View File
@@ -1,8 +1,6 @@
using System.Net;
using System.Net.Http.Headers;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
@@ -13,9 +11,10 @@ namespace decePubClient.Services
{
/// <summary>
/// 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>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
public class MediaService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
static readonly MediaConfiguration Fallback = new()
{
@@ -81,6 +80,20 @@ namespace decePubClient.Services
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)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
-1
View File
@@ -1,4 +1,3 @@
using collAnon.Client.Services;
using System.Text;
using decePubClient.Extensions;
-10
View File
@@ -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>
public static string Template(string uri)
{
+43 -8
View File
@@ -1,8 +1,6 @@
using System.Globalization;
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
@@ -23,12 +21,16 @@ namespace decePubClient.Services
Bookmark,
Unbookmark,
Pin,
Unpin
Unpin,
/// <summary>PrivaPub's own, for the threadiverse: a downvote replaces the persona's favourite (its upvote).</summary>
Downvote,
Undownvote
}
/// <summary>
/// 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.
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -53,7 +55,7 @@ namespace decePubClient.Services
InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Visibility = form.MastodonVisibility,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
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,
@@ -188,10 +190,10 @@ namespace decePubClient.Services
AnsweringToPostId = form.InReplyToId,
HasContentWarning = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.MessageType switch
Visibility = form.Visibility switch
{
MessageType.FollowersOnly => "followersonly",
MessageType.Unlisted => "unlisted",
Visibility.FollowersOnly => "followersonly",
Visibility.Unlisted => "unlisted",
_ => "public"
},
Latitude = form.IsLocated ? form.Latitude : null,
@@ -230,6 +232,39 @@ namespace decePubClient.Services
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>
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
{
+66 -6
View File
@@ -16,24 +16,37 @@ namespace decePubClient.Services
/// 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);
/// <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>
/// 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
/// 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.
/// 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>
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
ILoggingService logger) : IAsyncDisposable
{
const string UserStream = "user";
ClientWebSocket socket;
CancellationTokenSource running;
StreamSubscription current;
bool announcements;
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
/// <summary>Raised for each message of the stream subscribed now.</summary>
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
{
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>
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;
var previous = current;
current = subscription;
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);
if (subscription is not null)
await Say("subscribe", subscription, cancellationToken);
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();
_ = Run(running.Token);
if (hear)
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)
@@ -132,6 +171,8 @@ namespace decePubClient.Services
await socket.ConnectAsync(Endpoint, cancellationToken);
if (current is not null)
await Say("subscribe", current, cancellationToken);
if (announcements && current?.Stream != UserStream)
await Say("subscribe", new StreamSubscription(UserStream), 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
|| message["payload"]?.GetValue<string>() is not { } payload)
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
{
"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()
{
current = null;
+8 -2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
@@ -158,6 +156,14 @@ namespace decePubClient.Services
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>
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
{
+12 -4
View File
@@ -15,7 +15,7 @@ namespace decePubClient.Services
/// 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.
/// </summary>
public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
public class TokenAuthStateProvider(ILocalStorageService storage, ILogger<TokenAuthStateProvider> logger)
: AuthenticationStateProvider
{
/// <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);
}
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)})");
authData = null;
loaded = true;
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
if (deleteDb)
await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
+4 -18
View File
@@ -81,6 +81,10 @@
<i class="ion-md-settings"></i>
<span>@Localizer["Settings"]</span>
</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">
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
<i class="ion-md-switch text-error"></i>
@@ -224,12 +228,6 @@
var isDark = IsDark;
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark,
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.
@@ -237,23 +235,11 @@
{
var isDark = IsDark;
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.
protected async Task UpdateThemeDarkMode(bool 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
View File
@@ -1,12 +1,10 @@
/* 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/
(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("../");
@source not "../wwwroot/vendor";
@source not "../Docs";
@source not "../tools";
@source not "../tests";
@plugin "../tools/tailwind/daisyui.mjs" {
themes: false;
+38
View File
@@ -62,6 +62,44 @@
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
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). */
+1 -1
View File
@@ -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 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
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
areas would overlap. */
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more