Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c88980374 | ||
|
|
b8d7858acb | ||
|
|
b731d8c8c0 | ||
|
|
7d4b35f0d7 | ||
|
|
fd2f2e7c65 | ||
|
|
e4414f1ac9 | ||
|
|
5796d62406 | ||
|
|
4da6a398d7 | ||
|
|
356ea7b430 | ||
|
|
48df316256 | ||
|
|
dc2190e271 | ||
|
|
70cb389537 | ||
|
|
9d662f702b | ||
|
|
e94e32d198 | ||
|
|
e88f4afc45 | ||
|
|
eade4e399a | ||
|
|
a3461c3ece | ||
|
|
ed3bf57e5b | ||
|
|
b66d7cf545 | ||
|
|
47ff60f57b | ||
|
|
18331531b8 | ||
|
|
f1ac5eaad2 | ||
|
|
5522fe4fbb | ||
|
|
7b1bfa71c0 | ||
|
|
da8f4ffcab | ||
|
|
89cef25def | ||
|
|
765220059d | ||
|
|
dd38002682 | ||
|
|
1c9773bab0 | ||
|
|
931b7fcff1 | ||
|
|
8f15d0d5c1 | ||
|
|
071bcce4b0 | ||
|
|
b3bb2a28c3 | ||
|
|
d2ab5e7b40 | ||
|
|
0b237b1951 | ||
|
|
ffd4acd3d0 | ||
|
|
63f2517f5b | ||
|
|
f1cb90ac03 | ||
|
|
6731077191 | ||
|
|
87f3f2402a | ||
|
|
38336f9869 | ||
|
|
63d83810d7 | ||
|
|
88e55e671c | ||
|
|
b7ff917d8c | ||
|
|
24748168c0 |
No files matched your search
@@ -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/
|
||||
@@ -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,34 +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`.
|
||||
- **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
|
||||
|
||||
@@ -96,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.
|
||||
|
||||
@@ -116,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);
|
||||
@@ -125,9 +117,63 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||
shadow with `shadow-none!`.
|
||||
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
|
||||
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
|
||||
file input's own button (Choose File, Browse…) is raised like the app's buttons.
|
||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||
every collapse disappears.
|
||||
|
||||
## Phones
|
||||
|
||||
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
|
||||
- **Width and pointer are separate.**
|
||||
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||
`pointer-coarse:` at a call site.
|
||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
|
||||
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||
daisyUI's own size variables:
|
||||
- buttons, tabs, inputs, selects and file inputs are 44px tall;
|
||||
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
|
||||
the padding box);
|
||||
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
||||
- field text is 16px, so iOS doesn't zoom into a focused field.
|
||||
|
||||
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
||||
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
||||
its arrow with `::after` (NerdTip takes minimum sizes).
|
||||
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
|
||||
- **Overlays on a phone rise from the bottom.**
|
||||
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
||||
close button (`ShowClose`, named by `CloseText`).
|
||||
- `DDropdown SheetOnNarrow` turns its content into a bottom sheet.
|
||||
- `Components/PhoneSheet.razor` shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a
|
||||
sheet on a phone.
|
||||
- All are capped by `dvh`, and the app itself is `h-dvh`.
|
||||
- **The Back button closes the newest open overlay** instead of leaving the page.
|
||||
- Overlays register through `IDOverlayHost` (`Components/Daisy`, cascaded from `MainLayout`):
|
||||
- `DModal` and `DDropdown` through the cascade;
|
||||
- the nav menu, `PhoneSheet` and the full-screen globe through `Services/OverlayService.cs`.
|
||||
- `main.js` marks a real Back press (a capture-phase `popstate`), since Blazor can't tell it from the app's own
|
||||
navigation, which closes every overlay and goes on.
|
||||
- No history entries are pushed.
|
||||
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
||||
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
|
||||
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
|
||||
server's thumbnail.
|
||||
- **On a touch screen:**
|
||||
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
||||
pinned to the globe with a mouse only;
|
||||
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||
- pictures open in `Components/MediaViewer.razor`.
|
||||
- **The feed on a phone:**
|
||||
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
|
||||
the nerd stats' Details are in More, and the author's picture is in the header;
|
||||
- a new post starts from a floating button;
|
||||
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
||||
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||
|
||||
## daisyUI Blazor components (`Components/Daisy/`)
|
||||
|
||||
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
||||
@@ -174,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
|
||||
@@ -183,6 +229,7 @@ supplies:
|
||||
messages.
|
||||
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
||||
`AdditionalError` and `AdditionalErrors`.
|
||||
- `ConfirmService` asks before what can't be undone (see **Phones**).
|
||||
|
||||
**HTTP (`Services/IHttpService.cs`).**
|
||||
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
||||
@@ -193,9 +240,11 @@ 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.
|
||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||
@@ -209,30 +258,77 @@ 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
|
||||
closes for good. Login offers it while it waits.
|
||||
- **Invitations** (`Pages/Join.razor` at `/join?code=`): a group's invitation makes a login whose first persona joins
|
||||
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
|
||||
- Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
|
||||
persona is sent from Login to Settings to make one.
|
||||
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
|
||||
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
|
||||
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
|
||||
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
|
||||
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
|
||||
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
|
||||
All of it goes with no credentials.
|
||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||
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`), `AccountService` (profiles, relationships, follow,
|
||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
||||
block, mute, follow requests, the persona's followers and following and removing a follower, `/api/v2/search`, whose
|
||||
handles and URLs PrivaPub resolves), `ListService` (the
|
||||
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
||||
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
|
||||
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
|
||||
(`/clientapi/moderator` 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`
|
||||
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
|
||||
link, participants and status.
|
||||
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
|
||||
@@ -240,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.
|
||||
@@ -257,39 +361,147 @@ 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.
|
||||
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
||||
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
|
||||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
||||
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
|
||||
a poll instead of media.
|
||||
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
|
||||
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
|
||||
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
|
||||
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||
- the ids are kept, so a post that fails never uploads its media again;
|
||||
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||
- a description changed after the upload is PUT before the post;
|
||||
- a file taken out before posting is deleted from PrivaPub (`MediaService.Discard`, `DELETE /api/v1/media/:id`,
|
||||
`data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
|
||||
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
||||
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
||||
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
|
||||
(`media_attributes`). Its visibility and poll stay.
|
||||
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
|
||||
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
||||
(`/api/v2/search`, never resolving), once typing pauses.
|
||||
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
||||
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
|
||||
- **Threads** (`Pages/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`:
|
||||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
|
||||
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
|
||||
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
|
||||
`data-quote-policy-choice`: anyone, its followers or nobody, `PUT /api/v1/statuses/:id/interaction_policy` with
|
||||
`quote_approval_policy`; the policy is read back from `quote_approval.automatic`, `StatusService.QuotePolicyOf`). A
|
||||
post by someone else quoting one of them has "Revoke quote" in its menu (`data-revoke-quote`, asked first; `POST
|
||||
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
|
||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
|
||||
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
|
||||
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
|
||||
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
|
||||
such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
|
||||
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
|
||||
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
|
||||
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
|
||||
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
|
||||
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
|
||||
everywhere", see **One sign-in**); or the whole of it
|
||||
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
|
||||
signs out), the persona's profile (`ProfileEditor`:
|
||||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
|
||||
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
|
||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||||
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
|
||||
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
|
||||
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
|
||||
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
|
||||
with progress, a report and Stop. Imported posts are delivered to no one.
|
||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked
|
||||
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
|
||||
log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
|
||||
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
|
||||
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators:
|
||||
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until
|
||||
then they have no section.
|
||||
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
|
||||
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
|
||||
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
|
||||
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
|
||||
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
|
||||
`AboutService` read the instance again.
|
||||
- **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description
|
||||
(`data-instance-short-description`), Markdown description (`data-instance-description`), contact address
|
||||
(`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`;
|
||||
picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
|
||||
`data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy
|
||||
policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the
|
||||
weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the
|
||||
configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The
|
||||
thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`,
|
||||
asked first) is saved as soon as it is picked, streamed from the file as multipart `file`.
|
||||
- **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its
|
||||
`data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`,
|
||||
`data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
|
||||
(`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id.
|
||||
- **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`,
|
||||
`data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions;
|
||||
`data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`,
|
||||
`-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`);
|
||||
`data-announcement-delete` asks first.
|
||||
- **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and
|
||||
`/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars
|
||||
part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a
|
||||
block, since blocking a server again changes its block. `data-server-block-save`.
|
||||
- **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source`
|
||||
configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from
|
||||
`Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers
|
||||
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
|
||||
- **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows,
|
||||
inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the
|
||||
configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`,
|
||||
`data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
|
||||
- **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time
|
||||
(`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who
|
||||
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
|
||||
(`data-log-note`). `data-log-reload`.
|
||||
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
|
||||
texts, a removed rule's too.
|
||||
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
|
||||
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
|
||||
ledger's events and the server's own days. Counted per server, never per account.
|
||||
- A server's name opens `HostDetailDialog` (`data-statistics-host-open`, `/hosts/{host}?days=`): what it says of itself
|
||||
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
|
||||
ledger's `channel:…:…:outcome` keys like the servers table; `live` for a day not rolled up yet), its last events, and
|
||||
"Describe again" (`data-host-describe`, `POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
|
||||
- Events (`data-statistics-events`, `EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
|
||||
time, and "Load more" asks for those `before` the last one's time.
|
||||
- The server's days (`data-statistics-server`, `/server?days=`): its client API's requests by status class, the
|
||||
documents it served, and its ledger written, dropped and failed.
|
||||
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
|
||||
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
|
||||
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
||||
to disk.
|
||||
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
|
||||
has, and carries on from what PrivaPub says after a refused or lost piece.
|
||||
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
|
||||
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
|
||||
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
|
||||
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
||||
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
|
||||
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
|
||||
links there while requests wait.
|
||||
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
|
||||
@@ -310,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
|
||||
@@ -327,8 +549,28 @@ supplies:
|
||||
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season
|
||||
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||||
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
|
||||
top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
|
||||
the points.
|
||||
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
|
||||
a table). The globe has no tooltip. What it draws:
|
||||
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
|
||||
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
|
||||
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
|
||||
- the tropics, the polar circles and the CDN ring;
|
||||
- the view's centre and spread, and rings around the posts in view;
|
||||
- a small orrery in the top left corner, re-anchored to the camera before each render.
|
||||
|
||||
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
|
||||
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
|
||||
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
|
||||
already made, so an idle globe still draws nothing.
|
||||
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
|
||||
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
|
||||
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
|
||||
classes, only the ring and the legend remain.
|
||||
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
|
||||
gets everything.
|
||||
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||
@@ -339,24 +581,20 @@ supplies:
|
||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||
request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||||
the place's elevation and day length, the next equinox or solstice).
|
||||
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
|
||||
|
||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||
- 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
|
||||
@@ -366,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
|
||||
|
||||
|
||||
+44
-2
@@ -27,13 +27,28 @@ 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))]
|
||||
[JsonSerializable(typeof(SetupForm))]
|
||||
[JsonSerializable(typeof(InvitationLoginForm))]
|
||||
[JsonSerializable(typeof(ViewServerSetup))]
|
||||
[JsonSerializable(typeof(UserPasswordForm))]
|
||||
[JsonSerializable(typeof(RemoveSelfForm))]
|
||||
[JsonSerializable(typeof(PasswordRecoveryForm))]
|
||||
[JsonSerializable(typeof(NewPasswordForm))]
|
||||
[JsonSerializable(typeof(ViewAccount))]
|
||||
[JsonSerializable(typeof(EmailChangeForm))]
|
||||
[JsonSerializable(typeof(EmailCodeForm))]
|
||||
[JsonSerializable(typeof(PasswordForm))]
|
||||
[JsonSerializable(typeof(TotpCodeForm))]
|
||||
[JsonSerializable(typeof(TotpDisableForm))]
|
||||
[JsonSerializable(typeof(ViewTotpSetup))]
|
||||
[JsonSerializable(typeof(ViewTotpEnabled))]
|
||||
[JsonSerializable(typeof(ViewUserSettings))]
|
||||
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(JwtUser))]
|
||||
[JsonSerializable(typeof(ViewAvatar))]
|
||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||
@@ -49,6 +64,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ViewPost))]
|
||||
[JsonSerializable(typeof(List<ViewPost>))]
|
||||
[JsonSerializable(typeof(InsertPostForm))]
|
||||
[JsonSerializable(typeof(DeletePostForm))]
|
||||
[JsonSerializable(typeof(ViewReport))]
|
||||
[JsonSerializable(typeof(List<ViewReport>))]
|
||||
[JsonSerializable(typeof(DomainBlockForm))]
|
||||
@@ -57,6 +73,32 @@ 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))]
|
||||
[JsonSerializable(typeof(RestoreBackupForm))]
|
||||
[JsonSerializable(typeof(ViewBackupTicket))]
|
||||
[JsonSerializable(typeof(ViewBackupUpload))]
|
||||
[JsonSerializable(typeof(ViewPersonaArchive))]
|
||||
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
|
||||
[JsonSerializable(typeof(ViewArchiveTicket))]
|
||||
[JsonSerializable(typeof(ViewArchiveUpload))]
|
||||
[JsonSerializable(typeof(ViewServerSettings))]
|
||||
[JsonSerializable(typeof(ServerSettingsForm))]
|
||||
[JsonSerializable(typeof(ViewAnnouncement))]
|
||||
[JsonSerializable(typeof(List<ViewAnnouncement>))]
|
||||
[JsonSerializable(typeof(AnnouncementForm))]
|
||||
[JsonSerializable(typeof(ViewRelay))]
|
||||
[JsonSerializable(typeof(List<ViewRelay>))]
|
||||
[JsonSerializable(typeof(RelayForm))]
|
||||
[JsonSerializable(typeof(ViewAdminActionsPage))]
|
||||
public partial class ClientJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The persona's own followers or the accounts it follows (/api/v1/accounts/:id/followers, /following), 40 a page by the
|
||||
Link header, each opening its profile. PrivaPub lists them to the persona alone (hide_collections), so only the
|
||||
persona's own profile opens this. A follower can be removed (remove_from_followers), asked first. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||
data-account-list="@(Followers ? "followers" : "following")">
|
||||
<Heading><span class="capitalize">@(Followers ? Localizer["followers"] : Localizer["following"])</span></Heading>
|
||||
<ChildContent>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||
}
|
||||
else if (accounts.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70 mt-3" data-account-list-empty>@Localizer["Nobody yet."]</p>
|
||||
}
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
@foreach (var account in accounts)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-account="@account.Acct">
|
||||
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||
@* alt="": the name is next to it. *@
|
||||
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="flex flex-col min-w-0">
|
||||
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||
</span>
|
||||
</a>
|
||||
@if (Followers)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => RemoveFollower(account)"
|
||||
data-follower-remove>
|
||||
<i class="ion-md-person text-error" aria-hidden="true"></i><span>@Localizer["Remove from followers"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (maxId is not null)
|
||||
{
|
||||
<div class="flex justify-center mt-3">
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-account-list-more>
|
||||
@if (isLoadingMore)
|
||||
{
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Load more"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
/// <summary>The persona's own account id.</summary>
|
||||
[Parameter, EditorRequired] public string AccountId { get; set; }
|
||||
/// <summary>Its followers, rather than the accounts it follows.</summary>
|
||||
[Parameter] public bool Followers { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
/// <summary>A follower was removed.</summary>
|
||||
[Parameter] public EventCallback<Account> OnRemoved { get; set; }
|
||||
|
||||
List<Account> accounts = [];
|
||||
HashSet<string> busy = [];
|
||||
string maxId;
|
||||
bool isLoadingMore;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
accounts = [];
|
||||
maxId = null;
|
||||
await LoadPage();
|
||||
}
|
||||
|
||||
async Task LoadPage()
|
||||
{
|
||||
var result = Followers ? await AccountService.Followers(AccountId, maxId) : await AccountService.Following(AccountId, maxId);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
var page = (AccountPage)result.Data;
|
||||
var known = accounts.Select(a => a.Id).ToHashSet();
|
||||
accounts.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||
maxId = page.MaxId;
|
||||
}
|
||||
|
||||
async Task LoadMore()
|
||||
{
|
||||
isLoadingMore = true;
|
||||
await LoadPage();
|
||||
isLoadingMore = false;
|
||||
}
|
||||
|
||||
async Task RemoveFollower(Account account)
|
||||
{
|
||||
var name = account.Name ?? account.Acct;
|
||||
if (!await Confirm.Ask(Localizer["Remove {0} from your followers?", name], Localizer["They no longer see your followers-only posts, and may ask to follow you again."],
|
||||
Localizer["Remove from followers"], true))
|
||||
return;
|
||||
busy.Add(account.Id);
|
||||
var result = await AccountService.RemoveFromFollowers(account.Id);
|
||||
busy.Remove(account.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
accounts.Remove(account);
|
||||
Toast.AddResponseSuccess(Localizer["{0} removed from your followers.", name]);
|
||||
await OnRemoved.InvokeAsync(account);
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@using System.Globalization
|
||||
|
||||
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
|
||||
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
|
||||
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
|
||||
with it, and every persona's stream hears it go. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-announcements>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
|
||||
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (announcements.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
|
||||
}
|
||||
|
||||
@foreach (var announcement in announcements)
|
||||
{
|
||||
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@switch (StateOf(announcement))
|
||||
{
|
||||
case "shown":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
|
||||
break;
|
||||
case "ended":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
|
||||
break;
|
||||
default:
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
|
||||
break;
|
||||
}
|
||||
@if (announcement.AllDay)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
|
||||
}
|
||||
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
|
||||
</span>
|
||||
<span class="flex-1"></span>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
|
||||
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
|
||||
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
|
||||
{
|
||||
<p class="text-xs opacity-70">@When(announcement)</p>
|
||||
}
|
||||
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
|
||||
@foreach (var reaction in announcement.Reactions)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
|
||||
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
|
||||
</DBadge>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editing is not null)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||
data-announcement-dialog>
|
||||
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
|
||||
<ChildContent>
|
||||
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
|
||||
<DataAnnotationsValidator />
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
|
||||
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
|
||||
data-announcement-text />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["From"]</span>
|
||||
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Until"]</span>
|
||||
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
|
||||
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
|
||||
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
|
||||
@if (isSaving)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Save"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
|
||||
@code {
|
||||
const string LocalInput = "yyyy-MM-ddTHH:mm";
|
||||
|
||||
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
List<ViewAnnouncement> announcements = [];
|
||||
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
|
||||
AnnouncementForm editing;
|
||||
string startsAt;
|
||||
string endsAt;
|
||||
string error;
|
||||
bool isSaving;
|
||||
HashSet<string> busy = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
|
||||
|
||||
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
|
||||
|
||||
string When(ViewAnnouncement announcement)
|
||||
{
|
||||
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
|
||||
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
|
||||
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
|
||||
return (starts, ends) switch
|
||||
{
|
||||
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
|
||||
({ } from, null) => Localizer["From {0}", from],
|
||||
_ => Localizer["until {0}", ends]
|
||||
};
|
||||
}
|
||||
|
||||
void Closed(bool open)
|
||||
{
|
||||
if (!open)
|
||||
editing = null;
|
||||
}
|
||||
|
||||
void New()
|
||||
{
|
||||
error = null;
|
||||
editing = new AnnouncementForm();
|
||||
startsAt = null;
|
||||
endsAt = null;
|
||||
}
|
||||
|
||||
void Edit(ViewAnnouncement announcement)
|
||||
{
|
||||
error = null;
|
||||
editing = new AnnouncementForm
|
||||
{
|
||||
Id = announcement.Id,
|
||||
Text = announcement.Text,
|
||||
StartsAt = announcement.StartsAt,
|
||||
EndsAt = announcement.EndsAt,
|
||||
AllDay = announcement.AllDay,
|
||||
IsPublished = announcement.IsPublished
|
||||
};
|
||||
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||||
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
|
||||
static DateTime? Utc(string local) =>
|
||||
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
error = null;
|
||||
editing.StartsAt = Utc(startsAt);
|
||||
editing.EndsAt = Utc(endsAt);
|
||||
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
|
||||
{
|
||||
error = Localizer["An announcement can't end before it starts."];
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.SaveAnnouncement(editing);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = (ViewAnnouncement)result.Data;
|
||||
var at = announcements.FindIndex(a => a.Id == saved.Id);
|
||||
if (at < 0)
|
||||
announcements.Insert(0, saved);
|
||||
else
|
||||
announcements[at] = saved;
|
||||
editing = null;
|
||||
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
|
||||
}
|
||||
|
||||
async Task Delete(ViewAnnouncement announcement)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
|
||||
return;
|
||||
|
||||
busy.Add(announcement.Id);
|
||||
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
|
||||
busy.Remove(announcement.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
announcements.Remove(announcement);
|
||||
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
|
||||
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
|
||||
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
|
||||
and everyone signs in again. A restore never undoes what protects. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-backups>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (restarting is not null)
|
||||
{
|
||||
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
|
||||
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
|
||||
</DAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
|
||||
<i class="ion-md-archive"></i> @Localizer["Back up now"]
|
||||
</DButton>
|
||||
@if (backups.Running is { } running)
|
||||
{
|
||||
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
|
||||
</span>
|
||||
}
|
||||
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
|
||||
</div>
|
||||
|
||||
@if (backups.Waiting is { } waiting)
|
||||
{
|
||||
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
|
||||
<span>
|
||||
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
|
||||
@if (waiting.Error is { Length: > 0 } error)
|
||||
{
|
||||
<br /><span class="text-xs">@error</span>
|
||||
}
|
||||
</span>
|
||||
</DAlert>
|
||||
}
|
||||
|
||||
@if (backups.LastRestore is { } last)
|
||||
{
|
||||
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
|
||||
@if (last.Abandoned)
|
||||
{
|
||||
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
|
||||
<p class="text-xs">@last.Error</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
<b>@Localizer["Restored {0}", last.Backup]</b>
|
||||
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
|
||||
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
|
||||
</p>
|
||||
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
|
||||
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
|
||||
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
|
||||
{
|
||||
<p class="text-xs" data-last-restore-tombstoned>
|
||||
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
|
||||
</p>
|
||||
}
|
||||
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
|
||||
data-backup-upload />
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
|
||||
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
|
||||
</DButton>
|
||||
</div>
|
||||
@if (uploading)
|
||||
{
|
||||
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
|
||||
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (backups.Backups.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
|
||||
}
|
||||
|
||||
@foreach (var backup in backups.Backups)
|
||||
{
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
|
||||
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
|
||||
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
|
||||
</div>
|
||||
<p class="text-xs opacity-70">
|
||||
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
|
||||
@(backup.DbOnly
|
||||
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
|
||||
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
|
||||
@if (backup.MediaMissing > 0)
|
||||
{
|
||||
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
|
||||
}
|
||||
@if (!backup.Consistent)
|
||||
{
|
||||
<span>; @Localizer["not read at one instant"]</span>
|
||||
}
|
||||
</p>
|
||||
@if (backup.RootsDeletedSince > 0)
|
||||
{
|
||||
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
|
||||
}
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
|
||||
<i class="ion-md-download"></i> @Localizer["Download"]
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
|
||||
OnClick="() => AskRestore(backup)" data-backup-restore>
|
||||
<i class="ion-md-refresh"></i> @Localizer["Restore"]
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
|
||||
<i class="ion-md-trash"></i> @Localizer["Delete"]
|
||||
</DButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
|
||||
CloseText="@Localizer["Close"]" data-backup-download-dialog>
|
||||
<Heading>@Localizer["Download a backup"]</Heading>
|
||||
<ChildContent>
|
||||
@if (ticket is null)
|
||||
{
|
||||
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
|
||||
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
|
||||
placeholder="@Localizer["Password"]" data-backup-password />
|
||||
<ErrorViewer AdditionalError="@dialogError" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
|
||||
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
|
||||
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
|
||||
</a>
|
||||
}
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
|
||||
@if (ticket is null)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
|
||||
@Localizer["Get the link"]
|
||||
</DButton>
|
||||
}
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
|
||||
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
|
||||
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
|
||||
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
|
||||
@if (restoring?.RootsDeletedSince > 0)
|
||||
{
|
||||
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
|
||||
}
|
||||
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
|
||||
placeholder="@Localizer["Password"]" data-backup-password />
|
||||
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
|
||||
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
|
||||
<ErrorViewer AdditionalError="@dialogError" />
|
||||
</div>
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Error"
|
||||
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
|
||||
OnClick="Restore" data-backup-confirm>
|
||||
@Localizer["Restore"]
|
||||
</DButton>
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] AdminService AdminService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||
[Inject] AppConfiguration Configuration { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
|
||||
const string UploadInput = "input[data-backup-upload]";
|
||||
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||
|
||||
ViewBackups backups = new();
|
||||
bool busy;
|
||||
string restarting;
|
||||
ViewBackup downloading;
|
||||
ViewBackup restoring;
|
||||
string password;
|
||||
string typedHost;
|
||||
string ticket;
|
||||
long ticketSize;
|
||||
string dialogError;
|
||||
string pickedName;
|
||||
bool uploading;
|
||||
long uploaded;
|
||||
long uploadSize;
|
||||
double uploadedPercent;
|
||||
IJSObjectReference transfer;
|
||||
DotNetObjectReference<BackupsPanel> self;
|
||||
CancellationTokenSource polling;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
backups = await AdminService.Backups() ?? backups;
|
||||
if (backups.Running is not null || backups.Waiting is not null)
|
||||
Poll();
|
||||
}
|
||||
|
||||
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
|
||||
void Poll()
|
||||
{
|
||||
if (polling is not null)
|
||||
return;
|
||||
polling = new();
|
||||
var token = polling.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var timer = new PeriodicTimer(PollEvery);
|
||||
while (await timer.WaitForNextTickAsync(token))
|
||||
{
|
||||
var read = await AdminService.Backups(token);
|
||||
if (read is not null)
|
||||
backups = read;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
|
||||
break;
|
||||
}
|
||||
polling = null;
|
||||
}, token);
|
||||
}
|
||||
|
||||
static DColor KindColor(string kind) => kind switch
|
||||
{
|
||||
"nightly" => DColor.Neutral,
|
||||
"manual" => DColor.Primary,
|
||||
"pre-deploy" => DColor.Info,
|
||||
"pre-restore" => DColor.Warning,
|
||||
"uploaded" => DColor.Accent,
|
||||
_ => DColor.Neutral
|
||||
};
|
||||
|
||||
string KindName(string kind) => kind switch
|
||||
{
|
||||
"nightly" => Localizer["Nightly"],
|
||||
"manual" => Localizer["Made by hand"],
|
||||
"pre-deploy" => Localizer["Before a deploy"],
|
||||
"pre-restore" => Localizer["Before a restore"],
|
||||
"uploaded" => Localizer["Uploaded"],
|
||||
_ => kind
|
||||
};
|
||||
|
||||
async Task BackUp()
|
||||
{
|
||||
busy = true;
|
||||
var result = await AdminService.BackUp();
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
backups.Running = "backup";
|
||||
Toast.AddResponseSuccess(Localizer["The backup has started."]);
|
||||
Poll();
|
||||
}
|
||||
|
||||
async Task Delete(ViewBackup backup)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
|
||||
return;
|
||||
busy = true;
|
||||
var result = await AdminService.DeleteBackup(backup.Id);
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
|
||||
}
|
||||
|
||||
void AskDownload(ViewBackup backup)
|
||||
{
|
||||
(password, ticket, dialogError) = (null, null, null);
|
||||
downloading = backup;
|
||||
}
|
||||
|
||||
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
|
||||
|
||||
async Task Download()
|
||||
{
|
||||
busy = true;
|
||||
var result = await AdminService.BackupTicket(downloading.Id, password);
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
dialogError = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
var granted = (ViewBackupTicket)result.Data;
|
||||
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
|
||||
ticketSize = granted.Bytes;
|
||||
password = null;
|
||||
dialogError = null;
|
||||
}
|
||||
|
||||
void AskRestore(ViewBackup backup)
|
||||
{
|
||||
(password, typedHost, dialogError) = (null, null, null);
|
||||
restoring = backup;
|
||||
}
|
||||
|
||||
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
|
||||
|
||||
async Task Restore()
|
||||
{
|
||||
busy = true;
|
||||
var asked = restoring;
|
||||
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
dialogError = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
CloseRestore();
|
||||
restarting = asked.Id;
|
||||
Poll();
|
||||
}
|
||||
|
||||
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||
|
||||
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||
|
||||
async Task Upload()
|
||||
{
|
||||
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||
self ??= DotNetObjectReference.Create(this);
|
||||
var started = await AdminService.StartBackupUpload();
|
||||
if (started.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(started);
|
||||
return;
|
||||
}
|
||||
|
||||
var upload = (ViewBackupUpload)started.Data;
|
||||
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
|
||||
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||
if (!sent.Ok)
|
||||
{
|
||||
uploading = false;
|
||||
await AdminService.CancelBackupUpload(upload.Id);
|
||||
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||
return;
|
||||
}
|
||||
|
||||
var finished = await AdminService.FinishBackupUpload(upload.Id);
|
||||
uploading = false;
|
||||
if (finished.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(finished);
|
||||
return;
|
||||
}
|
||||
|
||||
var backup = (ViewBackup)finished.Data;
|
||||
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
|
||||
pickedName = null;
|
||||
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task Progress(long received, long total)
|
||||
{
|
||||
(uploaded, uploadSize) = (received, total);
|
||||
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
sealed class UploadOutcome
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
polling?.Cancel();
|
||||
self?.Dispose();
|
||||
if (transfer is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await transfer.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The federation ledger's events, newest first (ViewInteractionEvent): what was received, delivered or fetched and how
|
||||
it ended, never who. Channels and outcomes show as the ledger keys them, like the statistics' counters. In the
|
||||
statistics' Events section and in a server's detail. *@
|
||||
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-events-table data-scroll-x>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@Localizer["When"]</th>
|
||||
@if (ShowHost)
|
||||
{
|
||||
<th>@Localizer["server"]</th>
|
||||
}
|
||||
<th>@Localizer["Channel"]</th>
|
||||
<th>@Localizer["Activity"]</th>
|
||||
<th>@Localizer["Outcome"]</th>
|
||||
<th>@Localizer["Reason"]</th>
|
||||
<th class="text-right">HTTP</th>
|
||||
<th class="text-right">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var happened in Events)
|
||||
{
|
||||
<tr data-statistics-event="@happened.Channel" data-outcome="@happened.Outcome">
|
||||
<td class="whitespace-nowrap" title="@NerdRow.Time(happened.At)">@happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</td>
|
||||
@if (ShowHost)
|
||||
{
|
||||
<td class="max-w-40 truncate" title="@happened.Host">@happened.Host</td>
|
||||
}
|
||||
<td>@happened.Channel</td>
|
||||
<td class="max-w-48 truncate" title="@happened.Object">@happened.Activity</td>
|
||||
<td>
|
||||
<DBadge Size="DSize.Xs" Color="@OutcomeColor(happened.Outcome)">@happened.Outcome</DBadge>
|
||||
</td>
|
||||
<td class="max-w-48 truncate" title="@happened.Reason">@happened.Reason</td>
|
||||
<td class="text-right tabular-nums">@happened.Status</td>
|
||||
<td class="text-right tabular-nums">@happened.LatencyMs</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</DTable>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public List<ViewInteractionEvent> Events { get; set; } = [];
|
||||
/// <summary>A column for the server, where the events are of many.</summary>
|
||||
[Parameter] public bool ShowHost { get; set; } = true;
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
static DColor OutcomeColor(string outcome) => outcome switch
|
||||
{
|
||||
"ok" or "accepted" => DColor.Success,
|
||||
"queued" or "deferred" or "retry" => DColor.Warning,
|
||||
"failed" or "dead" or "refused" or "rejected" or "dropped" => DColor.Error,
|
||||
_ => DColor.None
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* One server of the federation statistics (/clientapi/admin/statistics/hosts/{host}), opened from its row: what it says
|
||||
of itself and what PrivaPub knows of its deliveries (as nerd rows, by their field names), its days with the main
|
||||
counters (newest first; a live day is today's, or one not rolled up yet), and its latest events. "Describe again"
|
||||
asks PrivaPub to read its NodeInfo and instance description anew. Counted per server, never per account. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" BoxClass="md:w-11/12 md:max-w-4xl"
|
||||
data-host-detail="@HostName">
|
||||
<Heading><span class="break-all">@HostName</span></Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-4 mt-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isDescribing)" OnClick="Describe" data-host-describe>
|
||||
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Describe again"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex items-center gap-2"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||
}
|
||||
else if (detail is null)
|
||||
{
|
||||
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (detail.Instance is { } instance)
|
||||
{
|
||||
<NerdRows Rows="InstanceRows(instance)" />
|
||||
}
|
||||
|
||||
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-72 rounded-xl neo-inset neo-2xs" data-host-days data-scroll-x>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@Localizer["Day"]</th>
|
||||
<th class="text-right">@Localizer["Received"]</th>
|
||||
<th class="text-right">@Localizer["In"]</th>
|
||||
<th class="text-right">@Localizer["Out"]</th>
|
||||
<th class="text-right">@Localizer["Failures"]</th>
|
||||
<th class="text-right">@Localizer["Accounts"]</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var day in detail.Days.OrderByDescending(d => d.Day))
|
||||
{
|
||||
<tr data-host-day="@NerdRow.Time(day.Day)">
|
||||
<td class="whitespace-nowrap">
|
||||
@day.Day.ToString(SUtility.dateTimeFormat)
|
||||
@if (day.Live)
|
||||
{
|
||||
<DBadge Size="DSize.Xs" Color="DColor.Info">@Localizer["live"]</DBadge>
|
||||
}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">@Channel(day.Counters, "recv")</td>
|
||||
<td class="text-right tabular-nums">@Channel(day.Counters, "in")</td>
|
||||
<td class="text-right tabular-nums">@Channel(day.Counters, "out")</td>
|
||||
<td class="text-right tabular-nums">@Failures(day.Counters)</td>
|
||||
<td class="text-right tabular-nums">@day.Accounts</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</DTable>
|
||||
|
||||
@if (detail.Events.Count > 0)
|
||||
{
|
||||
<b class="text-sm">@Localizer["Last events"]</b>
|
||||
<EventsTable Events="detail.Events" ShowHost="false" />
|
||||
}
|
||||
<p class="text-xs opacity-70">@detail.Attribution</p>
|
||||
}
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] AdminService AdminService { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public string HostName { get; set; }
|
||||
/// <summary>How many days back its days go.</summary>
|
||||
[Parameter] public int Days { get; set; } = 30;
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
ViewHostDetail detail;
|
||||
bool isDescribing;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => detail = await AdminService.Host(HostName, Days);
|
||||
|
||||
async Task Describe()
|
||||
{
|
||||
isDescribing = true;
|
||||
var result = await AdminService.Describe(HostName);
|
||||
isDescribing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["PrivaPub will read what {0} says of itself again.", HostName]);
|
||||
}
|
||||
|
||||
/// <summary>A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent).</summary>
|
||||
static long Channel(Dictionary<string, long> counters, string channel) =>
|
||||
counters.Where(c => c.Key.StartsWith(channel + ":", StringComparison.Ordinal)).Sum(c => c.Value);
|
||||
|
||||
// the deliveries out that failed, are retried or were given up, as the servers table counts them
|
||||
static long Failures(Dictionary<string, long> counters) =>
|
||||
counters.Where(c => c.Key.Split(':') is { Length: >= 4 } parts && parts[0] == "out" && parts[3] is "retry" or "dead" or "failed").Sum(c => c.Value);
|
||||
|
||||
static IEnumerable<NerdRow> InstanceRows(ViewHostInstance instance)
|
||||
{
|
||||
yield return NerdRow.Of("software", $"{instance.Software} {instance.SoftwareVersion}".Trim());
|
||||
yield return NerdRow.Of("nodeName", instance.NodeName);
|
||||
yield return NerdRow.Of("title", instance.Title);
|
||||
yield return NerdRow.Of("nodeDescription", instance.NodeDescription);
|
||||
yield return NerdRow.Of("openRegistrations", instance.OpenRegistrations);
|
||||
yield return NerdRow.Of("registrationMode", instance.RegistrationMode);
|
||||
yield return NerdRow.Of("usersTotal", instance.UsersTotal);
|
||||
yield return NerdRow.Of("usersActiveMonth", instance.UsersActiveMonth);
|
||||
yield return NerdRow.Of("localPosts", instance.LocalPosts);
|
||||
yield return NerdRow.Of("languages", instance.Languages.Count > 0 ? string.Join(", ", instance.Languages) : null);
|
||||
yield return NerdRow.Of("seen", instance.Seen);
|
||||
yield return NerdRow.Of("firstSeenAt", instance.FirstSeenAt);
|
||||
yield return NerdRow.Of("lastSeenAt", instance.LastSeenAt);
|
||||
yield return NerdRow.Of("describedAt", instance.DescribedAt);
|
||||
yield return NerdRow.Of("descriptionError", instance.DescriptionError);
|
||||
yield return NerdRow.Of("lastSuccessAt", instance.LastSuccessAt);
|
||||
yield return NerdRow.Of("lastFailureAt", instance.LastFailureAt);
|
||||
yield return NerdRow.Of("lastError", instance.LastError);
|
||||
yield return NerdRow.Of("consecutiveFailures", instance.ConsecutiveFailures > 0 ? instance.ConsecutiveFailures : null);
|
||||
yield return NerdRow.Of("unavailableUntil", instance.UnavailableUntil);
|
||||
yield return NerdRow.Of("place", string.Join(", ", new[] { instance.City, instance.Country }.Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } place ? place : null);
|
||||
yield return NerdRow.Of("asn", instance.Asn is { } asn ? $"AS{asn} {instance.AsnOrg}".Trim() : null);
|
||||
yield return NerdRow.Of("cdn", instance.Cdn);
|
||||
yield return NerdRow.Of("instanceApi", instance.InstanceApi);
|
||||
yield return NerdRow.Of("sourceUrl", instance.SourceUrl);
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@using System.Globalization
|
||||
|
||||
@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown
|
||||
description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the
|
||||
local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its
|
||||
weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts
|
||||
from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays.
|
||||
Registrations are the configuration's and only shown. *@
|
||||
<div class="flex flex-col gap-4 w-full" data-instance>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (settings is null)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold">@Localizer["Thumbnail"]</span>
|
||||
<img src="@settings.ThumbnailUrl" alt="@Localizer["Thumbnail"]" class="w-full max-w-sm aspect-[40/21] object-cover rounded-lg neo-inset neo-2xs"
|
||||
data-instance-thumbnail-image="@(settings.ThumbnailIsDefault ? "default" : "uploaded")" />
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="PickThumbnail" disabled="@(IsDefaultDisabled || isUploading)"
|
||||
aria-label="@Localizer["Thumbnail"]" data-instance-thumbnail />
|
||||
@if (isUploading)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
@if (!settings.ThumbnailIsDefault)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isUploading" OnClick="RemoveThumbnail" data-instance-thumbnail-remove>
|
||||
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Back to the default picture"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]</p>
|
||||
<ErrorViewer AdditionalErrors="thumbnailErrors" />
|
||||
</div>
|
||||
|
||||
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">@Localizer["Server name"]</span>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.Title" Immediate="true" autocomplete="off" maxlength="64" data-instance-title />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">@Localizer["Short description"]</span>
|
||||
<DTextarea TValue="string" class="w-full" @bind-Value="form.ShortDescription" Immediate="true" rows="2" autocomplete="off" maxlength="500"
|
||||
data-instance-short-description />
|
||||
<span class="text-xs opacity-70">@Localizer["Plain text, as the instance API, NodeInfo and the server's own actor give it."]</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">@Localizer["Description (Markdown)"]</span>
|
||||
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.Description" Immediate="true" rows="8" autocomplete="off"
|
||||
maxlength="@ServerSettingsForm.MaxText" data-instance-description />
|
||||
@if (settings.DescriptionUpdatedAt is { } described)
|
||||
{
|
||||
<span class="text-xs opacity-70" title="@described.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", described.GetPassedTime(Localizer)]</span>
|
||||
}
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||
<legend class="text-sm font-semibold px-1">@Localizer["Contact"]</legend>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Contact address"]</span>
|
||||
<DInput TValue="string" Type="email" class="w-full" @bind-Value="form.ContactEmail" Immediate="true" autocomplete="off" maxlength="254"
|
||||
data-instance-email>
|
||||
<Prefix><i class="ion-md-mail" aria-hidden="true"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
<span class="text-sm">@Localizer["Contact persona"]</span>
|
||||
@if (form.ContactAccountId is { Length: > 0 })
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-raised neo-2xs" data-instance-contact="@form.ContactAccountId">
|
||||
<i class="ion-md-person text-lg" aria-hidden="true"></i>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<b class="truncate">@(contactName ?? form.ContactAccountId)</b>
|
||||
@if (contactHandle is { Length: > 0 })
|
||||
{
|
||||
<span class="text-xs opacity-70 truncate">@contactHandle</span>
|
||||
}
|
||||
</span>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="RemoveContact" data-instance-contact-remove>
|
||||
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["No contact persona"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["None: the server names nobody."]</p>
|
||||
}
|
||||
@* its form attribute names no form, so Enter in the search never saves the settings *@
|
||||
<DInput TValue="string" class="w-full" Value="contactQuery" ValueChanged="SearchContact" ValueExpression="() => contactQuery" Immediate="true"
|
||||
autocomplete="off" form="instance-contact-search" placeholder="@Localizer["Search this server's personas"]"
|
||||
aria-label="@Localizer["Search this server's personas"]" data-instance-contact-search>
|
||||
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
|
||||
</DInput>
|
||||
<div class="flex flex-wrap gap-2" data-instance-contact-options>
|
||||
@if (contactQuery is { Length: > 0 })
|
||||
{
|
||||
@foreach (var account in contactMatches)
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving" OnClick="() => PickContact(account.Id, account.Name, account.Acct)"
|
||||
data-instance-contact-option="@account.Id">
|
||||
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||
</DButton>
|
||||
}
|
||||
@if (contactMatches.Count == 0 && !isSearching)
|
||||
{
|
||||
<span class="text-xs opacity-70">@Localizer["No persona of this server matches."]</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var persona in CascadingState.Personas)
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving"
|
||||
OnClick="() => PickContact(persona.Id, persona.Name ?? persona.UserName, persona.Handle ?? persona.UserName)" data-instance-contact-option="@persona.Id">
|
||||
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
|
||||
</DButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["The contact persona is shown to everyone as the one who runs this server."]</p>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">@Localizer["Privacy policy (Markdown)"]</span>
|
||||
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.PrivacyPolicy" Immediate="true" rows="8" autocomplete="off"
|
||||
maxlength="@ServerSettingsForm.MaxText" data-instance-privacy />
|
||||
@if (settings.PrivacyPolicyUpdatedAt is { } policyAt)
|
||||
{
|
||||
<span class="text-xs opacity-70" title="@policyAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", policyAt.GetPassedTime(Localizer)]</span>
|
||||
}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">@Localizer["Terms of service (Markdown)"]</span>
|
||||
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.TermsOfService" Immediate="true" rows="8" autocomplete="off"
|
||||
maxlength="@ServerSettingsForm.MaxText" placeholder="@Localizer["None"]" data-instance-terms />
|
||||
</label>
|
||||
<label class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm">@Localizer["In effect from"]</span>
|
||||
<DInput TValue="string" Type="date" class="w-auto" @bind-Value="termsDate" autocomplete="off" data-instance-terms-date />
|
||||
</label>
|
||||
<span class="text-xs opacity-70">@Localizer["Empty, the server has no terms. Without a date, new terms take effect today."]</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<DToggle LabelText="@Localizer["Publish the weekly activity"]" @bind-Value="form.PublishActivity" data-instance-publish-activity />
|
||||
<p class="text-xs opacity-70">@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm" data-instance-registrations="@settings.RegistrationMode">
|
||||
<b>@Localizer["Sign-ups"]</b>
|
||||
@(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"])
|
||||
<span class="text-xs opacity-70">@Localizer["(set in the server's configuration, Registrations:Mode)"]</span>
|
||||
</p>
|
||||
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
|
||||
@if (settings.UpdatedAt is { } savedAt)
|
||||
{
|
||||
<span class="me-auto text-xs opacity-70" title="@savedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)" data-instance-saved>
|
||||
@Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy]
|
||||
</span>
|
||||
}
|
||||
<DButton Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-instance-reset>@Localizer["Discard changes"]</DButton>
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-instance-save>
|
||||
@if (isSaving)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Save"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] MediaService MediaService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
ViewServerSettings settings;
|
||||
ServerSettingsForm form = new();
|
||||
// the terms' date as the date input holds it (yyyy-MM-dd), empty for none
|
||||
string termsDate;
|
||||
// the contact persona as shown: what the settings name, or the one just picked
|
||||
string contactName;
|
||||
string contactHandle;
|
||||
string contactQuery;
|
||||
List<Account> contactMatches = [];
|
||||
int searching;
|
||||
bool isSearching;
|
||||
bool isSaving;
|
||||
bool isUploading;
|
||||
List<string> pictureTypes = [];
|
||||
List<string> thumbnailErrors = [];
|
||||
|
||||
string PictureTypes => string.Join(',', pictureTypes);
|
||||
|
||||
// what PrivaPub takes for a picture now, within its hard cap
|
||||
long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0
|
||||
? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes)
|
||||
: ServerMediaLimitsForm.HardMaxImageBytes;
|
||||
|
||||
string SavedBy => settings?.UpdatedBy switch
|
||||
{
|
||||
null => Localizer["someone"],
|
||||
"cli" => Localizer["the command line"],
|
||||
var name => name
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))];
|
||||
settings = await InstanceAdminService.Settings();
|
||||
Reset();
|
||||
}
|
||||
|
||||
async Task Retry()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Reset()
|
||||
{
|
||||
if (settings is null)
|
||||
return;
|
||||
form = InstanceAdminService.FormOf(settings);
|
||||
termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName;
|
||||
contactHandle = settings.ContactAccount?.Handle;
|
||||
contactQuery = null;
|
||||
contactMatches = [];
|
||||
}
|
||||
|
||||
async Task SearchContact(string query)
|
||||
{
|
||||
contactQuery = query;
|
||||
var lookup = ++searching;
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
contactMatches = [];
|
||||
isSearching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching = true;
|
||||
await Task.Delay(300);
|
||||
if (lookup != searching)
|
||||
return;
|
||||
var found = await AccountService.Suggest(query.Trim(), false);
|
||||
if (lookup != searching)
|
||||
return;
|
||||
// the server's own personas only: a local account's acct has no host
|
||||
contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))];
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
void PickContact(string accountId, string name, string handle)
|
||||
{
|
||||
form.ContactAccountId = accountId;
|
||||
contactName = name;
|
||||
contactHandle = handle;
|
||||
contactQuery = null;
|
||||
contactMatches = [];
|
||||
}
|
||||
|
||||
void RemoveContact()
|
||||
{
|
||||
form.ContactAccountId = null;
|
||||
contactName = null;
|
||||
contactHandle = null;
|
||||
}
|
||||
|
||||
async Task PickThumbnail(InputFileChangeEventArgs eventArgs)
|
||||
{
|
||||
thumbnailErrors = [];
|
||||
var file = eventArgs.File;
|
||||
if (!pictureTypes.Contains(file.ContentType))
|
||||
{
|
||||
thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||
return;
|
||||
}
|
||||
if (file.Size > MaximumThumbnail)
|
||||
{
|
||||
thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
|
||||
MaximumThumbnail.GetFileSize(Localizer)]);
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail);
|
||||
isUploading = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// the form keeps what is being written; only the picture and the dates come from the answer
|
||||
settings = (ViewServerSettings)result.Data;
|
||||
Toast.AddResponseSuccess(Localizer["Thumbnail saved."]);
|
||||
}
|
||||
|
||||
async Task RemoveThumbnail()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."],
|
||||
Localizer["Remove"], true))
|
||||
return;
|
||||
|
||||
isUploading = true;
|
||||
var result = await InstanceAdminService.RemoveThumbnail();
|
||||
isUploading = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
settings = (ViewServerSettings)result.Data;
|
||||
Toast.AddResponseSuccess(Localizer["Thumbnail removed."]);
|
||||
}
|
||||
|
||||
// the value written here when it differs from the one shown, else the one PrivaPub has now
|
||||
static string Changed(string written, string shown, string latest) => written == shown ? latest : written;
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
var effective = default(DateTime?);
|
||||
if (termsDate is { Length: > 0 })
|
||||
{
|
||||
if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var on))
|
||||
{
|
||||
Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]);
|
||||
return;
|
||||
}
|
||||
effective = on;
|
||||
}
|
||||
|
||||
// only what was changed here: what another administrator saved meanwhile to the rest stays
|
||||
var shown = InstanceAdminService.FormOf(settings);
|
||||
var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.SaveSettings(latest =>
|
||||
{
|
||||
latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title);
|
||||
latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription);
|
||||
latest.Description = Changed(form.Description, shown.Description, latest.Description);
|
||||
latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail);
|
||||
latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId);
|
||||
latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy);
|
||||
latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService);
|
||||
latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt;
|
||||
latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity;
|
||||
});
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
settings = (ViewServerSettings)result.Data;
|
||||
Reset();
|
||||
Toast.AddResponseSuccess(Localizer["The server's settings are saved."]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The media limits the administrator sets over the configuration's (Media:*, /clientapi/admin/settings): what a login's
|
||||
media may take in all, how big a picture and a video may be, and how long a sound or a video may last. Left empty, a
|
||||
limit is the configuration's; beside each, the configured value and the one in force now, and PrivaPub's hard caps
|
||||
are the maxima (40 MB, 99 MB, four hours). Saving starts from the settings PrivaPub has now
|
||||
(InstanceAdminService.SaveSettings), so what Instance or Rules saved stays. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-media-limits>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (settings is null)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload."]</p>
|
||||
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="quota">
|
||||
<label class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Media a login may keep in all"]</span>
|
||||
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="quotaMb" Immediate="true" autocomplete="off" min="0" step="1"
|
||||
placeholder="@Localizer["as configured"]" data-media-quota>
|
||||
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||
</DInput>
|
||||
</label>
|
||||
<p class="text-xs opacity-70">
|
||||
@Localizer["Configured: {0}", Quota(settings.Media.ConfiguredQuotaBytesPerRoot)] ·
|
||||
<span data-media-effective>@Localizer["In force: {0}", Quota(settings.Media.EffectiveQuotaBytesPerRoot)]</span> ·
|
||||
@Localizer["0: no quota"]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="image">
|
||||
<label class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest picture"]</span>
|
||||
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="imageMb" Immediate="true" autocomplete="off" min="1"
|
||||
max="@(settings.Media.HardMaxImageBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-image>
|
||||
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||
</DInput>
|
||||
</label>
|
||||
<p class="text-xs opacity-70">
|
||||
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxImageBytes.GetFileSize(Localizer)] ·
|
||||
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxImageBytes.GetFileSize(Localizer)]</span> ·
|
||||
@Localizer["At most {0}", settings.Media.HardMaxImageBytes.GetFileSize(Localizer)]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="video">
|
||||
<label class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest video or sound"]</span>
|
||||
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="videoMb" Immediate="true" autocomplete="off" min="1"
|
||||
max="@(settings.Media.HardMaxVideoBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-video>
|
||||
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
|
||||
</DInput>
|
||||
</label>
|
||||
<p class="text-xs opacity-70">
|
||||
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxVideoBytes.GetFileSize(Localizer)] ·
|
||||
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxVideoBytes.GetFileSize(Localizer)]</span> ·
|
||||
@Localizer["At most {0}", settings.Media.HardMaxVideoBytes.GetFileSize(Localizer)]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="seconds">
|
||||
<label class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Longest video or sound"]</span>
|
||||
<DInput TValue="int?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="seconds" Immediate="true" autocomplete="off" min="1"
|
||||
max="@settings.Media.HardMaxSeconds" step="1" placeholder="@Localizer["as configured"]" data-media-seconds>
|
||||
<Suffix><span class="text-xs opacity-70">@Localizer["seconds"]</span></Suffix>
|
||||
</DInput>
|
||||
</label>
|
||||
<p class="text-xs opacity-70">
|
||||
@Localizer["Configured: {0}", Length(settings.Media.ConfiguredMaxSeconds)] ·
|
||||
<span data-media-effective>@Localizer["In force: {0}", Length(settings.Media.EffectiveMaxSeconds)]</span> ·
|
||||
@Localizer["At most {0}", Length(settings.Media.HardMaxSeconds)]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ErrorViewer AdditionalErrors="errors" />
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="UseConfigured" data-media-use-configured>
|
||||
@Localizer["Use the configured values"]
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-media-reset>@Localizer["Discard changes"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" OnClick="Save" data-media-save>
|
||||
@if (isSaving)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Save the limits"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
const long Megabyte = 1024 * 1024;
|
||||
|
||||
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||
|
||||
ViewServerSettings settings;
|
||||
// the overrides as being written, in megabytes and seconds; null: the configured value
|
||||
long? quotaMb;
|
||||
long? imageMb;
|
||||
long? videoMb;
|
||||
int? seconds;
|
||||
List<string> errors = [];
|
||||
bool isSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
settings = await InstanceAdminService.Settings();
|
||||
Reset();
|
||||
}
|
||||
|
||||
async Task Retry()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Reset()
|
||||
{
|
||||
errors = [];
|
||||
if (settings is null)
|
||||
return;
|
||||
quotaMb = Megabytes(settings.Media.QuotaBytesPerRoot);
|
||||
imageMb = Megabytes(settings.Media.MaxImageBytes);
|
||||
videoMb = Megabytes(settings.Media.MaxVideoBytes);
|
||||
seconds = settings.Media.MaxSeconds;
|
||||
}
|
||||
|
||||
void UseConfigured()
|
||||
{
|
||||
errors = [];
|
||||
quotaMb = null;
|
||||
imageMb = null;
|
||||
videoMb = null;
|
||||
seconds = null;
|
||||
}
|
||||
|
||||
string Quota(long bytes) => bytes <= 0 ? Localizer["no quota"] : bytes.GetFileSize(Localizer);
|
||||
|
||||
static string Length(int value) => NerdRow.Duration(TimeSpan.FromSeconds(value));
|
||||
|
||||
static long? Megabytes(long? bytes) => bytes is { } value ? (long)Math.Round(value / (double)Megabyte) : null;
|
||||
|
||||
// what was set stays to the byte while its megabytes are left as they were shown
|
||||
static long? Bytes(long? megabytes, long? before) =>
|
||||
megabytes is not { } value ? null : before is { } kept && Megabytes(kept) == value ? kept : value * Megabyte;
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
errors = [];
|
||||
var media = settings.Media;
|
||||
if (quotaMb is < 0)
|
||||
errors.Add(Localizer["The quota can't be below 0."]);
|
||||
if (imageMb is { } image && (image < 1 || image * Megabyte > media.HardMaxImageBytes))
|
||||
errors.Add(Localizer["A picture's limit goes from 1 MB to {0}.", media.HardMaxImageBytes.GetFileSize(Localizer)]);
|
||||
if (videoMb is { } video && (video < 1 || video * Megabyte > media.HardMaxVideoBytes))
|
||||
errors.Add(Localizer["A video's limit goes from 1 MB to {0}.", media.HardMaxVideoBytes.GetFileSize(Localizer)]);
|
||||
if (seconds is { } length && (length < 1 || length > media.HardMaxSeconds))
|
||||
errors.Add(Localizer["A length goes from 1 second to {0}.", Length(media.HardMaxSeconds)]);
|
||||
if (errors.Count > 0)
|
||||
return;
|
||||
|
||||
// only the limits changed here: what another administrator saved meanwhile to the others stays
|
||||
var quotaChanged = quotaMb != Megabytes(media.QuotaBytesPerRoot);
|
||||
var imageChanged = imageMb != Megabytes(media.MaxImageBytes);
|
||||
var videoChanged = videoMb != Megabytes(media.MaxVideoBytes);
|
||||
var secondsChanged = seconds != media.MaxSeconds;
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.SaveSettings(latest => latest.Media = new ServerMediaLimitsForm
|
||||
{
|
||||
QuotaBytesPerRoot = quotaChanged ? Bytes(quotaMb, media.QuotaBytesPerRoot) : latest.Media.QuotaBytesPerRoot,
|
||||
MaxImageBytes = imageChanged ? Bytes(imageMb, media.MaxImageBytes) : latest.Media.MaxImageBytes,
|
||||
MaxVideoBytes = videoChanged ? Bytes(videoMb, media.MaxVideoBytes) : latest.Media.MaxVideoBytes,
|
||||
MaxSeconds = secondsChanged ? seconds : latest.Media.MaxSeconds
|
||||
});
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
settings = (ViewServerSettings)result.Data;
|
||||
Reset();
|
||||
Toast.AddResponseSuccess(Localizer["The media limits are saved."]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The staff's log (/clientapi/moderator/log), newest first, 50 at a time: who acted (to administrators every login's
|
||||
name; to a moderator only its own; "the command line" for PrivaPub's CLI; nobody when an act on a persona of the
|
||||
acting login would tie the two), what was done, to what, and its note. Never an IP address. A moderator who isn't an
|
||||
administrator reads what isn't about a login. *@
|
||||
<div class="flex flex-col gap-2 w-full" data-log>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-log-reload>
|
||||
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (failed)
|
||||
{
|
||||
<p class="text-sm text-error">@Localizer["The log could not be read."]</p>
|
||||
}
|
||||
else if (entries.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-log-empty>@Localizer["Nothing was done yet."]</p>
|
||||
}
|
||||
|
||||
<ol class="flex flex-col gap-2">
|
||||
@foreach (var entry in entries)
|
||||
{
|
||||
<li class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-log-entry="@entry.Id" data-log-action="@entry.Action">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<b class="text-sm">@ActionName(entry.Action)</b>
|
||||
@if (entry.TargetLabel is { Length: > 0 } label)
|
||||
{
|
||||
<span class="text-sm break-all min-w-0" title="@($"{entry.TargetKind} {entry.TargetId}")" data-log-target>@label</span>
|
||||
}
|
||||
<span class="flex-1"></span>
|
||||
<span class="text-xs opacity-70" title="@entry.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@entry.At.GetPassedTime(Localizer)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span data-log-by="@(entry.ByYou ? "you" : entry.By ?? "none")">@By(entry)</span>
|
||||
<code class="opacity-60">@entry.Action</code>
|
||||
@if (entry.ReportId is { Length: > 0 } reportId)
|
||||
{
|
||||
<span class="opacity-70">@Localizer["Report: {0}", reportId]</span>
|
||||
}
|
||||
</div>
|
||||
@if (entry.Note is { Length: > 0 } note)
|
||||
{
|
||||
<p class="text-xs opacity-80 break-words" data-log-note>@note</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (next is not null)
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-log-more>
|
||||
@if (isLoadingMore)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Load more"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
const int PageSize = 50;
|
||||
|
||||
[Inject] AdminService AdminService { get; set; }
|
||||
|
||||
List<ViewAdminAction> entries = [];
|
||||
// the next page's `before`; null on the last
|
||||
string next;
|
||||
bool failed;
|
||||
bool isLoadingMore;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
var page = await AdminService.Log(null, PageSize);
|
||||
failed = page is null;
|
||||
entries = page?.Items ?? [];
|
||||
next = page?.Next;
|
||||
}
|
||||
|
||||
async Task Reload()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task More()
|
||||
{
|
||||
isLoadingMore = true;
|
||||
var page = await AdminService.Log(next, PageSize);
|
||||
isLoadingMore = false;
|
||||
if (page is null)
|
||||
{
|
||||
Toast.AddResponseError(Localizer["The log could not be read."]);
|
||||
return;
|
||||
}
|
||||
|
||||
var known = entries.Select(e => e.Id).ToHashSet();
|
||||
entries.AddRange(page.Items.Where(e => known.Add(e.Id)));
|
||||
next = page.Next;
|
||||
}
|
||||
|
||||
string By(ViewAdminAction entry) => entry switch
|
||||
{
|
||||
{ ByYou: true } => Localizer["by you"],
|
||||
{ By: "cli" } => Localizer["by the command line"],
|
||||
{ By: { Length: > 0 } name } => Localizer["by {0}", name],
|
||||
_ => Localizer["by someone not named"]
|
||||
};
|
||||
|
||||
string ActionName(string action) => action switch
|
||||
{
|
||||
"domain_block.insert" => Localizer["Server blocked"],
|
||||
"domain_block.delete" => Localizer["Server unblocked"],
|
||||
"backup.create" => Localizer["Backup made"],
|
||||
"backup.delete" => Localizer["Backup deleted"],
|
||||
"backup.restore" => Localizer["Backup restored"],
|
||||
"settings.update" => Localizer["Settings saved"],
|
||||
"settings.thumbnail" => Localizer["Thumbnail changed"],
|
||||
"rule.insert" => Localizer["Rule added"],
|
||||
"rule.update" => Localizer["Rule changed"],
|
||||
"rule.delete" => Localizer["Rule removed"],
|
||||
"announcement.insert" => Localizer["Announcement written"],
|
||||
"announcement.update" => Localizer["Announcement changed"],
|
||||
"announcement.delete" => Localizer["Announcement deleted"],
|
||||
"relay.insert" => Localizer["Relay added"],
|
||||
"relay.delete" => Localizer["Relay removed"],
|
||||
"report.resolve" => Localizer["Report resolved"],
|
||||
"root.promote" => Localizer["Login made administrator"],
|
||||
"root.demote" => Localizer["Login no longer administrator"],
|
||||
"root.create" => Localizer["Login made"],
|
||||
"root.delete" => Localizer["Login deleted"],
|
||||
"root.reset_totp" => Localizer["Two-factor sign-in reset"],
|
||||
"persona.move" => Localizer["Persona moved to another login"],
|
||||
_ => action
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The relays the server's actor subscribes to (/clientapi/admin/relays): the configuration's (Federation:Relays, removed
|
||||
only there) and the ones added here, each with its subscription's state. PrivaPub answers an addition or a removal at
|
||||
once (202) and subscribes or unsubscribes in the background, since reaching a relay takes a fetch, so the panel reads
|
||||
the list again a few seconds later. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-relays>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DInput TValue="string" class="flex-1 min-w-48" Type="url" @bind-Value="address" Immediate="true" autocomplete="off" maxlength="2048"
|
||||
placeholder="@Localizer["https://relay.example/actor"]" aria-label="@Localizer["The relay's address"]" data-relay-address>
|
||||
<Prefix><i class="ion-md-radio" aria-hidden="true"></i></Prefix>
|
||||
</DInput>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(address)" OnClick="Add" data-relay-add>
|
||||
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add the relay"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here."]</p>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (relays.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-relays-empty>@Localizer["The server subscribes to no relay."]</p>
|
||||
}
|
||||
|
||||
@foreach (var relay in relays)
|
||||
{
|
||||
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-relay="@relay.Address" data-relay-state="@relay.State"
|
||||
data-relay-source="@relay.Source">
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<b class="flex-1 min-w-0 break-all text-sm">@relay.Address</b>
|
||||
@switch (relay.State)
|
||||
{
|
||||
case "accepted":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Subscribed"]</DBadge>
|
||||
break;
|
||||
case "pending":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Waiting for the relay"]</DBadge>
|
||||
break;
|
||||
case "rejected":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Refused by the relay"]</DBadge>
|
||||
break;
|
||||
default:
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Not asked yet"]</DBadge>
|
||||
break;
|
||||
}
|
||||
@if (relay.Source == "configuration")
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" title="@Localizer["Named by the configuration (Federation:Relays): removed only there."]">
|
||||
@Localizer["Configured"]
|
||||
</DBadge>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(relay)" data-relay-remove>
|
||||
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
@if (relay.ActorURI is { Length: > 0 } actor && actor != relay.Address)
|
||||
{
|
||||
<p class="text-xs opacity-70 break-all">@Localizer["Its actor: {0}", actor]</p>
|
||||
}
|
||||
<p class="text-xs opacity-70">
|
||||
@if (relay.RequestedAt is { } requested)
|
||||
{
|
||||
<span title="@requested.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Asked {0} ago", requested.GetPassedTime(Localizer)]</span>
|
||||
}
|
||||
@if (relay.AnsweredAt is { } answered)
|
||||
{
|
||||
<span title="@answered.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"> · @Localizer["answered {0} ago", answered.GetPassedTime(Localizer)]</span>
|
||||
}
|
||||
</p>
|
||||
@if (relay.Source == "configuration")
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Named by the configuration (Federation:Relays): removed only there."]</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// when the list is read again after a change: subscribing to a relay is a fetch, then its answer
|
||||
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
|
||||
|
||||
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||
|
||||
List<ViewRelay> relays = [];
|
||||
string address;
|
||||
bool isSaving;
|
||||
CancellationTokenSource rereading;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => relays = await InstanceAdminService.Relays();
|
||||
|
||||
async Task Add()
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.AddRelay(address);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
relays = (List<ViewRelay>)result.Data;
|
||||
address = null;
|
||||
Toast.AddResponseSuccess(Localizer["The relay is added: the server subscribes to it now."]);
|
||||
await ReadAgainSoon();
|
||||
}
|
||||
|
||||
async Task Remove(ViewRelay relay)
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.RemoveRelay(relay.Address);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
relays = (List<ViewRelay>)result.Data;
|
||||
Toast.AddResponseSuccess(Localizer["The relay is removed: the server unsubscribes from it now."]);
|
||||
await ReadAgainSoon();
|
||||
}
|
||||
|
||||
// the background subscription answers later: the list is read again, the last change's timer replacing the one before
|
||||
async Task ReadAgainSoon()
|
||||
{
|
||||
if (rereading is not null)
|
||||
{
|
||||
await rereading.CancelAsync();
|
||||
rereading.Dispose();
|
||||
}
|
||||
rereading = new();
|
||||
_ = ReadAgain(rereading.Token);
|
||||
}
|
||||
|
||||
async Task ReadAgain(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var pause in Rereads)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(pause, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var read = await InstanceAdminService.Relays(cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
// an empty answer to a list that had relays is a failed read: a change here showed its own list at once
|
||||
if (read.Count == 0 && relays.Count > 0)
|
||||
continue;
|
||||
relays = read;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (rereading is not null)
|
||||
{
|
||||
await rereading.CancelAsync();
|
||||
rereading.Dispose();
|
||||
}
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The server's rules (/clientapi/admin/settings): written, explained, put in order and removed here, then saved together.
|
||||
A rule keeps its id while it is listed, so the reports that name it keep their meaning; one removed is kept, greyed
|
||||
below, as old reports name it, and can come back. Saving starts from the settings PrivaPub has now
|
||||
(InstanceAdminService.SaveSettings), so what Instance or Media limits saved stays. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-rules>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (settings is null)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Everyone reads them on the About page, and a report of a violation names the ones broken."]</p>
|
||||
@if (rows.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-rules-empty>@Localizer["The server has no rules."]</p>
|
||||
}
|
||||
<ol class="flex flex-col gap-2">
|
||||
@foreach (var (row, position) in Numbered)
|
||||
{
|
||||
<li @key="row" class="flex flex-col gap-2 p-2 rounded-lg neo-raised neo-2xs" data-rule="@position" data-rule-id="@(row.Id ?? "new")">
|
||||
<div class="flex items-start gap-2 pointer-coarse:gap-2">
|
||||
<span class="mt-2 w-6 flex-none text-right text-sm font-semibold tabular-nums">@(position + 1).</span>
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<DTextarea TValue="string" class="w-full" @bind-Value="row.Text" Immediate="true" rows="2" autocomplete="off" maxlength="500"
|
||||
placeholder="@Localizer["The rule"]" aria-label="@Localizer["The rule"]" data-rule-text />
|
||||
<DInput TValue="string" class="w-full" Size="DSize.Sm" @bind-Value="row.Hint" Immediate="true" autocomplete="off" maxlength="1000"
|
||||
placeholder="@Localizer["What it means, if it needs saying (optional)"]" aria-label="@Localizer["Explanation"]" data-rule-hint />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pointer-coarse:gap-2 flex-none">
|
||||
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == 0" OnClick="() => Move(position, -1)"
|
||||
aria-label="@Localizer["Move up"]" title="@Localizer["Move up"]" data-rule-up>
|
||||
<i class="ion-md-arrow-up" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == rows.Count - 1" OnClick="() => Move(position, 1)"
|
||||
aria-label="@Localizer["Move down"]" title="@Localizer["Move down"]" data-rule-down>
|
||||
<i class="ion-md-arrow-down" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(row)"
|
||||
aria-label="@Localizer["Remove the rule"]" title="@Localizer["Remove the rule"]" data-rule-delete>
|
||||
<i class="ion-md-trash text-error" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="Add" data-rule-add>
|
||||
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add a rule"]</span>
|
||||
</DButton>
|
||||
<span class="flex-1"></span>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-rule-reset>@Localizer["Discard changes"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || rows.Any(r => string.IsNullOrWhiteSpace(r.Text))" OnClick="Save"
|
||||
data-rule-save>
|
||||
@if (isSaving)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Save the rules"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
@if (rows.Count >= ServerSettingsForm.MaxRules)
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["A server has {0} rules at most.", ServerSettingsForm.MaxRules]</p>
|
||||
}
|
||||
|
||||
@if (Removed.Count > 0)
|
||||
{
|
||||
<div class="flex flex-col gap-2 mt-2" data-rules-deleted>
|
||||
<b class="text-sm">@Localizer["Removed rules, kept as old reports name them"]</b>
|
||||
@foreach (var rule in Removed)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-inset neo-2xs opacity-60" data-rule-deleted="@rule.Id">
|
||||
<span class="flex flex-col flex-1 min-w-0">
|
||||
<s class="text-sm break-words">@rule.Text</s>
|
||||
@if (rule.DeletedAt is { } deletedAt)
|
||||
{
|
||||
<span class="text-xs" title="@deletedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Removed {0} ago", deletedAt.GetPassedTime(Localizer)]</span>
|
||||
}
|
||||
</span>
|
||||
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="() => Restore(rule)"
|
||||
data-rule-restore>
|
||||
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Bring it back"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] InstanceAdminService InstanceAdminService { get; set; }
|
||||
|
||||
ViewServerSettings settings;
|
||||
// the rules as being written, in their order: a listed rule keeps its id, a new one has none
|
||||
List<ServerRuleForm> rows = [];
|
||||
bool isSaving;
|
||||
|
||||
IEnumerable<(ServerRuleForm Row, int Position)> Numbered => rows.Select((row, position) => (row, position));
|
||||
|
||||
// the rules the server keeps deleted, unless they are listed again above
|
||||
List<ViewServerRule> Removed => [.. (settings?.Rules ?? []).Where(r => r.IsDeleted && rows.All(row => row.Id != r.Id))];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
settings = await InstanceAdminService.Settings();
|
||||
Reset();
|
||||
}
|
||||
|
||||
async Task Retry()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Reset() => rows = settings is null ? [] : InstanceAdminService.FormOf(settings).Rules;
|
||||
|
||||
void Add() => rows.Add(new ServerRuleForm());
|
||||
|
||||
// gone from the list, it is removed when the rules are saved (kept, marked, for the reports naming it)
|
||||
void Remove(ServerRuleForm row) => rows.Remove(row);
|
||||
|
||||
void Move(int position, int by)
|
||||
{
|
||||
var row = rows[position];
|
||||
rows.RemoveAt(position);
|
||||
rows.Insert(position + by, row);
|
||||
}
|
||||
|
||||
// listed again with its id, it is the same rule: the reports naming it name it still
|
||||
void Restore(ViewServerRule rule) => rows.Add(new ServerRuleForm { Id = rule.Id, Text = rule.Text, Hint = rule.Hint });
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
var rules = rows.Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text?.Trim(), Hint = string.IsNullOrWhiteSpace(r.Hint) ? null : r.Hint.Trim() }).ToList();
|
||||
isSaving = true;
|
||||
var result = await InstanceAdminService.SaveSettings(latest => latest.Rules = rules);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
settings = (ViewServerSettings)result.Data;
|
||||
Reset();
|
||||
Toast.AddResponseSuccess(Localizer["The rules are saved."]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* 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;
|
||||
|
||||
@@ -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">
|
||||
@@ -37,7 +40,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview>
|
||||
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
|
||||
<DStat>
|
||||
<Heading>@Localizer["Deliveries that arrived"]</Heading>
|
||||
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+249
-62
@@ -5,8 +5,10 @@
|
||||
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
||||
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
|
||||
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
|
||||
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words
|
||||
and warning change, its media, visibility and poll stay). *@
|
||||
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
|
||||
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
|
||||
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
|
||||
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
|
||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@@ -17,18 +19,19 @@
|
||||
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
||||
</DInput>
|
||||
}
|
||||
<div class="relative">
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
<div class="flex flex-col gap-1">
|
||||
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||
placeholder="@Placeholder" rows="3" data-compose />
|
||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
@* under the words, not over their last line *@
|
||||
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@* alt="": the handle is next to it. *@
|
||||
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="truncate">@("@" + account.Acct)</span>
|
||||
@@ -36,7 +39,7 @@
|
||||
}
|
||||
@foreach (var tag in suggestedTags)
|
||||
{
|
||||
<DButton Size="DSize.Xs" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -82,29 +85,62 @@
|
||||
|
||||
@if (form.Media.Count > 0)
|
||||
{
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2" data-compose-media>
|
||||
@foreach (var media in form.Media)
|
||||
{
|
||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
|
||||
@if (media.Preview is { Length: > 0 } preview)
|
||||
{
|
||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
||||
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
|
||||
}
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
||||
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||
<p class="inline-flex items-center gap-2 text-xs">
|
||||
@switch (media.State)
|
||||
{
|
||||
case UploadState.Uploading:
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
|
||||
break;
|
||||
case UploadState.Processing:
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
|
||||
break;
|
||||
case UploadState.Failed:
|
||||
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
|
||||
@if (media.File is not null)
|
||||
{
|
||||
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
|
||||
}
|
||||
break;
|
||||
default:
|
||||
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
|
||||
break;
|
||||
}
|
||||
</p>
|
||||
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
|
||||
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
||||
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||
</div>
|
||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]"
|
||||
data-upload-remove>
|
||||
<i class="ion-md-trash text-base text-error"></i>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (AskingAltText)
|
||||
{
|
||||
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
|
||||
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
|
||||
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
|
||||
</div>
|
||||
</DAlert>
|
||||
}
|
||||
}
|
||||
|
||||
@if (form.IsLocated)
|
||||
@@ -131,16 +167,18 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@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")"
|
||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
<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.Visibility.ToString()]]"
|
||||
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
|
||||
<Trigger>
|
||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></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" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
||||
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
||||
<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>
|
||||
@@ -162,12 +200,12 @@
|
||||
<i class="ion-md-warning text-base"></i>
|
||||
</DButton>
|
||||
|
||||
@if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll)
|
||||
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||
{
|
||||
@* A label styled as a button opens the hidden file input. *@
|
||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
||||
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||
<i class="ion-md-attach text-base"></i>
|
||||
@if (form.Media.Count != 0)
|
||||
{
|
||||
@@ -178,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>
|
||||
}
|
||||
@@ -233,6 +271,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] MediaService MediaService { get; set; }
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
@@ -293,6 +332,7 @@
|
||||
if (!typed.Success)
|
||||
return;
|
||||
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||
@@ -313,57 +353,106 @@
|
||||
|
||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||
|
||||
long MaximumFileSize => form.MediaType switch
|
||||
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
|
||||
MediaConfiguration limits = new();
|
||||
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
|
||||
bool askingAltText;
|
||||
bool altTextConfirmed;
|
||||
|
||||
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
|
||||
|
||||
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
|
||||
|
||||
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
|
||||
|
||||
bool OfKind(string type) => form.MediaType switch
|
||||
{
|
||||
MediaType.Images => 16_777_216,
|
||||
MediaType.Audio => 41_943_040,
|
||||
_ => 99_614_720
|
||||
MediaType.Images => type.StartsWith("image/"),
|
||||
MediaType.Video => type.StartsWith("video/"),
|
||||
_ => type.StartsWith("audio/")
|
||||
};
|
||||
|
||||
string AcceptedFileTypes => form.MediaType switch
|
||||
// the server's types of the kind chosen, and the extensions a file picker may know them by
|
||||
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
|
||||
|
||||
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
||||
_ => default
|
||||
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
|
||||
};
|
||||
|
||||
// what a file is: the browser's word, else its extension's
|
||||
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
|
||||
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
|
||||
|
||||
// a new post starts in the language the persona chose in Settings, else in the reader's
|
||||
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||
|
||||
// a new post starts with the visibility the persona chose in Settings
|
||||
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;
|
||||
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||
bool focusPending = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
return;
|
||||
focusPending = false;
|
||||
await textarea.Element.FocusAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
form.GroupId = GroupId;
|
||||
form.MessageType = DefaultVisibility;
|
||||
form.Visibility = DefaultVisibility;
|
||||
form.Language = DefaultLanguage;
|
||||
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||
form.QuotedStatusId = Quoting?.Id;
|
||||
limits = await MediaService.Limits();
|
||||
if (Editing is not null)
|
||||
{
|
||||
var source = await StatusService.Source(Editing.Id);
|
||||
form.Content = source?.Text;
|
||||
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||
form.SpoilerText = source?.SpoilerText;
|
||||
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
|
||||
form.Language = Editing.Language ?? form.Language;
|
||||
form.Sensitive = Editing.Sensitive;
|
||||
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
|
||||
{
|
||||
Id = m.Id,
|
||||
State = UploadState.Ready,
|
||||
Posted = true,
|
||||
AltText = m.Description,
|
||||
SentAltText = m.Description,
|
||||
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
|
||||
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
|
||||
FileName = Path.GetFileName(m.Url ?? string.Empty)
|
||||
})];
|
||||
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
|
||||
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
|
||||
}
|
||||
IsLoading = false;
|
||||
if (InReplyTo is null)
|
||||
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;
|
||||
@@ -374,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;
|
||||
}
|
||||
|
||||
@@ -417,40 +506,126 @@
|
||||
|
||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||
{
|
||||
if (file.Size > MaximumFileSize)
|
||||
var type = TypeOf(file);
|
||||
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
|
||||
{
|
||||
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||
continue;
|
||||
}
|
||||
if (file.Size > MaximumFileSize(type))
|
||||
{
|
||||
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await ReadFile(file) is { } media)
|
||||
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
|
||||
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
|
||||
form.Media.Add(media);
|
||||
else
|
||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||
altTextConfirmed = false;
|
||||
Start(media);
|
||||
if (type.StartsWith("image/"))
|
||||
media.Preview = await Thumbnail(file);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
||||
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||
async Task<string> Thumbnail(IBrowserFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||
if (file.ContentType.StartsWith("image"))
|
||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
||||
return media;
|
||||
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer);
|
||||
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
||||
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
void Start(UploadMedia media)
|
||||
{
|
||||
media.Cancel = new();
|
||||
media.State = UploadState.Uploading;
|
||||
media.Error = default;
|
||||
media.Done = Upload(media, media.Cancel.Token);
|
||||
}
|
||||
|
||||
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
|
||||
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
|
||||
{
|
||||
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
if (uploaded.IsInvalid)
|
||||
{
|
||||
media.State = UploadState.Failed;
|
||||
media.Error = uploaded.ErrorMessage;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
var attachment = (MediaAttachment)uploaded.Data;
|
||||
media.Id = attachment.Id;
|
||||
media.SentAltText = attachment.Description;
|
||||
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(2000, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var processed = await MediaService.Get(media.Id, cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
if (processed.IsInvalid)
|
||||
{
|
||||
media.State = UploadState.Failed;
|
||||
media.Error = processed.ErrorMessage;
|
||||
}
|
||||
else if (processed.Data is MediaAttachment)
|
||||
media.State = UploadState.Ready;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
||||
void Retry(UploadMedia media) => Start(media);
|
||||
|
||||
// a file taken out before posting goes from PrivaPub too (DELETE /api/v1/media/:id), unless a post holds it already: an
|
||||
// edit's own media stay its post's. A failure is only logged; the upload stays unattached on PrivaPub.
|
||||
async Task Remove(UploadMedia media)
|
||||
{
|
||||
media.Cancel?.Cancel();
|
||||
form.Media.Remove(media);
|
||||
if (media.Posted || media.Id is not { } mediaId)
|
||||
return;
|
||||
await MediaService.Discard(mediaId);
|
||||
}
|
||||
|
||||
// the reader chose to publish its pictures without descriptions
|
||||
async Task PublishAnyway()
|
||||
{
|
||||
altTextConfirmed = true;
|
||||
askingAltText = false;
|
||||
await Publish();
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var media in form.Media)
|
||||
media.Cancel?.Cancel();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task Publish()
|
||||
{
|
||||
error = default;
|
||||
@@ -464,6 +639,18 @@
|
||||
error = Localizer["A poll needs two choices at least."];
|
||||
return;
|
||||
}
|
||||
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
|
||||
{
|
||||
askingAltText = true;
|
||||
return;
|
||||
}
|
||||
// what is still uploading or processing is waited for
|
||||
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
|
||||
{
|
||||
isPublishing = true;
|
||||
await Task.WhenAll(form.Media.Select(m => m.Done));
|
||||
isPublishing = false;
|
||||
}
|
||||
if (Editing is not null)
|
||||
{
|
||||
isPublishing = true;
|
||||
@@ -507,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
|
||||
dialog in the middle from md. Closing it in any way answers no. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
|
||||
<Heading>@Confirm.Heading</Heading>
|
||||
<ChildContent>
|
||||
@if (Confirm.Text is { Length: > 0 } text)
|
||||
{
|
||||
<p class="text-sm">@text</p>
|
||||
}
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
|
||||
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
|
||||
@Confirm.ConfirmText
|
||||
</DButton>
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Confirm.Changed += Refresh;
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Refresh() => InvokeAsync(StateHasChanged);
|
||||
|
||||
void Closed(bool open)
|
||||
{
|
||||
if (!open)
|
||||
Confirm.Answer(false);
|
||||
}
|
||||
|
||||
public void Dispose() => Confirm.Changed -= Refresh;
|
||||
}
|
||||
+312
-71
@@ -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 actions: PrivaPub keeps them off the Mastodon API. 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"
|
||||
@@ -17,11 +20,11 @@
|
||||
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
|
||||
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
||||
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))"
|
||||
data-uri="@(Compact ? null : Post.Status?.Uri)">
|
||||
data-uri="@(Compact ? null : Post.Status?.Uri)" data-thread="@(IncludeExpand && !Compact && Status is not null && !Post.Located ? "" : null)">
|
||||
|
||||
@if (Author is { } author)
|
||||
{
|
||||
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
<div class="hidden md:flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
style="@HeaderBackground(author)">
|
||||
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
|
||||
@* alt="": the author's name is next to it. *@
|
||||
@@ -31,7 +34,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
|
||||
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 max-md:pl-3 md:py-4 md:pr-4 min-w-0">
|
||||
|
||||
@if (Post.Notification is { } notification)
|
||||
{
|
||||
@@ -49,13 +52,20 @@
|
||||
}
|
||||
<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)
|
||||
{
|
||||
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
|
||||
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
|
||||
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
|
||||
<a class="font-bold truncate pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(booster.Id)}")" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
|
||||
<span>@Localizer["boosted"]</span>
|
||||
</p>
|
||||
}
|
||||
@@ -79,11 +89,19 @@
|
||||
|
||||
@if (Status is { } status)
|
||||
{
|
||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
||||
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-sm">
|
||||
@if (Author is { } phoneAuthor)
|
||||
{
|
||||
<a class="md:hidden flex-none self-center grid place-items-center h-11 w-11" href="@($"account/{Uri.EscapeDataString(phoneAuthor.Id)}")"
|
||||
title="@phoneAuthor.Acct">
|
||||
@* alt="": the author's name is next to it. *@
|
||||
<img alt="" loading="lazy" class="h-10 w-10 object-cover rounded-full neo-raised neo-2xs bg-base-100" src="@(phoneAuthor.Avatar ?? "imgs/icon-192.png")" />
|
||||
</a>
|
||||
}
|
||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1 max-md:self-center">
|
||||
<span class="inline-flex max-md:flex-col md:gap-2 min-w-0 max-w-full">
|
||||
<b class="shrink truncate" title="@status.Account?.Name">@CustomEmojis.Text(status.Account?.Name, status.Account?.Emojis ?? [])</b>
|
||||
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||
<span class="flex-1 min-w-6 opacity-50 truncate md:self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||
</span>
|
||||
</NerdTip>
|
||||
<span class="flex-none inline-flex gap-2 items-center">
|
||||
@@ -91,7 +109,7 @@
|
||||
{
|
||||
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
|
||||
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
|
||||
<i class="ion-md-cloud" aria-hidden="true"></i>
|
||||
<span class="truncate">@fronted.Label</span>
|
||||
</span>
|
||||
@@ -100,7 +118,7 @@
|
||||
else if (Post.Location is { } location)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
|
||||
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
|
||||
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
|
||||
</span>
|
||||
@@ -112,7 +130,7 @@
|
||||
</NerdTip>
|
||||
@if (status.EditedAt is not null)
|
||||
{
|
||||
<button type="button" class="link link-hover opacity-60" data-history title="@Localizer["Edit history"]"
|
||||
<button type="button" class="link link-hover opacity-60 touch-target" data-history title="@Localizer["Edit history"]"
|
||||
@onclick="() => OpenHistory(status)">@Localizer["edited"]</button>
|
||||
}
|
||||
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
|
||||
@@ -123,7 +141,7 @@
|
||||
{
|
||||
<p class="inline-flex items-center gap-2 min-w-0 text-xs opacity-80" data-wall="@wall.Acct">
|
||||
<i class="ion-md-clipboard" aria-hidden="true"></i>
|
||||
<a class="truncate" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
|
||||
<a class="truncate pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
|
||||
@Localizer["On the wall of {0}", wall.Name]
|
||||
</a>
|
||||
</p>
|
||||
@@ -182,7 +200,7 @@
|
||||
@* the reactions stand outside the content warning: they are not what it hides *@
|
||||
@if (status.EmojiReactions.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-1" data-reactions>
|
||||
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2" data-reactions>
|
||||
@foreach (var reaction in status.EmojiReactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
||||
@@ -203,10 +221,20 @@
|
||||
|
||||
@if (Post.Located)
|
||||
{
|
||||
<p class="inline-flex gap-2 items-center text-xs opacity-70">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<p class="inline-flex gap-2 items-center opacity-70">
|
||||
<i class="ion-md-locate"></i>
|
||||
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
|
||||
</p>
|
||||
@* its author takes it back here: it has no other action *@
|
||||
@if (IsOwn && !Compact)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="ml-auto" Disabled="IsDefaultDisabled || isActing" OnClick="Delete" data-located-delete>
|
||||
<i aria-hidden="true" class="ion-md-trash text-error"></i>
|
||||
<span>@Localizer["Delete"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (status.Privapub?.Approval is "pending" or "rejected")
|
||||
@@ -220,59 +248,78 @@
|
||||
|
||||
@if (!Post.Located && !Compact)
|
||||
{
|
||||
<footer class="flex gap-2 mt-1 justify-between">
|
||||
<div class="flex gap-2">
|
||||
@* on a phone the actions are five equal cells (Reply, Boost, Favourite, React, More); Bookmark and the thread are in
|
||||
More, and the downvotes, the counts and Expand show from md *@
|
||||
<footer class="grid grid-cols-5 gap-1 mt-1 md:flex md:gap-2 md:justify-between">
|
||||
<div class="contents md:flex md:gap-2">
|
||||
@if (status.Privapub?.Locked == true)
|
||||
{
|
||||
@* its community's moderators locked the thread: no reply is taken *@
|
||||
<span class="btn btn-sm btn-disabled" title="@Localizer["Locked by its community's moderators"]" data-locked>
|
||||
<span class="btn btn-sm btn-disabled max-md:w-full" title="@Localizer["Locked by its community's moderators"]" data-locked>
|
||||
<i aria-hidden="true" class="ion-md-lock text-lg"></i>
|
||||
@if (status.RepliesCount > 0)
|
||||
{
|
||||
<span>@status.RepliesCount</span>
|
||||
<span class="md:hidden">@status.RepliesCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.RepliesCount</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
|
||||
@if (status.RepliesCount > 0)
|
||||
{
|
||||
<span>@status.RepliesCount</span>
|
||||
<span class="md:hidden">@status.RepliesCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.RepliesCount</span>
|
||||
}
|
||||
</DButton>
|
||||
}
|
||||
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
|
||||
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
|
||||
@if (status.ReblogsCount > 0)
|
||||
{
|
||||
<span>@status.ReblogsCount</span>
|
||||
<span class="md:hidden">@status.ReblogsCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.ReblogsCount</span>
|
||||
}
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
|
||||
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
|
||||
@if (status.FavouritesCount > 0)
|
||||
{
|
||||
<span>@status.FavouritesCount</span>
|
||||
<span class="md:hidden">@status.FavouritesCount.Compact()</span>
|
||||
<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)
|
||||
{
|
||||
<span class="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
|
||||
<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>@ownVotes.Down
|
||||
</span>
|
||||
}
|
||||
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
</DButton>
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
|
||||
title="@Localizer["React"]">
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
@@ -281,20 +328,52 @@
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
<span class="hidden md:contents">
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
</NerdTip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="contents md:flex md:gap-2">
|
||||
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
|
||||
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
|
||||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||||
SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
|
||||
<Trigger>
|
||||
<i aria-hidden="true" class="ion-md-more text-lg"></i>
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@if (IncludeExpand)
|
||||
{
|
||||
<NavLink class="btn btn-sm justify-start whitespace-nowrap md:hidden" href="@($"expand/{Uri.EscapeDataString(status.Id)}")"
|
||||
@onclick="OpenConversation" data-thread-link>
|
||||
<i aria-hidden="true" class="ion-md-expand text-base"></i>
|
||||
<span>@Localizer["Thread"]</span>
|
||||
</NavLink>
|
||||
}
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" data-bookmark-phone>
|
||||
<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>
|
||||
</DButton>
|
||||
@if (status.Url is { Length: > 0 } url)
|
||||
{
|
||||
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
|
||||
@@ -332,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>
|
||||
@@ -354,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>
|
||||
@@ -368,7 +463,7 @@
|
||||
</DDropdown>
|
||||
@if (IncludeExpand)
|
||||
{
|
||||
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
|
||||
<NavLink class="btn btn-sm hidden md:inline-flex" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
|
||||
@onclick="OpenConversation">
|
||||
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
||||
</NavLink>
|
||||
@@ -376,17 +471,38 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@if (isReplying)
|
||||
{
|
||||
@* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
|
||||
<PhoneSheet IsOpen="isReplying" OnClose="() => isReplying = false" Heading="@Localizer["Reply to {0}", "@" + status.Account?.Acct]">
|
||||
<Composer InReplyTo="status" OnPublished="Replied" />
|
||||
}
|
||||
@if (isQuoting)
|
||||
{
|
||||
</PhoneSheet>
|
||||
<PhoneSheet IsOpen="isQuoting" OnClose="() => isQuoting = false" Heading="@Localizer["Quote"]">
|
||||
<Composer Quoting="status" OnPublished="Quoted" />
|
||||
}
|
||||
@if (isEditing)
|
||||
{
|
||||
</PhoneSheet>
|
||||
<PhoneSheet IsOpen="isEditing" OnClose="() => isEditing = false" Heading="@Localizer["Edit"]">
|
||||
<Composer Editing="status" OnPublished="Edited" />
|
||||
</PhoneSheet>
|
||||
@* a phone's way to the nerd stats the card's corners hold on hover, all in one sheet *@
|
||||
@if (isDetailsOpen)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isDetailsOpen = open" CloseText="@Localizer["Close"]"
|
||||
data-details-sheet>
|
||||
<Heading>@Localizer["Details"]</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
<NerdRows Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" />
|
||||
<NerdRows Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" />
|
||||
@if (Post.Location is { Source: LocationSource.Cdn } detailsCdn)
|
||||
{
|
||||
<NerdRows Heading="@Localizer["The server is behind {0}, so its place is unknown.", detailsCdn.Label]" Rows="CdnRows()" Pending="isReadingServer" />
|
||||
}
|
||||
else if (Post.Location is not null)
|
||||
{
|
||||
<NerdRows Heading="@Localizer["Place"]" Rows="LocationRows()" />
|
||||
}
|
||||
<NerdRows Heading="@Localizer["Counts"]" Rows="CountRows(status)" />
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
@if (isReporting && status.Account is { } reported)
|
||||
{
|
||||
@@ -396,9 +512,17 @@
|
||||
{
|
||||
<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 IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||
<Heading>@Localizer["Edit history"]</Heading>
|
||||
<ChildContent>
|
||||
<ol class="flex flex-col gap-4 mt-3">
|
||||
@@ -449,12 +573,15 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] TimelineService TimelineService { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] ProvenanceService ProvenanceService { get; set; }
|
||||
|
||||
[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; }
|
||||
@@ -468,6 +595,8 @@
|
||||
[Parameter] public FilterContext? FilterIn { get; set; }
|
||||
|
||||
bool isReplying;
|
||||
bool isDetailsOpen;
|
||||
MediaAttachment viewingPicture;
|
||||
bool isQuoting;
|
||||
bool isEditing;
|
||||
bool isActing;
|
||||
@@ -479,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 = [];
|
||||
@@ -504,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;
|
||||
|
||||
@@ -552,12 +683,13 @@
|
||||
<div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
|
||||
@foreach (var media in status.MediaAttachments)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
|
||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
||||
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
||||
{
|
||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
||||
@onclick="() => revealedMedia.Add(media.Id)">
|
||||
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
|
||||
@* its blurhash shows behind it: the colours, nothing of what it shows *@
|
||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
|
||||
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
|
||||
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
@@ -569,6 +701,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (viewingPicture is { } picture)
|
||||
{
|
||||
<MediaViewer Pictures="Pictures(status)" Start="Pictures(status).IndexOf(picture)" OnClose="() => viewingPicture = null" />
|
||||
}
|
||||
|
||||
@if (status.Card is { } card && status.MediaAttachments.Count == 0)
|
||||
{
|
||||
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
|
||||
@@ -586,16 +723,16 @@
|
||||
|
||||
@if (status.Poll is { } poll)
|
||||
{
|
||||
<div class="flex flex-col gap-1 text-xs" data-poll>
|
||||
<div class="flex flex-col gap-1 pointer-coarse:gap-2 text-xs" data-poll>
|
||||
@if (CanVote(poll))
|
||||
{
|
||||
@for (var i = 0; i < poll.Options.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 cursor-pointer">
|
||||
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 pointer-coarse:min-h-11 cursor-pointer">
|
||||
<input type="@(poll.Multiple ? "checkbox" : "radio")" class="@(poll.Multiple ? "checkbox checkbox-xs" : "radio radio-xs")"
|
||||
name="@($"poll-{poll.Id}")" checked="@pollChoices.Contains(index)" @onchange="() => ToggleChoice(poll, index)" />
|
||||
<span>@poll.Options[index].Title</span>
|
||||
<span class="min-w-0 break-words">@poll.Options[index].Title</span>
|
||||
</label>
|
||||
}
|
||||
<DButton Size="DSize.Xs" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isActing || pollChoices.Count == 0)"
|
||||
@@ -634,7 +771,7 @@
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
|
||||
@foreach (var venue in happening.Places)
|
||||
{
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
|
||||
<span class="inline-flex gap-2 items-center min-w-0 break-all"><i class="ion-md-pin flex-none"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
|
||||
}
|
||||
@if (IsWebLink(happening.OnlineUrl))
|
||||
{
|
||||
@@ -694,7 +831,7 @@
|
||||
|
||||
@if (status.Tags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2">
|
||||
@foreach (var tag in status.Tags)
|
||||
{
|
||||
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
|
||||
@@ -703,21 +840,37 @@
|
||||
}
|
||||
</div>;
|
||||
|
||||
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
|
||||
// media that didn't load (gone from their server, or refused): said so, with the way to their original
|
||||
HashSet<string> unavailableMedia = [];
|
||||
|
||||
RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch
|
||||
{
|
||||
"image" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
||||
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
|
||||
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
|
||||
</a>,
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
|
||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
|
||||
</button>,
|
||||
// under reduced motion a gifv waits for a tap (its controls)
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
|
||||
playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
|
||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
|
||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></audio>,
|
||||
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
||||
</a>
|
||||
};
|
||||
|
||||
RenderFragment Unavailable(MediaAttachment media) =>
|
||||
@<div class="flex flex-col items-center justify-center gap-2 w-full aspect-video rounded-lg neo-inset neo-2xs text-sm" data-media-unavailable>
|
||||
<i class="ion-md-alert text-lg opacity-70" aria-hidden="true"></i>
|
||||
<span>@Localizer["Media unavailable"]</span>
|
||||
@if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original)
|
||||
{
|
||||
<a class="link text-xs pointer-coarse:py-3.5" href="@original" target="_blank" rel="noopener noreferrer">@Localizer["Open original"]</a>
|
||||
}
|
||||
</div>;
|
||||
|
||||
async Task Act(StatusAction action)
|
||||
{
|
||||
isActing = true;
|
||||
@@ -734,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; }
|
||||
|
||||
@@ -885,8 +1106,12 @@
|
||||
|
||||
async Task Delete()
|
||||
{
|
||||
// a located post never left PrivaPub: no server is asked
|
||||
if (!await Confirm.Ask(Localizer["Delete this post?"], Post.Located ? null : Localizer["It goes from here, and the servers that have it are asked to delete it too."],
|
||||
Localizer["Delete"], true))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
var result = Post.Located ? await StatusService.DeleteLocated(Status.Id, CascadingState.PersonaId) : await StatusService.Delete(Status.Id);
|
||||
isActing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
@@ -904,6 +1129,8 @@
|
||||
|
||||
async Task TakeOffWall()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Take this post off your wall?"], null, Localizer["Take off my wall"], true))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
isActing = false;
|
||||
@@ -919,6 +1146,10 @@
|
||||
|
||||
async Task AccountAction(string action)
|
||||
{
|
||||
var name = Status.Account?.Name ?? Status.Account?.Acct;
|
||||
if (!await Confirm.Ask(action == "block" ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
|
||||
Localizer[action == "block" ? "Block" : "Mute"], action == "block"))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.AccountAction(Status.Account.Id, action);
|
||||
isActing = false;
|
||||
@@ -970,6 +1201,16 @@
|
||||
await OnReplied.InvokeAsync(reply);
|
||||
}
|
||||
|
||||
static List<MediaAttachment> Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")];
|
||||
|
||||
void OpenPicture(MediaAttachment media) => viewingPicture = media;
|
||||
|
||||
async Task OpenDetails()
|
||||
{
|
||||
isDetailsOpen = true;
|
||||
await Task.WhenAll(ReadProvenance(), ReadServer());
|
||||
}
|
||||
|
||||
async Task ReadProvenance()
|
||||
{
|
||||
if (Status is null || Post.Located)
|
||||
@@ -1019,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,7 +3,8 @@
|
||||
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
||||
@if (Href is null)
|
||||
{
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
|
||||
@onmousedown:preventDefault="KeepFocus">
|
||||
@ChildContent
|
||||
</button>
|
||||
}
|
||||
@@ -27,6 +28,8 @@ else
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
[Parameter] public string Href { get; set; }
|
||||
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
||||
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
|
||||
[Parameter] public bool KeepFocus { get; set; }
|
||||
|
||||
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
||||
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
@inherits DComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||
too. With Hover the CSS hover rule opens it instead. *@
|
||||
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
|
||||
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
@onclick="Toggle">
|
||||
@Trigger
|
||||
</div>
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
||||
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
|
||||
aria-hidden="true" @onclick="Close"></div>
|
||||
}
|
||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
||||
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
|
||||
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +36,21 @@
|
||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; }
|
||||
[Parameter] public string ContentClass { get; set; }
|
||||
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||
|
||||
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
|
||||
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
|
||||
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
|
||||
|
||||
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||
|
||||
ElementReference trigger;
|
||||
ElementReference content;
|
||||
IDisposable overlay;
|
||||
bool focusContent;
|
||||
bool focusTrigger;
|
||||
|
||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||
@@ -50,17 +70,54 @@
|
||||
_ => null
|
||||
};
|
||||
|
||||
Task Toggle() => SetOpen(!IsOpen);
|
||||
protected override void OnParametersSet() => TrackOverlay();
|
||||
|
||||
Task Close() => SetOpen(false);
|
||||
|
||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen)
|
||||
void TrackOverlay()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||
{
|
||||
await SetOpen(false, true);
|
||||
StateHasChanged();
|
||||
}));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => overlay?.Dispose();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (focusContent)
|
||||
{
|
||||
focusContent = false;
|
||||
await content.FocusAsync();
|
||||
}
|
||||
if (focusTrigger)
|
||||
{
|
||||
focusTrigger = false;
|
||||
await trigger.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
Task Toggle() => SetOpen(!IsOpen, IsOpen);
|
||||
|
||||
Task Close() => SetOpen(false, true);
|
||||
|
||||
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
|
||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen, bool giveFocusBack)
|
||||
{
|
||||
focusContent = isOpen && !IsOpen;
|
||||
focusTrigger = !isOpen && IsOpen && giveFocusBack;
|
||||
IsOpen = isOpen;
|
||||
TrackOverlay();
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
@inherits DComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
||||
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
|
||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
|
||||
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
|
||||
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
||||
class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
||||
@if (Heading is not null || ShowClose)
|
||||
{
|
||||
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||
@if (Heading is not null)
|
||||
{
|
||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
||||
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="flex-1"></span>
|
||||
}
|
||||
@if (ShowClose)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
|
||||
@onclick="Close" data-modal-close>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@ChildContent
|
||||
@if (Actions is not null)
|
||||
@@ -35,11 +53,17 @@
|
||||
[Parameter] public DModalPlacement Placement { get; set; }
|
||||
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
||||
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
||||
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
|
||||
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
|
||||
[Parameter] public string CloseText { get; set; } = "close";
|
||||
/// <summary>A visible close button beside the heading.</summary>
|
||||
[Parameter] public bool ShowClose { get; set; } = true;
|
||||
[Parameter] public string BoxClass { get; set; }
|
||||
|
||||
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||
|
||||
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
||||
IDisposable overlay;
|
||||
ElementReference box;
|
||||
bool wasOpen;
|
||||
bool focusPending;
|
||||
@@ -52,6 +76,7 @@
|
||||
DModalPlacement.Bottom => "modal-bottom",
|
||||
DModalPlacement.Start => "modal-start",
|
||||
DModalPlacement.End => "modal-end",
|
||||
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -60,8 +85,26 @@
|
||||
if (IsOpen && !wasOpen)
|
||||
focusPending = true;
|
||||
wasOpen = IsOpen;
|
||||
TrackOverlay();
|
||||
}
|
||||
|
||||
void TrackOverlay()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||
{
|
||||
await SetOpen(false);
|
||||
StateHasChanged();
|
||||
}));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => overlay?.Dispose();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
@@ -78,6 +121,7 @@
|
||||
{
|
||||
IsOpen = isOpen;
|
||||
wasOpen = isOpen;
|
||||
TrackOverlay();
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,7 @@ public enum DModalPlacement
|
||||
Top,
|
||||
Bottom,
|
||||
Start,
|
||||
End
|
||||
End,
|
||||
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
|
||||
ResponsiveBottom
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using 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;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
||||
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96). *@
|
||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96); it is marked as a strip that scrolls sideways on
|
||||
purpose (data-scroll-x), which the phone tests allow. *@
|
||||
@if (Scroll)
|
||||
{
|
||||
<div class="@Cx("overflow-x-auto", WrapperClass)">
|
||||
<div class="@Cx("overflow-x-auto", WrapperClass)" data-scroll-x>
|
||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
||||
value is the content; rows, placeholder and the like pass through. *@
|
||||
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
||||
|
||||
@code {
|
||||
/// <summary>The textarea itself, for focusing it.</summary>
|
||||
public ElementReference Element { get; private set; }
|
||||
|
||||
[Parameter] public DColor Color { get; set; }
|
||||
[Parameter] public DSize Size { get; set; }
|
||||
[Parameter] public bool Ghost { get; set; }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace decePubClient.Components.Daisy;
|
||||
|
||||
/// <summary>
|
||||
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
|
||||
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
|
||||
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
|
||||
/// </summary>
|
||||
public interface IDOverlayHost
|
||||
{
|
||||
IDisposable Open(Func<Task> close);
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
|
||||
[Parameter] public string AccessibleName { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
var index = i;
|
||||
var tab = tabs[i];
|
||||
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
||||
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
|
||||
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
||||
@onclick="() => Select(index)">
|
||||
@tab.Header
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
|
||||
tabs wait; from md only the selected tab shows its name until xl *@
|
||||
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
|
||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||
aria-label="@Localizer["Feeds"]">
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<DTab Class="gap-1 whitespace-nowrap">
|
||||
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
|
||||
<Header>
|
||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
|
||||
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
|
||||
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
|
||||
{
|
||||
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
||||
@@ -30,10 +33,15 @@
|
||||
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
||||
</NerdTip>
|
||||
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||
<i class="ion-md-planet text-base"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||
@* a phone held sideways has no room for the banner: the globe opens full screen *@
|
||||
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
|
||||
data-globe-full>
|
||||
<i class="ion-md-planet text-base"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||
<i class="ion-md-create text-base"></i>
|
||||
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
||||
</DButton>
|
||||
@@ -107,6 +115,8 @@
|
||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||
[Parameter] public bool IsComposing { get; set; }
|
||||
[Parameter] public EventCallback OnCompose { get; set; }
|
||||
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
|
||||
[Parameter] public EventCallback OnGlobeFull { get; set; }
|
||||
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
||||
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
||||
|
||||
@@ -120,6 +130,17 @@
|
||||
|
||||
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
||||
|
||||
ElementReference strip;
|
||||
int revealed = -1;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (ActiveIndex == revealed)
|
||||
return;
|
||||
revealed = ActiveIndex;
|
||||
CascadingState.Status.RevealActive(strip);
|
||||
}
|
||||
|
||||
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
||||
|
||||
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||
|
||||
+121
-115
@@ -3,55 +3,58 @@
|
||||
|
||||
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
|
||||
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
|
||||
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
|
||||
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
|
||||
<div class="relative w-full h-full">
|
||||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||||
|
||||
@if (stats?.Sun is { } sun)
|
||||
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
|
||||
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
|
||||
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
|
||||
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
|
||||
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
|
||||
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
|
||||
brings the feed back. *@
|
||||
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
|
||||
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
|
||||
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
|
||||
<dl id="@($"{uid}-sun")" class="sr-only">
|
||||
@if (sun is not null)
|
||||
{
|
||||
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
|
||||
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
|
||||
<span class="flex items-center gap-1 font-semibold">
|
||||
<i class="@SunIcon(sun)"></i>
|
||||
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt>@row.Label</dt>
|
||||
<dd>@row.Value</dd>
|
||||
}
|
||||
}
|
||||
</dl>
|
||||
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
|
||||
|
||||
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||
@if (sun is not null)
|
||||
{
|
||||
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
|
||||
data-globe-moment>
|
||||
<i class="@SunIcon(sun)" aria-hidden="true"></i>
|
||||
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||
</span>
|
||||
@if (sun.Phase is { } phase)
|
||||
{
|
||||
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
|
||||
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
else if (error is { Length: > 0 })
|
||||
{
|
||||
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
|
||||
}
|
||||
</div>
|
||||
</NerdTip>
|
||||
</div>
|
||||
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
|
||||
}
|
||||
|
||||
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
||||
<span class="badge badge-ghost badge-xs gap-1">
|
||||
<i class="ion-md-planet"></i>
|
||||
@if (stats is { Error: null } live)
|
||||
@if (Expanded)
|
||||
{
|
||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
|
||||
}
|
||||
else if (stats?.Error is { Length: > 0 })
|
||||
{
|
||||
<span>@Localizer["The globe could not start."]</span>
|
||||
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
|
||||
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
|
||||
<i class="ion-md-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Localizer["loading"]…</span>
|
||||
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
|
||||
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
|
||||
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</NerdTip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -61,23 +64,85 @@
|
||||
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
||||
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
||||
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
||||
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
|
||||
[Parameter] public bool Expanded { get; set; }
|
||||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
|
||||
ElementReference closeButton;
|
||||
ElementReference expandButton;
|
||||
IDisposable overlay;
|
||||
bool focusClose;
|
||||
bool focusExpand;
|
||||
|
||||
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
|
||||
|
||||
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
|
||||
static readonly string[] InstrumentKeys =
|
||||
[
|
||||
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
|
||||
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
|
||||
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
|
||||
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
|
||||
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
|
||||
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
|
||||
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
|
||||
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
|
||||
];
|
||||
|
||||
async Task Open()
|
||||
{
|
||||
focusClose = true;
|
||||
await ExpandedChanged.InvokeAsync(true);
|
||||
}
|
||||
|
||||
async Task Close()
|
||||
{
|
||||
focusExpand = true;
|
||||
await ExpandedChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
|
||||
|
||||
ElementReference element;
|
||||
IJSObjectReference module;
|
||||
DotNetObjectReference<GlobePanel> reference;
|
||||
PeriodicTimer statsTimer;
|
||||
readonly CancellationTokenSource cancellation = new();
|
||||
int? sentFingerprint;
|
||||
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
|
||||
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
|
||||
bool postsHandedOver = true;
|
||||
GlobeStats stats;
|
||||
SunStats sun;
|
||||
string error;
|
||||
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
protected override void OnParametersSet() => postsHandedOver = true;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
postsHandedOver = true;
|
||||
// full screen, the Back button closes it
|
||||
if (Expanded && overlay is null)
|
||||
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||
else if (!Expanded && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (focusClose && Expanded)
|
||||
{
|
||||
focusClose = false;
|
||||
await closeButton.FocusAsync();
|
||||
}
|
||||
if (focusExpand && !Expanded)
|
||||
{
|
||||
focusExpand = false;
|
||||
await expandButton.FocusAsync(preventScroll: true);
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
try
|
||||
{
|
||||
@@ -85,10 +150,11 @@
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||||
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
||||
_ = ReadStats(cancellation.Token);
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
|
||||
{
|
||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
|
||||
});
|
||||
}
|
||||
if (postsHandedOver)
|
||||
{
|
||||
@@ -148,7 +214,8 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||||
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
|
||||
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
|
||||
[JSInvokable]
|
||||
public async Task SunChanged()
|
||||
{
|
||||
@@ -157,7 +224,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||||
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -168,42 +235,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task ReadStats(CancellationToken cancellationToken)
|
||||
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
|
||||
[JSInvokable]
|
||||
public void GlobeFailed(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> StatsRows()
|
||||
{
|
||||
if (stats is null)
|
||||
yield break;
|
||||
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
|
||||
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
|
||||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
||||
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
|
||||
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
|
||||
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
|
||||
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
|
||||
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
|
||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
|
||||
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
|
||||
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
|
||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||||
error = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||||
@@ -272,9 +309,9 @@
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
overlay?.Dispose();
|
||||
await cancellation.CancelAsync();
|
||||
cancellation.Dispose();
|
||||
statsTimer?.Dispose();
|
||||
reference?.Dispose();
|
||||
try
|
||||
{
|
||||
@@ -295,37 +332,6 @@
|
||||
|
||||
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||||
|
||||
sealed class GlobeStats
|
||||
{
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public double? Altitude { get; set; }
|
||||
public int Visible { get; set; }
|
||||
public int VisibleLocated { get; set; }
|
||||
public double? CentroidLatitude { get; set; }
|
||||
public double? CentroidLongitude { get; set; }
|
||||
public double SpreadKm { get; set; }
|
||||
public int Points { get; set; }
|
||||
public int Posts { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public int CdnGroups { get; set; }
|
||||
public int Fps { get; set; }
|
||||
public string Renderer { get; set; }
|
||||
public string Texture { get; set; }
|
||||
public int Anisotropy { get; set; }
|
||||
public bool AutoRotate { get; set; }
|
||||
public bool Paused { get; set; }
|
||||
public bool Idle { get; set; }
|
||||
public string Hovered { get; set; }
|
||||
public int Arcs { get; set; }
|
||||
public int Rings { get; set; }
|
||||
public int Countries { get; set; }
|
||||
public int CountryNames { get; set; }
|
||||
public int Borders { get; set; }
|
||||
public SunStats Sun { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||
sealed class SunStats
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* "Filter this post", from a card's menu: the persona's filters (/api/v2/filters), one of which takes the post (POST
|
||||
/api/v2/filters/:id/statuses); a filter that holds it already says so. Filters are made and changed on /filters. The
|
||||
card reads the post again afterwards, so the filter applies to it at once. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-filter-post-dialog>
|
||||
<Heading>@Localizer["Filter this post"]</Heading>
|
||||
<ChildContent>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||
}
|
||||
else if (filters.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70 mt-3">@Localizer["This persona has no filter yet."]</p>
|
||||
}
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
@foreach (var filter in filters)
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="justify-start" Disabled="@(IsDefaultDisabled || isSaving || Holds(filter))" OnClick="() => Add(filter)"
|
||||
data-filter-choice="@filter.Id">
|
||||
<i class="ion-md-funnel" aria-hidden="true"></i>
|
||||
<span class="flex-1 min-w-0 truncate text-left">@filter.Title</span>
|
||||
<span class="text-xs opacity-70">@Description(filter)</span>
|
||||
</DButton>
|
||||
}
|
||||
<a class="btn btn-sm justify-start" href="filters" data-filter-post-manage>
|
||||
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Make a filter"]</span>
|
||||
</a>
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] FilterService FilterService { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
/// <summary>The post went into a filter.</summary>
|
||||
[Parameter] public EventCallback OnFiltered { get; set; }
|
||||
|
||||
List<Filter> filters = [];
|
||||
bool isSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => filters = await FilterService.All();
|
||||
|
||||
bool Holds(Filter filter) => filter.Statuses.Any(s => s.StatusId == StatusId);
|
||||
|
||||
string Description(Filter filter) => Holds(filter) ? Localizer["Already in it"]
|
||||
: !filter.IsActive ? Localizer["Expired"]
|
||||
: filter.FilterAction switch
|
||||
{
|
||||
FilterAction.Hide => Localizer["Hide"],
|
||||
FilterAction.Blur => Localizer["Blur"],
|
||||
_ => Localizer["Warn"]
|
||||
};
|
||||
|
||||
async Task Add(Filter filter)
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await FilterService.AddStatus(filter.Id, StatusId);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Added to {0}.", filter.Title]);
|
||||
await OnFiltered.InvokeAsync();
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The accounts waiting for the persona to accept their follow (/api/v1/follow_requests), newest first, each opening its
|
||||
profile, accepted (authorize) or refused (reject) here; "Load more" follows the Link header's next page when PrivaPub
|
||||
sends one. On the persona's own profile (hidden while nobody waits) and in a sheet from the Notifications tab. *@
|
||||
@if (!HideWhenEmpty || IsLoading || requests.Count > 0)
|
||||
{
|
||||
<section class="flex flex-none flex-col gap-2 w-full" data-follow-requests>
|
||||
@if (ShowHeading)
|
||||
{
|
||||
<h2 class="font-semibold text-sm inline-flex items-center gap-2"><i class="ion-md-person-add" aria-hidden="true"></i>@Localizer["Follow requests"]</h2>
|
||||
}
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||
}
|
||||
else if (requests.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-follow-requests-empty>@Localizer["No follow request is waiting."]</p>
|
||||
}
|
||||
@foreach (var account in requests)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-follow-request="@account.Acct">
|
||||
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||
@* alt="": the name is next to it. *@
|
||||
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="flex flex-col min-w-0">
|
||||
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||
</span>
|
||||
</a>
|
||||
@if (answers.TryGetValue(account.Id, out var accepted))
|
||||
{
|
||||
<span class="text-xs opacity-70" data-follow-request-answer="@(accepted ? "accepted" : "refused")">
|
||||
@(accepted ? Localizer["Accepted."] : Localizer["Refused."])
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex gap-2">
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, true)"
|
||||
data-follow-request-accept>
|
||||
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Accept"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, false)"
|
||||
data-follow-request-reject>
|
||||
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Refuse"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (maxId is not null)
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-follow-requests-more>
|
||||
@if (isLoadingMore)
|
||||
{
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Load more"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
|
||||
/// <summary>Renders nothing once read with nobody waiting (the persona's own profile).</summary>
|
||||
[Parameter] public bool HideWhenEmpty { get; set; }
|
||||
[Parameter] public bool ShowHeading { get; set; }
|
||||
/// <summary>A request was answered: true when accepted.</summary>
|
||||
[Parameter] public EventCallback<bool> OnAnswered { get; set; }
|
||||
|
||||
List<Account> requests = [];
|
||||
// what was answered here, by account id: accepted (true) or refused
|
||||
Dictionary<string, bool> answers = new();
|
||||
HashSet<string> busy = [];
|
||||
string maxId;
|
||||
string loadedPersonaId;
|
||||
bool isLoadingMore;
|
||||
|
||||
// The persona changed (NavMenu's switcher): its requests are another persona's.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (CascadingState.PersonaId == loadedPersonaId)
|
||||
return;
|
||||
loadedPersonaId = CascadingState.PersonaId;
|
||||
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
IsLoading = true;
|
||||
requests = [];
|
||||
answers = new();
|
||||
maxId = null;
|
||||
await LoadPage();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadPage()
|
||||
{
|
||||
var result = await AccountService.FollowRequests(maxId);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
var page = (AccountPage)result.Data;
|
||||
var known = requests.Select(a => a.Id).ToHashSet();
|
||||
requests.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||
maxId = page.MaxId;
|
||||
}
|
||||
|
||||
async Task LoadMore()
|
||||
{
|
||||
isLoadingMore = true;
|
||||
await LoadPage();
|
||||
isLoadingMore = false;
|
||||
}
|
||||
|
||||
async Task Answer(Account account, bool accept)
|
||||
{
|
||||
busy.Add(account.Id);
|
||||
var result = await AccountService.Answer(account.Id, accept);
|
||||
busy.Remove(account.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
answers[account.Id] = accept;
|
||||
await OnAnswered.InvokeAsync(accept);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
|
||||
have done so, newest first, each opening its profile. *@
|
||||
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
|
||||
<ChildContent>
|
||||
@if (IsLoading)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
|
||||
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
|
||||
keys), and the original in a new tab for whoever wants the file. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
|
||||
<Heading>
|
||||
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
|
||||
</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
|
||||
<figure class="flex flex-col gap-2 min-h-0">
|
||||
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
|
||||
data-blurhash="@Current.Blurhash" />
|
||||
@if (Current.Description is { Length: > 0 } description)
|
||||
{
|
||||
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
|
||||
}
|
||||
</figure>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@if (Pictures.Count > 1)
|
||||
{
|
||||
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
|
||||
title="@Localizer["Previous picture"]" data-picture-previous>
|
||||
<i class="ion-md-arrow-back" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
}
|
||||
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
|
||||
<i class="ion-md-open" aria-hidden="true"></i>
|
||||
<span>@Localizer["Open original"]</span>
|
||||
</a>
|
||||
@if (Pictures.Count > 1)
|
||||
{
|
||||
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
|
||||
title="@Localizer["Next picture"]" data-picture-next>
|
||||
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
/// <summary>The post's pictures, in its order.</summary>
|
||||
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
|
||||
/// <summary>The one opened.</summary>
|
||||
[Parameter] public int Start { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
int index;
|
||||
|
||||
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Previous() => index = Math.Max(0, index - 1);
|
||||
|
||||
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
|
||||
|
||||
void OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "ArrowLeft")
|
||||
Previous();
|
||||
else if (e.Key == "ArrowRight")
|
||||
Next();
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
|
||||
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
|
||||
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
|
||||
@if (Heading is { Length: > 0 })
|
||||
{
|
||||
<p class="font-bold opacity-80">@Heading</p>
|
||||
}
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||
<dd class="break-all">@row.Value</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (Pending)
|
||||
{
|
||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Heading { get; set; }
|
||||
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
|
||||
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
|
||||
[Parameter] public bool Pending { get; set; }
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
|
||||
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
|
||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
|
||||
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
|
||||
pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
|
||||
@if (TapOpens)
|
||||
{
|
||||
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
|
||||
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
|
||||
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
|
||||
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
|
||||
@ChildContent
|
||||
</DTooltip>
|
||||
</DTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||
@ChildContent
|
||||
</DTooltip>
|
||||
}
|
||||
@if (isSheetOpen)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
|
||||
<Heading>@Heading</Heading>
|
||||
<ChildContent>
|
||||
<NerdRows Rows="Rows" Pending="Pending" />
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
@@ -16,8 +38,19 @@
|
||||
[Parameter] public DTooltipPlacement Placement { get; set; }
|
||||
[Parameter] public DTooltipAlign Align { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
|
||||
[Parameter] public bool TapOpens { get; set; } = true;
|
||||
|
||||
bool opened;
|
||||
bool isSheetOpen;
|
||||
|
||||
async Task Tap()
|
||||
{
|
||||
if (CascadingState.Status.CanHover())
|
||||
return;
|
||||
isSheetOpen = true;
|
||||
await Open();
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
@@ -29,21 +62,5 @@
|
||||
await OnOpen.InvokeAsync();
|
||||
}
|
||||
|
||||
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
|
||||
@if (Heading is { Length: > 0 })
|
||||
{
|
||||
<p class="font-bold opacity-80">@Heading</p>
|
||||
}
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||
<dd class="break-all">@row.Value</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (Pending)
|
||||
{
|
||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||
}
|
||||
</div>;
|
||||
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
|
||||
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
|
||||
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
|
||||
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
|
||||
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
|
||||
data-sheet>
|
||||
<div class="md:hidden flex items-center gap-3 mb-3">
|
||||
<b class="flex-1 min-w-0 truncate">@Heading</b>
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
|
||||
@onclick="Close" data-sheet-close>
|
||||
<i class="ion-md-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
/// <summary>The sheet's heading on a phone.</summary>
|
||||
[Parameter] public string Heading { get; set; }
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
|
||||
IDisposable overlay;
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
// open, the Back button closes it
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
overlay?.Dispose();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
Task Close() => OnClose.InvokeAsync();
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
|
||||
(PublicNoticeService), in MainLayout. *@
|
||||
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
||||
data-public-notice>
|
||||
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
|
||||
<ChildContent>
|
||||
<p class="text-sm">
|
||||
@@ -14,7 +15,7 @@
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)">@Localizer["Continue"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Who may quote one of the persona's own public or unlisted posts (PUT /api/v1/statuses/:id/interaction_policy,
|
||||
quote_approval_policy): anyone, its followers or nobody, from the card's menu. The post states its policy in
|
||||
quote_approval.automatic; PrivaPub tells the post's readers of the change. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-quote-policy-dialog>
|
||||
<Heading>@Localizer["Who may quote it"]</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="justify-start" Active="policy == current" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => Choose(policy)"
|
||||
aria-pressed="@(policy == current ? "true" : "false")" data-quote-policy-choice="@StatusService.QuotePolicyValue(policy)">
|
||||
<i class="@PolicyIcon(policy)" aria-hidden="true"></i>
|
||||
<span class="flex-1 text-left">@PolicyName(policy)</span>
|
||||
@if (policy == current)
|
||||
{
|
||||
<i class="ion-md-checkmark text-primary" aria-hidden="true"></i>
|
||||
}
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public Status Status { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
/// <summary>The post as PrivaPub now describes it, its new policy in it.</summary>
|
||||
[Parameter] public EventCallback<Status> OnChanged { get; set; }
|
||||
|
||||
QuotePolicy current = QuotePolicy.Public;
|
||||
bool isSaving;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
current = StatusService.QuotePolicyOf(Status);
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
string PolicyName(QuotePolicy policy) => policy switch
|
||||
{
|
||||
QuotePolicy.Followers => Localizer["Your followers"],
|
||||
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||
_ => Localizer["Anyone"]
|
||||
};
|
||||
|
||||
static string PolicyIcon(QuotePolicy policy) => policy switch
|
||||
{
|
||||
QuotePolicy.Followers => "ion-md-people",
|
||||
QuotePolicy.Nobody => "ion-md-close-circle",
|
||||
_ => "ion-md-globe"
|
||||
};
|
||||
|
||||
async Task Choose(QuotePolicy policy)
|
||||
{
|
||||
if (policy == current)
|
||||
return;
|
||||
isSaving = true;
|
||||
var result = await StatusService.SetQuotePolicy(Status.Id, policy);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = (Status)result.Data;
|
||||
current = StatusService.QuotePolicyOf(updated);
|
||||
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||
await OnChanged.InvokeAsync(updated);
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
@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. *@
|
||||
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A
|
||||
violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the
|
||||
server has rules. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||
<ChildContent>
|
||||
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
|
||||
@@ -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)
|
||||
@@ -20,13 +45,17 @@
|
||||
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
|
||||
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
|
||||
}
|
||||
<div class="flex justify-end gap-2">
|
||||
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] AboutService AboutService { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public Account Account { get; set; }
|
||||
/// <summary>The post it was opened from, sent with the report.</summary>
|
||||
@@ -36,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)
|
||||
{
|
||||
@@ -59,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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
data-export-kind="@data">
|
||||
@if (exporting == data)
|
||||
{
|
||||
<DLoading Size="DSize.Xs" />
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -95,9 +95,15 @@
|
||||
async Task Export(PortableData data)
|
||||
{
|
||||
exporting = data;
|
||||
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||
exporting = null;
|
||||
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv");
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
||||
}
|
||||
|
||||
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's
|
||||
layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from
|
||||
the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@
|
||||
<div class="flex flex-col gap-5 w-full" data-archive>
|
||||
<section class="flex flex-col gap-2" data-archive-export>
|
||||
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
|
||||
<p class="text-sm opacity-70">@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]</p>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
|
||||
@if (state.ExportState is "queued" or "running")
|
||||
{
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="ion-md-archive"></i>
|
||||
}
|
||||
<span>@Localizer["Make the archive"]</span>
|
||||
</DButton>
|
||||
@if (state.ExportState == "ready")
|
||||
{
|
||||
@if (link is null)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
|
||||
<i class="ion-md-download"></i> @Localizer["Get the link"]
|
||||
</DButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
|
||||
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
|
||||
</a>
|
||||
}
|
||||
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (state.ExportState == "failed")
|
||||
{
|
||||
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2" data-archive-import>
|
||||
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
|
||||
<p class="text-sm opacity-70">@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]</p>
|
||||
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
|
||||
@foreach (var part in Parts)
|
||||
{
|
||||
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
|
||||
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
@if (!Importing)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
|
||||
data-archive-import-start>
|
||||
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
|
||||
</DButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
|
||||
@Localizer["Stop"]
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
@if (uploading)
|
||||
{
|
||||
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
|
||||
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
|
||||
}
|
||||
@if (state.ImportState is not (null or "none"))
|
||||
{
|
||||
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
|
||||
@if (state.ImportState is "queued" or "running")
|
||||
{
|
||||
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
|
||||
}
|
||||
<span class="text-sm">
|
||||
@(state.ImportState switch
|
||||
{
|
||||
"queued" => Localizer["Waiting to start."],
|
||||
"running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal],
|
||||
"done" => Localizer["Imported."],
|
||||
"stopped" => Localizer["Stopped."],
|
||||
_ => Localizer["The import failed: {0}", state.ImportError ?? ""]
|
||||
})
|
||||
</span>
|
||||
@if (state.ImportFrom is { Length: > 0 } from)
|
||||
{
|
||||
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
|
||||
}
|
||||
<ul class="text-xs tabular-nums">
|
||||
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
|
||||
{
|
||||
<li data-archive-count="@what">@count × @CountName(what)</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] ArchiveService Archives { get; set; }
|
||||
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||
[Inject] AppConfiguration Configuration { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
|
||||
const string FileInput = "input[data-archive-file]";
|
||||
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||
static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
||||
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
||||
|
||||
ViewPersonaArchive state = new();
|
||||
string loadedFor;
|
||||
HashSet<string> chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"];
|
||||
bool chosenFlag;
|
||||
bool busy;
|
||||
string link;
|
||||
string pickedName;
|
||||
bool uploading;
|
||||
long uploaded;
|
||||
long uploadSize;
|
||||
double uploadedPercent;
|
||||
IJSObjectReference transfer;
|
||||
DotNetObjectReference<PersonaArchivePanel> self;
|
||||
CancellationTokenSource polling;
|
||||
|
||||
bool Importing => uploading || state.ImportState is "queued" or "running";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
// another persona: its own archive
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!IsLoading && loadedFor != CascadingState.PersonaId)
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
loadedFor = CascadingState.PersonaId;
|
||||
link = null;
|
||||
if (loadedFor is null)
|
||||
return;
|
||||
state = await Archives.State(loadedFor) ?? new ViewPersonaArchive();
|
||||
if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running")
|
||||
Poll();
|
||||
}
|
||||
|
||||
void Poll()
|
||||
{
|
||||
if (polling is not null)
|
||||
return;
|
||||
polling = new();
|
||||
var token = polling.Token;
|
||||
var persona = loadedFor;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var timer = new PeriodicTimer(PollEvery);
|
||||
while (await timer.WaitForNextTickAsync(token))
|
||||
{
|
||||
if (persona != loadedFor)
|
||||
break;
|
||||
if (await Archives.State(persona, token) is { } read)
|
||||
state = read;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running"))
|
||||
break;
|
||||
}
|
||||
polling = null;
|
||||
}, token);
|
||||
}
|
||||
|
||||
string PartName(string part) => part switch
|
||||
{
|
||||
"posts" => Localizer["Posts and their pictures"],
|
||||
"profile" => Localizer["Profile: name, words, fields, pictures"],
|
||||
"follows" => Localizer["Follows, asked again"],
|
||||
"blocks" => Localizer["Blocks"],
|
||||
"mutes" => Localizer["Mutes"],
|
||||
"domainblocks" => Localizer["Blocked servers"],
|
||||
"lists" => Localizer["Lists"],
|
||||
"bookmarks" => Localizer["Bookmarks"],
|
||||
"filters" => Localizer["Filters"],
|
||||
"tags" => Localizer["Followed hashtags"],
|
||||
"notificationpolicy" => Localizer["Notification policy"],
|
||||
"pins" => Localizer["Pinned posts"],
|
||||
"located" => Localizer["Located posts"],
|
||||
"scheduled" => Localizer["Scheduled posts"],
|
||||
"likes" => Localizer["Likes, given again"],
|
||||
_ => part
|
||||
};
|
||||
|
||||
string CountName(string what) => what switch
|
||||
{
|
||||
"posts" => Localizer["posts imported"],
|
||||
"posts already here" => Localizer["posts already here"],
|
||||
"posts deleted here" => Localizer["posts deleted here, left deleted"],
|
||||
"boosts skipped" => Localizer["boosts left out"],
|
||||
"direct or circle posts skipped" => Localizer["private posts left out"],
|
||||
"not the archive's own" => Localizer["posts by others left out"],
|
||||
"not a post" => Localizer["other items left out"],
|
||||
"media" => Localizer["pictures and files imported"],
|
||||
"media not in the archive" => Localizer["pictures not in the archive"],
|
||||
"media refused" => Localizer["pictures refused"],
|
||||
"located posts" => Localizer["located posts imported"],
|
||||
"located posts already here" => Localizer["located posts already here"],
|
||||
"scheduled posts" => Localizer["scheduled posts imported"],
|
||||
"scheduled posts past or refused" => Localizer["scheduled posts past or refused"],
|
||||
"followed hashtags" => Localizer["hashtags followed"],
|
||||
"notification policy" => Localizer["notification policy set"],
|
||||
"filters" => Localizer["filters imported"],
|
||||
"pins" => Localizer["posts pinned"],
|
||||
"blocked servers" => Localizer["servers blocked"],
|
||||
"blocks" => Localizer["accounts blocked"],
|
||||
"mutes" => Localizer["accounts muted"],
|
||||
"follows" => Localizer["follows asked"],
|
||||
"lists" => Localizer["lists made"],
|
||||
"list members" => Localizer["list members added"],
|
||||
"list members not followed" => Localizer["list members not followed"],
|
||||
"bookmarks" => Localizer["bookmarks imported"],
|
||||
"likes" => Localizer["likes given"],
|
||||
"profile" => Localizer["profile updated"],
|
||||
"pictures refused" => Localizer["pictures refused"],
|
||||
_ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]],
|
||||
_ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]],
|
||||
_ => what
|
||||
};
|
||||
|
||||
void Choose(string part, bool on)
|
||||
{
|
||||
if (on)
|
||||
chosen.Add(part);
|
||||
else
|
||||
chosen.Remove(part);
|
||||
}
|
||||
|
||||
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||
|
||||
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||
|
||||
async Task Export()
|
||||
{
|
||||
busy = true;
|
||||
var result = await Archives.Export(loadedFor);
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
state = (ViewPersonaArchive)result.Data;
|
||||
link = null;
|
||||
Poll();
|
||||
}
|
||||
|
||||
async Task Link()
|
||||
{
|
||||
busy = true;
|
||||
var result = await Archives.Ticket(loadedFor);
|
||||
busy = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri;
|
||||
}
|
||||
|
||||
async Task Import()
|
||||
{
|
||||
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||
self ??= DotNetObjectReference.Create(this);
|
||||
var started = await Archives.StartUpload();
|
||||
if (started.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(started);
|
||||
return;
|
||||
}
|
||||
|
||||
var upload = (ViewArchiveUpload)started.Data;
|
||||
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri;
|
||||
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||
uploading = false;
|
||||
if (!sent.Ok)
|
||||
{
|
||||
await Archives.CancelUpload(upload.Id);
|
||||
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||
return;
|
||||
}
|
||||
|
||||
var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] });
|
||||
if (asked.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(asked);
|
||||
return;
|
||||
}
|
||||
|
||||
state = (ViewPersonaArchive)asked.Data;
|
||||
Poll();
|
||||
}
|
||||
|
||||
async Task Stop()
|
||||
{
|
||||
var result = await Archives.Stop(loadedFor);
|
||||
if (result.IsInvalid)
|
||||
Toast.AddResponseError(result);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task Progress(long received, long total)
|
||||
{
|
||||
(uploaded, uploadSize) = (received, total);
|
||||
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
sealed class UploadOutcome
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
polling?.Cancel();
|
||||
self?.Dispose();
|
||||
if (transfer is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await transfer.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,35 @@ else
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||
<div class="relative w-full">
|
||||
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
|
||||
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
|
||||
alt="@Localizer["Avatar"]" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 pt-6">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>@Localizer["Avatar"]</span>
|
||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
@if (HasOwn(account.Avatar) && form.Avatar is null)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
|
||||
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>@Localizer["Header"]</span>
|
||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
|
||||
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||
</label>
|
||||
@if (HasOwn(account.Header) && form.Header is null)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
|
||||
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 max-w-md">
|
||||
@@ -68,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>
|
||||
@@ -110,18 +126,24 @@ else
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] MediaService MediaService { get; set; }
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
|
||||
// PrivaPub keeps pictures up to 20 MB and resizes them
|
||||
const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||
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();
|
||||
string loadedPersonaId;
|
||||
bool isSaving;
|
||||
List<string> pictureErrors = [];
|
||||
// the picture types the server takes, read with the account
|
||||
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
||||
string PictureTypes => string.Join(",", pictureTypes);
|
||||
|
||||
// a picture of its own, not the server's default
|
||||
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -145,6 +167,7 @@ else
|
||||
{
|
||||
// the account as the persona sees itself, with its Source
|
||||
account = await AuthService.CurrentAccount();
|
||||
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||
Reset();
|
||||
}
|
||||
|
||||
@@ -165,32 +188,56 @@ else
|
||||
{
|
||||
pictureErrors = [];
|
||||
var file = eventArgs.File;
|
||||
if (file.Size > MaximumPictureSize)
|
||||
if (!pictureTypes.Contains(file.ContentType))
|
||||
{
|
||||
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||
return;
|
||||
}
|
||||
if (file.Size > ProfileForm.MaximumPictureSize)
|
||||
{
|
||||
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
||||
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
|
||||
await fileStream.CopyToAsync(memoryStream);
|
||||
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
|
||||
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
|
||||
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
|
||||
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||
if (isAvatar)
|
||||
form.Avatar = picture;
|
||||
else
|
||||
form.Header = picture;
|
||||
try
|
||||
{
|
||||
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
|
||||
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer);
|
||||
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
async Task RemovePicture(bool avatar)
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await AccountService.RemovePicture(avatar);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
|
||||
account = (Account)result.Data;
|
||||
CascadingState.PersonaChanged(account);
|
||||
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
|
||||
}
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
isSaving = true;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
|
||||
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
|
||||
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
|
||||
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
|
||||
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
|
||||
<p class="flex flex-wrap items-center gap-2">
|
||||
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
|
||||
@if (account is { TwoFactorEnabled: true })
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
|
||||
}
|
||||
else if (account is not null)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else if (recoveryCodes.Count > 0)
|
||||
{
|
||||
<div class="flex flex-col gap-3" data-totp-codes>
|
||||
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
|
||||
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
|
||||
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
|
||||
</DAlert>
|
||||
<p class="text-sm">
|
||||
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
|
||||
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
|
||||
</p>
|
||||
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
|
||||
@foreach (var recoveryCode in recoveryCodes)
|
||||
{
|
||||
<li>@recoveryCode</li>
|
||||
}
|
||||
</ol>
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
|
||||
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
|
||||
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (account is { TwoFactorEnabled: true })
|
||||
{
|
||||
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
|
||||
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
|
||||
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
|
||||
{
|
||||
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
|
||||
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
|
||||
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
|
||||
</DAlert>
|
||||
}
|
||||
|
||||
@if (isDisabling)
|
||||
{
|
||||
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-totp-disable-password />
|
||||
@if (disableWithRecoveryCode)
|
||||
{
|
||||
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
|
||||
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
|
||||
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
|
||||
}
|
||||
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
|
||||
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
|
||||
</DButton>
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
|
||||
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
|
||||
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||
</DButton>
|
||||
}
|
||||
}
|
||||
else if (account is not null)
|
||||
{
|
||||
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
|
||||
|
||||
@if (setup is not null)
|
||||
{
|
||||
<div class="flex flex-col gap-3" data-totp-scan>
|
||||
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
|
||||
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
|
||||
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Key"]</span>
|
||||
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
|
||||
</div>
|
||||
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
|
||||
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
|
||||
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
|
||||
</DButton>
|
||||
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
|
||||
<DataAnnotationsValidator />
|
||||
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
|
||||
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
|
||||
<ErrorViewer />
|
||||
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
|
||||
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
}
|
||||
else if (isAskingPassword)
|
||||
{
|
||||
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-totp-setup-password />
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
|
||||
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
|
||||
data-totp-setup-open>
|
||||
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
|
||||
</DButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
/// <summary>From this many recovery codes left down, the block warns.</summary>
|
||||
const int FewRecoveryCodes = 3;
|
||||
|
||||
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
|
||||
ViewAccount account;
|
||||
PasswordForm setupPassword = new();
|
||||
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
|
||||
ViewTotpSetup setup;
|
||||
TotpCodeForm enable = new();
|
||||
TotpDisableForm disable = new();
|
||||
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
|
||||
List<string> recoveryCodes = [];
|
||||
bool isAskingPassword;
|
||||
bool isDisabling;
|
||||
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
|
||||
bool disableWithRecoveryCode;
|
||||
bool isSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => account = await AuthService.Account();
|
||||
|
||||
void OpenSetup()
|
||||
{
|
||||
setupPassword = new();
|
||||
isAskingPassword = true;
|
||||
}
|
||||
|
||||
void OpenDisable()
|
||||
{
|
||||
disable = new();
|
||||
disableWithRecoveryCode = false;
|
||||
isDisabling = true;
|
||||
}
|
||||
|
||||
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
|
||||
void CloseForms()
|
||||
{
|
||||
isAskingPassword = false;
|
||||
isDisabling = false;
|
||||
setup = default;
|
||||
setupPassword = new();
|
||||
enable = new();
|
||||
disable = new();
|
||||
}
|
||||
|
||||
void ToggleDisableRecoveryCode()
|
||||
{
|
||||
disableWithRecoveryCode = !disableWithRecoveryCode;
|
||||
disable.Code = default;
|
||||
}
|
||||
|
||||
async Task Setup()
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await AuthService.TotpSetup(setupPassword.Password);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
// a 409: two-factor sign-in was turned on meanwhile, from another device
|
||||
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
CloseForms();
|
||||
account = await AuthService.Account() ?? account;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setup = result.Data as ViewTotpSetup;
|
||||
setupPassword = new();
|
||||
enable = new();
|
||||
isAskingPassword = false;
|
||||
}
|
||||
|
||||
async Task Enable()
|
||||
{
|
||||
isSaving = true;
|
||||
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
|
||||
var result = await AuthService.TotpEnable(enable.Code.Trim());
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
enable = new();
|
||||
// a 409: the setup expired (an hour), so it begins again with the password
|
||||
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
CloseForms();
|
||||
account = await AuthService.Account() ?? account;
|
||||
isAskingPassword = account is { TwoFactorEnabled: false };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
recoveryCodes = result.Data as List<string> ?? [];
|
||||
setup = default;
|
||||
enable = new();
|
||||
account.TwoFactorEnabled = true;
|
||||
account.RecoveryCodesLeft = recoveryCodes.Count;
|
||||
}
|
||||
|
||||
async Task CopyCodes()
|
||||
{
|
||||
// a refusal is reported by main.js; the codes can still be selected and copied by hand
|
||||
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
|
||||
return;
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
|
||||
}
|
||||
|
||||
void CodesSaved() => recoveryCodes = [];
|
||||
|
||||
async Task Disable()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
|
||||
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
|
||||
return;
|
||||
|
||||
isSaving = true;
|
||||
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
disable.Code = default;
|
||||
return;
|
||||
}
|
||||
|
||||
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||
CloseForms();
|
||||
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
||||
@if (Toast.Messages.Count > 0)
|
||||
{
|
||||
<DToast class="z-[1000]">
|
||||
@* (on a phone above the round New post button) *@
|
||||
<DToast class="z-[1000] max-md:bottom-20">
|
||||
@foreach (var message in Toast.Messages)
|
||||
{
|
||||
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
||||
|
||||
@@ -1,704 +0,0 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"__export_date": "2022-02-14T20:02:43.810Z",
|
||||
"__export_source": "insomnia.desktop.app:v2021.7.2",
|
||||
"resources": [
|
||||
{
|
||||
"_id": "req_db7df93225be442e89a167ae594c5551",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1644868863001,
|
||||
"created": 1643816994345,
|
||||
"url": "https://localhost:5000/api/v1/message/add",
|
||||
"name": "Add message",
|
||||
"description": "rootMessageId => un susseguirsi di risposte ad un messaggio si portera' dietro sempre l'id del messaggio di partenza\n\ntitle => titolo opzionale\n\ncontent => testo del messaggio, con supporto di markdown\n\nResponse JSON:\n\n>\t\t{\n>\t\t\t\"user\": {\n\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n\t\t\t\t\"displayName\": \"loosy\",\n\t\t\t\t\"pictureUrl\": \"https://...\",\n\t\t\t\t\"backgroundUrl\": \"https://...\",\n\t\t\t\t\"profileUrl\": \"https://...\"\n\t\t\t},\n\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n\t\t\t\"rootMessageId\": null,\n\t\t\t\"messageId\": \"guid/uuid/whatever\",\n\t\t\t\"title\": \"...\",\n\t\t\t\"content\": \"...\",\n\t\t\t\"isFavourite\": false,\n\t\t\t\"isBoosted\": false,\n\t\t\t\"media\": [{\n\t\t\t\t\t\"fileName\": \"ciao.png\",\n\t\t\t\t\t\"altText\": \"descrizione immagine\",\n\t\t\t\t\t\"contentType\": \"image/png\",\n\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n\t\t\t}],\n\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t}\n",
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"mimeType": "application/json",
|
||||
"text": " {\n\t\"contentType\": \"1||2||3\",//PlainText,HTML,Markdown\n\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public,\n\t\"mediaType\": \"1||2||3||4\",//Images,Video,Audio,Documents\n\t\"rootMessageId\": null,\n\t\"title\": \"...\",\n\t\"content\": \"...\",\n\t\"media\": [{\n\t\t\"fileName\": \"ciao.png\",\n \"altText\": \"descrizione opzionale immagine\",\n\t\t\"contentType\": \"image/png\",//mime\n\t\t\"blob\": [23,33,11,...]//byteArray || base64?\n\t}],\n\t\"createdAt\": \"UTC or ticks\"\n}\n\n"
|
||||
},
|
||||
"parameters": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"id": "pair_b95faf0515f9453595110e7895330c5f"
|
||||
}
|
||||
],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1643816957190,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1643829443147,
|
||||
"created": 1643816769446,
|
||||
"name": "APIs",
|
||||
"description": "",
|
||||
"environment": {},
|
||||
"environmentPropertyOrder": {},
|
||||
"metaSortKey": -1643816769446,
|
||||
"_type": "request_group"
|
||||
},
|
||||
{
|
||||
"_id": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"parentId": null,
|
||||
"modified": 1608987862911,
|
||||
"created": 1608987862911,
|
||||
"name": "Insomnia",
|
||||
"description": "",
|
||||
"scope": "collection",
|
||||
"_type": "workspace"
|
||||
},
|
||||
{
|
||||
"_id": "req_b33056a8e8a64c838662a60a4909537c",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1643829255295,
|
||||
"created": 1643817054991,
|
||||
"url": "https://localhost:5000/api/v1/message/messageId/delete",
|
||||
"name": "Remove message",
|
||||
"description": "[queryParam] messageId => id del messaggio da eliminare\n\nResponse 200\n",
|
||||
"method": "DELETE",
|
||||
"body": {},
|
||||
"parameters": [],
|
||||
"headers": [],
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"grantType": "authorization_code"
|
||||
},
|
||||
"metaSortKey": -1643816957177.5,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "req_95c442071ee247f1a97d823c6e503db2",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1644868763642,
|
||||
"created": 1643816956965,
|
||||
"url": "https://localhost:5000/api/v1/messages",
|
||||
"name": "Get messages list",
|
||||
"description": "Response JSON:\n>\t\t[\n>\t\t\t{\n>\t\t\t\t\"user\": {\n>\t\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n>\t\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n>\t\t\t\t\t\"displayName\": \"loosy\",\n>\t\t\t\t\t\"pictureUrl\": \"https://...\",\n>\t\t\t\t\t\"backgroundUrl\": \"https://...\",\n>\t\t\t\t\t\"profileUrl\": \"https://...\"\n>\t\t\t\t},\n>\t\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n>\t\t\t\t\"rootMessageId\": null,\n>\t\t\t\t\"messageId\": \"guid/uuid/whatever\",\n>\t\t\t\t\"title\": \"...\",\n>\t\t\t\t\"content\": \"...\",\n>\t\t\t\t\"isFavourite\": false,\n>\t\t\t\t\"isBoosted\": false,\n>\t\t\t\t\"media\": [{\n>\t\t\t\t\t\"fileName\": \"ciao.png\",\n>\t\t\t\t\t\"altText\": \"descrizione immagine\",\n>\t\t\t\t\t\"contentType\": \"image/png\",\n>\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n>\t\t\t\t}],\n>\t\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t\t}, {...}\n>\t\t]",
|
||||
"method": "GET",
|
||||
"body": {},
|
||||
"parameters": [
|
||||
{
|
||||
"id": "pair_6e5222c6a78341bbb24f9b18d236df6c",
|
||||
"name": "since",
|
||||
"value": "UTC/ticks/messageId?",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"headers": [],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1643816956965,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1642377560788,
|
||||
"created": 1608987862952,
|
||||
"name": "Base Environment",
|
||||
"data": {},
|
||||
"dataPropertyOrder": {},
|
||||
"color": null,
|
||||
"isPrivate": false,
|
||||
"metaSortKey": 1608987862952,
|
||||
"_type": "environment"
|
||||
},
|
||||
{
|
||||
"_id": "jar_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1642378408249,
|
||||
"created": 1608987862953,
|
||||
"name": "Default Jar",
|
||||
"cookies": [
|
||||
{
|
||||
"key": "oc_sessionPassphrase",
|
||||
"value": "nMmK2Vb0FhKNaSQiPteel3EhA5swjs9%2FYJwU79BzPrq1cgNmCdL2w88ktlfpy8BXU1Yuj7MKSmCLNYMB0NIDFZ%2B7AnbhqpW0am5cFolB4eRzT2uSUSg16nKnAPVXAkGa",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.279Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.279Z",
|
||||
"id": "06476282363234076"
|
||||
},
|
||||
{
|
||||
"key": "occ7de4yt74j",
|
||||
"value": "dsleqfmojuisnmgcq63p0nrk77",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.279Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9557040429295196"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookielax",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.280Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
||||
"id": "4410522677170672"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookiestrict",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=strict" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.280Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
||||
"id": "7173600438498109"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T11:49:33.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.818Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "3533626334860853"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.818Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "06043516223469947"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "1905708786875855"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "4672408440036"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "7693972008479257"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9731704342625882"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "3557084085645068"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T11:51:28.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:28.651Z",
|
||||
"lastAccessed": "2021-05-04T10:51:28.651Z",
|
||||
"id": "8645799753636227"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "9029869294914612"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "6422488233461934"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "7557066093242031"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T12:01:26.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:26.192Z",
|
||||
"lastAccessed": "2021-05-04T11:01:26.192Z",
|
||||
"id": "7093910949861533"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.223Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.223Z",
|
||||
"id": "677709203943275"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.224Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9477816718956802"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.224Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "41804757195232756"
|
||||
},
|
||||
{
|
||||
"key": "oc_sessionPassphrase",
|
||||
"value": "pUIH26mXSdmGTfubB4dUzw8F8MQsUHdwnHCeo7ElPWIGDxXte2XC1M0EMyN5eriarJcMmkfh4kSGFI9%2BRcs%2BHt2gdDygddkz5I7Tl8KtjkwC%2BawIYuL8z3lobWTL%2BCy%2B",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.727Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.727Z",
|
||||
"id": "06313521114695941"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookielax",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "860532957782806"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookiestrict",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=strict" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "31117326197847706"
|
||||
},
|
||||
{
|
||||
"key": "ocl7d3yrw74j",
|
||||
"value": "uecghev7rleu4uptmruhptuvto",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:13:28.249Z",
|
||||
"id": "5917940161261273"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:03:09.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "009292942805962934"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.252Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
||||
"id": "5470762369580981"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.252Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
||||
"id": "4385020724400406"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.253Z",
|
||||
"id": "05297254853120181"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "46009278671166176"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "05812364381235913"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "3772361407666647"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:05:17.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "08277815359810625"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "7962572269421415"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "8604909194077435"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "6043027104701622"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:11:28.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:11:28.872Z",
|
||||
"lastAccessed": "2022-01-17T00:11:28.872Z",
|
||||
"id": "014769824799037545"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "7141536696124962"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "08455474112707173"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "141765916939931"
|
||||
}
|
||||
],
|
||||
"_type": "cookie_jar"
|
||||
},
|
||||
{
|
||||
"_id": "spc_f832b28de5204b4a88374d08ba93a8e2",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1608987862912,
|
||||
"created": 1608987862912,
|
||||
"fileName": "Insomnia",
|
||||
"contents": "",
|
||||
"contentType": "yaml",
|
||||
"_type": "api_spec"
|
||||
},
|
||||
{
|
||||
"_id": "env_77dfa781871a430f8e5014b07c9f9061",
|
||||
"parentId": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"modified": 1642377578797,
|
||||
"created": 1642377566379,
|
||||
"name": "Nextcloud API",
|
||||
"data": {},
|
||||
"dataPropertyOrder": {},
|
||||
"color": null,
|
||||
"isPrivate": false,
|
||||
"metaSortKey": 1642377566379,
|
||||
"_type": "environment"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +1,15 @@
|
||||
using Blazored.LocalStorage;
|
||||
|
||||
using 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")
|
||||
|
||||
@@ -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();
|
||||
@@ -57,23 +30,32 @@ public static class GenericExtensions
|
||||
};
|
||||
}
|
||||
|
||||
// a count as a phone's narrow action buttons show it: 999, 1.2k, 12k, 1.2M
|
||||
public static string Compact(this int count) => count switch
|
||||
{
|
||||
< 1000 => count.ToString(CultureInfo.CurrentCulture),
|
||||
< 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k",
|
||||
< 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k",
|
||||
_ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M"
|
||||
};
|
||||
|
||||
public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
|
||||
{
|
||||
>= 1_048_576 => localizer["{0}MB", size / 1_048_576],
|
||||
_ => 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;
|
||||
@@ -104,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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -27,6 +30,8 @@
|
||||
public PublicCacheData PublicCacheData { get; private set; } = new();
|
||||
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
||||
public bool IsSignedIn { get; private set; }
|
||||
/// <summary>The session has been read once: from then on a null PersonaId means the login has no persona, not "not yet".</summary>
|
||||
public bool IsSessionLoaded { get; private set; }
|
||||
/// <summary>The root's login name, shown to its owner only.</summary>
|
||||
public string RootUserName { get; private set; }
|
||||
/// <summary>The root's personas.</summary>
|
||||
@@ -40,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()
|
||||
{
|
||||
@@ -57,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);
|
||||
}
|
||||
@@ -75,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).
|
||||
@@ -93,6 +190,7 @@
|
||||
if (!IsSignedIn)
|
||||
{
|
||||
ClearSession();
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
@@ -105,11 +203,16 @@
|
||||
{
|
||||
PersonaId = null;
|
||||
Persona = null;
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// loaded once the persona is known (or couldn't be used): a page rendered while it is switched to would otherwise
|
||||
// see a loaded session without one, and the feed would say it is empty
|
||||
await SwitchPersona(personaId, cancellationToken);
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
||||
@@ -153,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();
|
||||
@@ -160,6 +264,8 @@
|
||||
|
||||
void ClearSession()
|
||||
{
|
||||
CancelSettingsSave();
|
||||
loginSettings = null;
|
||||
IsSignedIn = false;
|
||||
RootUserName = null;
|
||||
Personas = [];
|
||||
@@ -228,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;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace decePubClient.LayerComponents
|
||||
{
|
||||
public class PagesBase : LocalizableComponentBase
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(SearchResults))]
|
||||
[JsonSerializable(typeof(MediaAttachment))]
|
||||
[JsonSerializable(typeof(InstanceV2))]
|
||||
[JsonSerializable(typeof(Notification))]
|
||||
[JsonSerializable(typeof(List<Notification>))]
|
||||
[JsonSerializable(typeof(Conversation))]
|
||||
@@ -58,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
|
||||
{
|
||||
}
|
||||
+71
-4
@@ -9,16 +9,30 @@ public static class APIs
|
||||
{
|
||||
public const string ClientApiPrefix = "clientapi/";
|
||||
public const string MastodonPrefix = "api/";
|
||||
public const string OAuthPrefix = "oauth/";
|
||||
|
||||
public static class ClientApi
|
||||
{
|
||||
public const string POST_Login = "clientapi/user/login";
|
||||
public const string GET_Setup = "clientapi/user/setup";
|
||||
public const string POST_Setup = "clientapi/user/setup";
|
||||
public const string POST_InvitationSignUp = "clientapi/user/invitation/signup";
|
||||
public const string GET_Logout = "clientapi/user/logout";
|
||||
public const string GET_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";
|
||||
@@ -33,14 +47,48 @@ public static class APIs
|
||||
public const string GET_Posts = "clientapi/post/list";
|
||||
public const string GET_Nearby = "clientapi/post/nearby";
|
||||
public const string POST_InsertPost = "clientapi/post/insert";
|
||||
public const string POST_DeletePost = "clientapi/post/delete";
|
||||
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
||||
public const string GET_ModeratorLog = "clientapi/moderator/log";
|
||||
public const string GET_AdminSettings = "clientapi/admin/settings";
|
||||
public const string POST_AdminSettings = "clientapi/admin/settings";
|
||||
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
|
||||
public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail";
|
||||
public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list";
|
||||
public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert";
|
||||
public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update";
|
||||
public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}";
|
||||
public const string GET_AdminRelays = "clientapi/admin/relays";
|
||||
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
|
||||
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
|
||||
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
|
||||
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
|
||||
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
|
||||
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||
public static string GET_M_StatisticsHost(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}";
|
||||
public static string POST_M_StatisticsDescribe(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}/describe";
|
||||
public const string GET_StatisticsEvents = "clientapi/admin/statistics/events";
|
||||
public const string GET_StatisticsServer = "clientapi/admin/statistics/server";
|
||||
public const string GET_AdminBackups = "clientapi/admin/backups";
|
||||
public const string POST_AdminBackup = "clientapi/admin/backups";
|
||||
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||
public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket";
|
||||
public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore";
|
||||
public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads";
|
||||
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
|
||||
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||
public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||
public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||
public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
|
||||
public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
|
||||
public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
|
||||
public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
|
||||
public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||
public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||
}
|
||||
|
||||
public static class OAuth
|
||||
@@ -70,8 +118,22 @@ 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";
|
||||
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||
@@ -81,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)}";
|
||||
@@ -110,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";
|
||||
@@ -133,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)}";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class ClientLogs
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Where { get; set; }
|
||||
public string WarningMessage { get; set; }
|
||||
public ClientLogException Exception { get; set; }
|
||||
public DateTime TimeStamp { get; set; } = DateTime.Now;
|
||||
/// <summary>The entry as HTML for a log viewer: the warning text, or the exception's details.</summary>
|
||||
public string ErrorMessage()
|
||||
{
|
||||
if (Exception is null)
|
||||
return WarningMessage;
|
||||
|
||||
var details = @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
|
||||
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br>
|
||||
<b>Message</b>: {Exception.Message ?? "N/A"}";
|
||||
if (Exception.InnerExceptionMessage is null)
|
||||
return details;
|
||||
|
||||
return $"{details}<br>\n<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
public class ClientLogException
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string HelpLink { get; set; }
|
||||
public string HResult { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string StackTrace { get; set; }
|
||||
public string TargetSiteName { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string InnerExceptionMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ 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; }
|
||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
public string SpoilerText { get; set; }
|
||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
@@ -62,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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace decePubClient.Models.Mastodon;
|
||||
|
||||
/// <summary>/api/v1/announcements: what the server's administrators announce, shown while published and not ended, as
|
||||
/// the persona sees it (Read and each reaction's Me are the persona's own; signed out, neither is said).</summary>
|
||||
public class Announcement
|
||||
{
|
||||
public string Id { get; set; }
|
||||
/// <summary>Sanitized HTML, from the administrator's Markdown.</summary>
|
||||
public string Content { get; set; }
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public bool AllDay { get; set; }
|
||||
public DateTime PublishedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
/// <summary>Whether the persona dismissed it.</summary>
|
||||
public bool? Read { get; set; }
|
||||
public List<AnnouncementReaction> Reactions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did.</summary>
|
||||
public class AnnouncementReaction
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Count { get; set; }
|
||||
public bool? Me { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which.</summary>
|
||||
public class AnnouncementReactionEvent
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Count { get; set; }
|
||||
public string AnnouncementId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace decePubClient.Models.Mastodon;
|
||||
|
||||
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
|
||||
/// its rules, and what an upload may be.</summary>
|
||||
public class InstanceV2
|
||||
{
|
||||
public string Domain { get; set; }
|
||||
public string Title { get; set; }
|
||||
/// <summary>The short description, as plain text.</summary>
|
||||
public string Description { get; set; }
|
||||
public InstanceThumbnail Thumbnail { get; set; } = new();
|
||||
public InstanceContact Contact { get; set; } = new();
|
||||
public List<InstanceRule> Rules { get; set; } = [];
|
||||
public InstanceConfiguration Configuration { get; set; } = new();
|
||||
}
|
||||
|
||||
public class InstanceThumbnail
|
||||
{
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Who answers for the server: an address, and the persona its administrator named (null when none).</summary>
|
||||
public class InstanceContact
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public Account Account { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One of the server's rules (/api/v1/instance/rules and the instance's own), in their order.</summary>
|
||||
public class InstanceRule
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string Hint { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceConfiguration
|
||||
{
|
||||
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
|
||||
public class MediaConfiguration
|
||||
{
|
||||
public List<string> SupportedMimeTypes { get; set; } = [];
|
||||
public long ImageSizeLimit { get; set; }
|
||||
public long VideoSizeLimit { get; set; }
|
||||
public long ImageMatrixLimit { get; set; }
|
||||
public long VideoMatrixLimit { get; set; }
|
||||
public int VideoFrameRateLimit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it
|
||||
/// last changed.</summary>
|
||||
public class InstanceDocument
|
||||
{
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public string Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none
|
||||
/// (404).</summary>
|
||||
public class TermsOfService
|
||||
{
|
||||
/// <summary>yyyy-MM-dd.</summary>
|
||||
public string EffectiveDate { get; set; }
|
||||
/// <summary>Whether they apply already (false while their date is ahead).</summary>
|
||||
public bool Effective { get; set; }
|
||||
public string Content { get; set; }
|
||||
public string SucceededBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is
|
||||
/// obfuscated, its digest the SHA-256 of the whole name.</summary>
|
||||
public class PublicDomainBlock
|
||||
{
|
||||
public string Domain { get; set; }
|
||||
public string Digest { get; set; }
|
||||
/// <summary>suspend, silence or noop.</summary>
|
||||
public string Severity { get; set; }
|
||||
public string Comment { get; set; }
|
||||
}
|
||||
@@ -189,13 +189,23 @@ public class PollForm
|
||||
public bool Multiple { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
|
||||
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||
public class StatusEditForm
|
||||
{
|
||||
public string Status { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string Language { get; set; }
|
||||
public List<string> MediaIds { get; set; } = [];
|
||||
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||
public class MediaAttributeForm
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||
@@ -244,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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+33
-5
@@ -1,5 +1,6 @@
|
||||
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.User;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
@@ -10,20 +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
|
||||
@@ -59,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -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]" }
|
||||
@@ -91,10 +91,14 @@ namespace decePubClient.Models
|
||||
|
||||
static string Flag(bool value) => value ? "true" : "false";
|
||||
|
||||
static ByteArrayContent Picture(UploadMedia media)
|
||||
// PrivaPub keeps pictures up to this size and resizes them
|
||||
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||
|
||||
// streamed from the browser's file, read again for each attempt
|
||||
static StreamContent Picture(UploadMedia media)
|
||||
{
|
||||
var picture = new ByteArrayContent(media.Blob);
|
||||
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
||||
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||
return picture;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class SettingsOptions
|
||||
{
|
||||
public bool IsOpen { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
{
|
||||
Images = 1,
|
||||
Video,
|
||||
Audio,
|
||||
Documents
|
||||
Audio
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace decePubClient.Models.Types
|
||||
{
|
||||
public enum MessageType
|
||||
{
|
||||
Direct = 1,
|
||||
FollowersOnly,
|
||||
Unlisted,
|
||||
Public
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace decePubClient.Models.Types
|
||||
{
|
||||
/// <summary>Who a post is for; the members are resource keys too (Localizer[value.ToString()]).</summary>
|
||||
public enum Visibility
|
||||
{
|
||||
Direct = 1,
|
||||
FollowersOnly,
|
||||
Unlisted,
|
||||
Public
|
||||
}
|
||||
}
|
||||
+35
-4
@@ -1,17 +1,48 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace decePubClient.Models;
|
||||
|
||||
public enum UploadState
|
||||
{
|
||||
Uploading,
|
||||
Processing,
|
||||
Ready,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||
/// </summary>
|
||||
public class UploadMedia
|
||||
{
|
||||
public string ContentType { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public string AltText { get; set; }
|
||||
public byte[] Blob { get; set; } = [];
|
||||
public long Size { get; set; }
|
||||
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Base64Preview { get; set; }
|
||||
public IBrowserFile File { get; set; }
|
||||
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public long Size { get; set; }
|
||||
public string Preview { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public UploadState State { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Id { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Error { get; set; }
|
||||
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string SentAltText { get; set; }
|
||||
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public bool Posted { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public CancellationTokenSource Cancel { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public Task Done { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public static class VConstants
|
||||
{
|
||||
public const string HideClass = "hidden";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class ViewLanguage
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string International2Code { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
@page "/about"
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
<Title>@Localizer["About {0}", instance?.Title ?? "PrivaPub"]</Title>
|
||||
|
||||
@* What this server says of itself, to anyone, signed in or not (AboutService, the Mastodon instance API without
|
||||
credentials): its name, short description and thumbnail, its long description, who answers for it, its rules, its
|
||||
privacy policy and terms of service, and the domain blocks its administrator published (an obfuscated one partly
|
||||
starred, as the server gives it). The documents are the server's sanitized HTML. *@
|
||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||
|
||||
<div class="flex flex-col items-center gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto" data-about>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (instance is null)
|
||||
{
|
||||
<div class="flex flex-col items-center gap-2 mt-6">
|
||||
<p class="text-sm text-error">@Localizer["The server's description could not be read."]</p>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<article class="flex flex-col w-full max-w-3xl rounded-xl neo-raised neo-sm overflow-hidden" data-about-server>
|
||||
@if (instance.Thumbnail.Url is { Length: > 0 } thumbnail)
|
||||
{
|
||||
@* decorative: the server's name follows *@
|
||||
<img src="@thumbnail" alt="" class="w-full aspect-[40/21] object-cover" />
|
||||
}
|
||||
<div class="flex flex-col gap-2 p-4 md:p-5">
|
||||
<h1 class="text-2xl font-bold break-words" data-about-title>@instance.Title</h1>
|
||||
@if (instance.Domain is { Length: > 0 } domain)
|
||||
{
|
||||
<p class="text-sm opacity-70 break-all">@domain</p>
|
||||
}
|
||||
@if (instance.Description is { Length: > 0 } summary)
|
||||
{
|
||||
<p class="text-base break-words" data-about-summary>@summary</p>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@if (description?.Content is { Length: > 0 } extended)
|
||||
{
|
||||
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-description>
|
||||
<div class="post-content doc-content break-words">@((MarkupString)extended)</div>
|
||||
@if (description.UpdatedAt is { } describedAt)
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
|
||||
@if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null)
|
||||
{
|
||||
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-contact>
|
||||
<h2 class="text-lg font-bold">@Localizer["Contact"]</h2>
|
||||
@if (instance.Contact.Account is { } account)
|
||||
{
|
||||
<a class="flex items-center gap-3 p-2 rounded-lg neo-raised neo-2xs min-w-0" href="@AccountLink(account)" data-about-contact-account="@account.Id">
|
||||
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="flex flex-col min-w-0">
|
||||
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
@if (instance.Contact.Email is { Length: > 0 } email)
|
||||
{
|
||||
<a class="link inline-flex items-center gap-2 break-all touch-target self-start" href="@($"mailto:{email}")" data-about-contact-email>
|
||||
<i class="ion-md-mail" aria-hidden="true"></i>@email
|
||||
</a>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
|
||||
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-rules>
|
||||
<h2 class="text-lg font-bold">@Localizer["Rules"]</h2>
|
||||
@if (instance.Rules.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70">@Localizer["The server has no rules."]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ol class="flex flex-col gap-2 list-decimal ps-6">
|
||||
@foreach (var rule in instance.Rules)
|
||||
{
|
||||
<li class="break-words" data-about-rule="@rule.Id">
|
||||
<span>@rule.Text</span>
|
||||
@if (rule.Hint is { Length: > 0 } hint)
|
||||
{
|
||||
<p class="text-sm opacity-70">@hint</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (privacy?.Content is { Length: > 0 } policy)
|
||||
{
|
||||
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-privacy>
|
||||
<h2 class="text-lg font-bold">@Localizer["Privacy policy"]</h2>
|
||||
<div class="post-content doc-content break-words">@((MarkupString)policy)</div>
|
||||
@if (privacy.UpdatedAt is { } policyAt)
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
|
||||
@if (terms?.Content is { Length: > 0 } termsText)
|
||||
{
|
||||
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-terms>
|
||||
<h2 class="text-lg font-bold">@Localizer["Terms of service"]</h2>
|
||||
@if (TermsDate is { } effectiveOn)
|
||||
{
|
||||
<p class="text-xs opacity-70">
|
||||
@(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn])
|
||||
</p>
|
||||
}
|
||||
<div class="post-content doc-content break-words">@((MarkupString)termsText)</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
@if (blocks.Count > 0)
|
||||
{
|
||||
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-blocks>
|
||||
<h2 class="text-lg font-bold">@Localizer["Moderated servers"]</h2>
|
||||
<p class="text-sm opacity-70">@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
@foreach (var block in blocks)
|
||||
{
|
||||
<li class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs" data-about-block="@block.Domain">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<b class="flex-1 min-w-0 break-all font-mono text-sm" title="@($"SHA-256 {block.Digest}")">@block.Domain</b>
|
||||
@switch (block.Severity)
|
||||
{
|
||||
case "suspend":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Suspended"]</DBadge>
|
||||
break;
|
||||
case "silence":
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Silenced"]</DBadge>
|
||||
break;
|
||||
default:
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Limited"]</DBadge>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
@if (block.Comment is { Length: > 0 } reason)
|
||||
{
|
||||
<p class="text-xs break-words">@reason</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AboutService AboutService { get; set; }
|
||||
|
||||
InstanceV2 instance;
|
||||
InstanceDocument description;
|
||||
InstanceDocument privacy;
|
||||
TermsOfService terms;
|
||||
List<PublicDomainBlock> blocks = [];
|
||||
|
||||
// the terms' date as the reader writes dates
|
||||
string TermsDate => DateTime.TryParseExact(terms?.EffectiveDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var on) ? on.ToString(SUtility.dateTimeFormat) : null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
var reading = AboutService.Instance(fresh: true);
|
||||
var describing = AboutService.ExtendedDescription();
|
||||
var policing = AboutService.PrivacyPolicy();
|
||||
var terming = AboutService.Terms();
|
||||
var blocking = AboutService.DomainBlocks();
|
||||
await Task.WhenAll(reading, describing, policing, terming, blocking);
|
||||
instance = reading.Result;
|
||||
description = describing.Result;
|
||||
privacy = policing.Result;
|
||||
terms = terming.Result;
|
||||
blocks = blocking.Result;
|
||||
}
|
||||
|
||||
async Task Retry()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
// signed in, the contact's profile in the app; signed out, its own page
|
||||
string AccountLink(Account account) =>
|
||||
CascadingState.IsSignedIn ? $"account/{Uri.EscapeDataString(account.Id)}" : account.Url;
|
||||
}
|
||||
+98
-159
@@ -4,29 +4,15 @@
|
||||
|
||||
<Title>@Localizer["Administration"]</Title>
|
||||
|
||||
@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. 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>
|
||||
@@ -39,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>
|
||||
@@ -53,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>
|
||||
@@ -65,146 +129,21 @@
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
<BackupsPanel />
|
||||
</div>
|
||||
</ChildContent>
|
||||
</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 {
|
||||
}
|
||||
+15
-3
@@ -14,8 +14,9 @@
|
||||
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||
<div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
|
||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||
aria-label="@Localizer["Explore"]">
|
||||
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
|
||||
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
|
||||
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
|
||||
@@ -43,7 +44,7 @@
|
||||
@foreach (var item in tags)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
|
||||
<button type="button" class="flex flex-col items-start min-w-0 flex-1 text-left" @onclick="() => OpenTag(item.Name)">
|
||||
<button type="button" class="flex flex-col items-start justify-center min-w-0 flex-1 text-left pointer-coarse:min-h-11" @onclick="() => OpenTag(item.Name)">
|
||||
<b class="truncate">#@item.Name</b>
|
||||
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
|
||||
</button>
|
||||
@@ -139,6 +140,17 @@
|
||||
</section>
|
||||
|
||||
@code {
|
||||
ElementReference strip;
|
||||
int revealed = -1;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if ((int)tab == revealed)
|
||||
return;
|
||||
revealed = (int)tab;
|
||||
CascadingState.Status.RevealActive(strip);
|
||||
}
|
||||
|
||||
enum ExploreTab
|
||||
{
|
||||
Hashtags,
|
||||
|
||||
+61
-6
@@ -6,7 +6,9 @@
|
||||
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
||||
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)
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
|
||||
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
|
||||
aria-label="@Localizer["Invitation code"]" data-group-invitation />
|
||||
<span>@Localizer["Invitation link, for someone without a login here"]</span>
|
||||
<input class="input input-sm w-full font-mono text-xs" value="@($"{Navigation.BaseUri}join?code={Uri.EscapeDataString(invitation)}")" readonly
|
||||
autocomplete="off" onfocus="this.select()" aria-label="@Localizer["Invitation link, for someone without a login here"]" data-group-invitation-link />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -187,6 +190,7 @@
|
||||
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
|
||||
|
||||
[Inject] GroupService GroupService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
List<ViewGroup> groups = [];
|
||||
List<ViewGroupMember> members = [];
|
||||
@@ -281,6 +285,8 @@
|
||||
|
||||
async Task Leave(ViewGroup group)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
|
||||
return;
|
||||
isSaving = true;
|
||||
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
|
||||
isSaving = false;
|
||||
|
||||
+95
-19
@@ -10,35 +10,70 @@
|
||||
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">
|
||||
|
||||
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
|
||||
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
|
||||
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
||||
<aside class="@GlobeClass" role="@(globeExpanded ? "dialog" : null)" aria-modal="@(globeExpanded ? "true" : null)"
|
||||
aria-label="@(globeExpanded ? Localizer["Globe"] : null)">
|
||||
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" @bind-Expanded="globeExpanded" />
|
||||
</aside>
|
||||
|
||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
|
||||
@if (!isComposing)
|
||||
{
|
||||
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
|
||||
<DButton Shape="DButtonShape.Circle" Size="DSize.Lg" Color="DColor.Primary" OnClick="ToggleCompose"
|
||||
class="md:hidden fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-30" aria-label="@Localizer["New post"]"
|
||||
title="@Localizer["New post"]" data-compose-open>
|
||||
<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" />
|
||||
|
||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
|
||||
@* (on a phone the bottom stays clear of the New post button) *@
|
||||
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 max-md:pb-24 md:p-4">
|
||||
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
|
||||
|
||||
@if (isComposing)
|
||||
{
|
||||
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
||||
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
|
||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||
</div>
|
||||
}
|
||||
</PhoneSheet>
|
||||
|
||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||
@if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0))
|
||||
{
|
||||
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
@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)
|
||||
{
|
||||
@@ -82,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">
|
||||
@@ -119,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 = [];
|
||||
@@ -128,15 +163,28 @@
|
||||
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;
|
||||
string loadError;
|
||||
bool isLoadingMore;
|
||||
bool isComposing;
|
||||
bool globeExpanded;
|
||||
|
||||
// a phone's banner (none when the phone is held sideways: the feed would have no room), the column from lg, or
|
||||
// full screen
|
||||
string GlobeClass => globeExpanded
|
||||
? "fixed inset-0 z-[70] block h-dvh w-full overflow-hidden bg-base-100"
|
||||
: Filter.ShowGlobe
|
||||
? "block max-lg:short:hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden"
|
||||
: "hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden";
|
||||
(double Latitude, double Longitude)? position;
|
||||
string positionError;
|
||||
string loadedPersonaId;
|
||||
bool waitingForPersona;
|
||||
FeedQuery lastQuery;
|
||||
DateTime? lastPollAt;
|
||||
TimeSpan? lastLoadTook;
|
||||
@@ -165,9 +213,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// (while the session is still being read the persona isn't known yet: the feed is loading, not empty)
|
||||
if (CascadingState.PersonaId is null)
|
||||
{
|
||||
IsLoading = false;
|
||||
waitingForPersona = !CascadingState.IsSessionLoaded;
|
||||
IsLoading = waitingForPersona;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,11 +251,19 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
||||
// The persona is known (the session was read after the page opened) or changed (NavMenu's switcher): everything on the
|
||||
// page belongs to it.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
|
||||
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
|
||||
{
|
||||
waitingForPersona = false;
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
|
||||
return;
|
||||
waitingForPersona = false;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
@@ -403,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
|
||||
{
|
||||
@@ -461,6 +522,14 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
// a composer that opens shows at the top of the feed, where it is, however far down the feed was read
|
||||
async Task ToggleCompose()
|
||||
{
|
||||
isComposing = !isComposing;
|
||||
if (isComposing && feedModule is not null)
|
||||
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
||||
}
|
||||
|
||||
async Task ShowPending()
|
||||
{
|
||||
var shown = pending;
|
||||
@@ -532,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);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
@page "/join"
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Join with an invitation"]</Title>
|
||||
|
||||
@* A new login through a group's invitation (PrivaPub's /clientapi/user/invitation/signup): sign-up is otherwise closed,
|
||||
so this is how everyone but the first administrator arrives. Its first persona joins the group. A group's page links
|
||||
here with the code (?code=). *@
|
||||
<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-join-signed-in>
|
||||
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
|
||||
<p class="text-sm">@Localizer["A persona of yours joins a group by its invitation in Groups."]</p>
|
||||
<DButton Href="groups" Color="DColor.Primary">@Localizer["Groups"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="SignUp" 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-join>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Join with an invitation"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["A group's invitation makes your login here, and your first persona joins the group."]</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Invitation code"]</span>
|
||||
<DInput TValue="string" class="w-full font-mono text-xs" @bind-Value="form.InvitationCode" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code />
|
||||
</label>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.InvitationPassword" autocomplete="off"
|
||||
placeholder="@Localizer["Invitation password, if it has one"]" data-join-code-password />
|
||||
|
||||
<b class="text-sm">@Localizer["Your login"]</b>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Username"]</span>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-join-username>
|
||||
<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="new-password" data-join-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 password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-join-repeat>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<b class="text-sm">@Localizer["Your first persona"]</b>
|
||||
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-join-persona-username>
|
||||
<Prefix>@@</Prefix>
|
||||
</DInput>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
|
||||
placeholder="@Localizer["Display name"]" data-join-persona-name />
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-join-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Join"]</span>
|
||||
</DButton>
|
||||
<a class="link text-sm self-center touch-target" href="login">@Localizer["I have a login: sign in"]</a>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
|
||||
readonly InvitationLoginForm form = new();
|
||||
string repeatedPassword;
|
||||
string error;
|
||||
bool isWorking;
|
||||
|
||||
[SupplyParameterFromQuery] public string Code { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
form.InvitationCode = Code?.Trim();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task SignUp()
|
||||
{
|
||||
error = default;
|
||||
if (form.Password != repeatedPassword)
|
||||
{
|
||||
error = Localizer["The two passwords don't match."];
|
||||
return;
|
||||
}
|
||||
|
||||
isWorking = true;
|
||||
form.InvitationCode = form.InvitationCode?.Trim();
|
||||
form.InvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? null : form.InvitationPassword;
|
||||
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||
var result = await AuthService.Join(form);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
isWorking = false;
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
|
||||
Navigation.NavigateTo("");
|
||||
}
|
||||
}
|
||||
+128
-9
@@ -3,16 +3,59 @@
|
||||
<Title>@Localizer["Login"]</Title>
|
||||
|
||||
@* 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). *@
|
||||
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). 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">
|
||||
|
||||
@if (!CascadingState.IsSignedIn)
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
|
||||
<div class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16">
|
||||
@if (setupPending)
|
||||
{
|
||||
<a href="setup" class="flex items-center gap-3 w-full p-4 rounded-xl neo-raised neo-sm ring-2 ring-primary" data-setup-offer>
|
||||
<i class="ion-md-construct text-2xl text-primary" aria-hidden="true"></i>
|
||||
<span class="flex flex-col">
|
||||
<b>@Localizer["This server has no administrator yet"]</b>
|
||||
<span class="text-sm">@Localizer["Set it up: the first login made becomes its administrator."]</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
<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>
|
||||
<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">
|
||||
@@ -27,6 +70,7 @@
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
}
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
@@ -37,13 +81,31 @@
|
||||
}
|
||||
<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)
|
||||
{
|
||||
<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">
|
||||
<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-no-persona>
|
||||
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
|
||||
<p class="text-sm">@Localizer["This login has no persona to act as. Create one on PrivaPub first."]</p>
|
||||
<p class="text-sm">@Localizer["This login has no persona to act as yet: make one in Settings."]</p>
|
||||
<DButton Href="settings" Color="DColor.Primary">@Localizer["Settings"]</DButton>
|
||||
<DButton OnClick="SignOut">@Localizer["Logout"]</DButton>
|
||||
</div>
|
||||
}
|
||||
@@ -77,29 +139,69 @@
|
||||
|
||||
@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; }
|
||||
|
||||
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()
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -119,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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -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">
|
||||
|
||||
@@ -40,8 +42,9 @@
|
||||
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
|
||||
</p>
|
||||
}
|
||||
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
|
||||
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" style="@HeaderStyle"></div>
|
||||
@* not overflow-hidden: the Lists menu hangs out of it; the header picture takes the card's top corners itself *@
|
||||
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs" data-account-id="@account.Id">
|
||||
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center rounded-t-xl" style="@HeaderStyle"></div>
|
||||
<div class="flex flex-wrap items-end gap-3 px-4 -mt-10">
|
||||
@* alt="": the name is next to it. *@
|
||||
<img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100"
|
||||
@@ -66,7 +69,7 @@
|
||||
}
|
||||
@if (relationship?.Following == true)
|
||||
{
|
||||
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm"
|
||||
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm" SheetOnNarrow="true"
|
||||
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-64 bg-base-100 neo-raised neo-xs z-20" title="@Localizer["Lists"]">
|
||||
<Trigger>
|
||||
<i class="ion-md-list"></i><span>@Localizer["Lists"]</span>
|
||||
@@ -141,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>
|
||||
@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>
|
||||
@@ -190,6 +214,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] ListService ListService { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||
@@ -210,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 = [];
|
||||
|
||||
@@ -328,6 +355,11 @@
|
||||
|
||||
async Task Do(AccountAction action)
|
||||
{
|
||||
var name = account.Name ?? account.Acct;
|
||||
if (action is AccountAction.Block or AccountAction.Mute
|
||||
&& !await Confirm.Ask(action == AccountAction.Block ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
|
||||
Localizer[action == AccountAction.Block ? "Block" : "Mute"], action == AccountAction.Block))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await AccountService.Act(account.Id, action);
|
||||
isActing = false;
|
||||
@@ -352,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);
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
List<ScheduledStatus> scheduled = [];
|
||||
readonly Dictionary<string, string> moves = [];
|
||||
@@ -129,6 +130,8 @@
|
||||
|
||||
async Task Drop(ScheduledStatus item)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Never publish this post?"], null, Localizer["Never publish it"], true))
|
||||
return;
|
||||
isSaving = true;
|
||||
var result = await StatusService.Unschedule(item.Id);
|
||||
isSaving = false;
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
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 />
|
||||
<label class="flex flex-col gap-2 max-w-md">
|
||||
<span class="text-sm">@Localizer["Language"]</span>
|
||||
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
||||
@@ -130,7 +137,10 @@
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<div class="flex flex-col gap-6">
|
||||
<PersonaArchivePanel />
|
||||
<DataTransfer />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
@page "/setup"
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Set up PrivaPub"]</Title>
|
||||
|
||||
@* The server's first login, made while it has no administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16,
|
||||
first come, first served): it becomes the administrator, with its first persona, and setup closes for good. Everyone
|
||||
else joins through a group's invitation (Join). *@
|
||||
<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 (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else 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">
|
||||
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
|
||||
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else if (!isPending)
|
||||
{
|
||||
<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-setup-done>
|
||||
<h1 class="text-xl font-bold">@Localizer["This server is set up"]</h1>
|
||||
<p class="text-sm">@Localizer["It has an administrator already: sign in, or join with a group's invitation."]</p>
|
||||
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2">
|
||||
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
|
||||
<DButton Href="join">@Localizer["Join with an invitation"]</DButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="form" OnValidSubmit="SetUp" 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-setup>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Set up PrivaPub"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["This server has no administrator yet. The first login made here becomes it, with its first persona; after that, joining takes a group's invitation."]</p>
|
||||
|
||||
<b class="text-sm">@Localizer["Your login"]</b>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Username"]</span>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-setup-username>
|
||||
<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="new-password" data-setup-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 password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-setup-repeat>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<b class="text-sm">@Localizer["Your first persona"]</b>
|
||||
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-setup-persona-username>
|
||||
<Prefix>@@</Prefix>
|
||||
</DInput>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
|
||||
placeholder="@Localizer["Display name"]" data-setup-persona-name />
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-setup-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Set up PrivaPub"]</span>
|
||||
</DButton>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
|
||||
readonly SetupForm form = new();
|
||||
string repeatedPassword;
|
||||
string error;
|
||||
bool isPending;
|
||||
bool isWorking;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => isPending = await AuthService.SetupPending();
|
||||
|
||||
async Task SetUp()
|
||||
{
|
||||
error = default;
|
||||
if (form.Password != repeatedPassword)
|
||||
{
|
||||
error = Localizer["The two passwords don't match."];
|
||||
return;
|
||||
}
|
||||
|
||||
isWorking = true;
|
||||
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||
var result = await AuthService.SetUp(form);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
isWorking = false;
|
||||
// set up meanwhile: the page says so
|
||||
isPending = result.StatusCode != (int)System.Net.HttpStatusCode.Forbidden;
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
|
||||
Navigation.NavigateTo("");
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,14 @@
|
||||
{
|
||||
@foreach (var post in ancestors)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 max-w-[calc(100%-1.5rem)]" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
|
||||
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
|
||||
@foreach (var post in descendants)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 max-w-[calc(100%-1.5rem)]" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
+9
-4
@@ -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,11 +44,17 @@ builder.Services.AddOptions()
|
||||
.AddScoped<AccountService>()
|
||||
.AddScoped<PortabilityService>()
|
||||
.AddScoped<AdminService>()
|
||||
.AddScoped<InstanceAdminService>()
|
||||
.AddScoped<AboutService>()
|
||||
.AddScoped<AnnouncementService>()
|
||||
.AddScoped<ArchiveService>()
|
||||
.AddScoped<ListService>()
|
||||
.AddScoped<FilterService>()
|
||||
.AddScoped<DiscoveryService>()
|
||||
.AddScoped<StreamingService>()
|
||||
.AddScoped<PublicNoticeService>()
|
||||
.AddScoped<ConfirmService>()
|
||||
.AddScoped<OverlayService>()
|
||||
.AddScoped<MediaService>()
|
||||
.AddScoped<GroupService>()
|
||||
.AddScoped<InstanceService>()
|
||||
@@ -60,15 +65,15 @@ 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 =>
|
||||
{
|
||||
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
// each request has its own timeout (HttpService.Measure): an upload may take minutes, anything else 100 s
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(serviceProvider =>
|
||||
|
||||
+1336
-67
File diff suppressed because it is too large.
Load diff
+1335
-66
File diff suppressed because it is too large.
Load diff
Generated
+18
@@ -60,6 +60,24 @@ namespace decePubClient.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The {0} field is not a valid date..
|
||||
/// </summary>
|
||||
public static string InvalidDate {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidDate", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The {0} field is not valid..
|
||||
/// </summary>
|
||||
public static string InvalidValue {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidValue", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
|
||||
/// </summary>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-17
@@ -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)
|
||||
@@ -221,10 +213,39 @@ namespace decePubClient.Services
|
||||
: new WebResult { Data = account };
|
||||
}
|
||||
|
||||
/// <summary>Removes the persona's avatar or header (the default shows again); Data is the account as it is now.</summary>
|
||||
public async Task<WebResult> RemovePicture(bool avatar, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Delete(avatar ? APIs.Mastodon.DELETE_ProfileAvatar : APIs.Mastodon.DELETE_ProfileHeader,
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
||||
return account?.Id is null
|
||||
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||
: new WebResult { Data = account };
|
||||
}
|
||||
|
||||
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
|
||||
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);
|
||||
@@ -265,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
|
||||
{
|
||||
@@ -276,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);
|
||||
|
||||
+143
-5
@@ -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)
|
||||
{
|
||||
@@ -62,6 +77,74 @@ namespace decePubClient.Services
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The server's backups, what runs and the last restore; null on failure.</summary>
|
||||
public async Task<ViewBackups> Backups(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_AdminBackups, cancellationToken: cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.DefaultReadFromJsonAsync<ViewBackups>(cancellationToken);
|
||||
|
||||
await LogFailure(response, nameof(Backups), cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Starts a backup; it runs on while the list says so.</summary>
|
||||
public async Task<WebResult> BackUp(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackup, cancellationToken: cancellationToken);
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WebResult> DeleteBackup(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackup(id), cancellationToken: cancellationToken);
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>A download link for a backup, for the password; Data is the ViewBackupTicket.</summary>
|
||||
public async Task<WebResult> BackupTicket(string id, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupTicket(id), new BackupPasswordForm { Password = password },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupTicket>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Asks for a backup to be restored: PrivaPub stops within seconds and restores it as it starts again.</summary>
|
||||
public async Task<WebResult> RestoreBackup(string id, RestoreBackupForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupRestore(id), form, cancellationToken: cancellationToken);
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>An upload to send a backup's pieces to (transfer.js); Data is the ViewBackupUpload.</summary>
|
||||
public async Task<WebResult> StartBackupUpload(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackupUpload, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupUpload>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>The whole upload read into a backup; Data is the ViewBackup.</summary>
|
||||
public async Task<WebResult> FinishBackupUpload(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupUploadFinish(id), cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackup>(cancellationToken) };
|
||||
}
|
||||
|
||||
public async Task<WebResult> CancelBackupUpload(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackupUpload(id), cancellationToken: cancellationToken);
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
|
||||
@@ -94,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user