Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da6a398d7 | ||
|
|
356ea7b430 | ||
|
|
48df316256 | ||
|
|
dc2190e271 | ||
|
|
70cb389537 | ||
|
|
9d662f702b | ||
|
|
e94e32d198 | ||
|
|
e88f4afc45 | ||
|
|
eade4e399a | ||
|
|
a3461c3ece | ||
|
|
ed3bf57e5b | ||
|
|
b66d7cf545 | ||
|
|
47ff60f57b | ||
|
|
18331531b8 | ||
|
|
f1ac5eaad2 | ||
|
|
5522fe4fbb | ||
|
|
7b1bfa71c0 | ||
|
|
da8f4ffcab | ||
|
|
89cef25def | ||
|
|
765220059d | ||
|
|
dd38002682 | ||
|
|
1c9773bab0 | ||
|
|
931b7fcff1 | ||
|
|
8f15d0d5c1 | ||
|
|
071bcce4b0 | ||
|
|
b3bb2a28c3 | ||
|
|
d2ab5e7b40 | ||
|
|
0b237b1951 | ||
|
|
ffd4acd3d0 | ||
|
|
63f2517f5b | ||
|
|
f1cb90ac03 | ||
|
|
6731077191 | ||
|
|
87f3f2402a | ||
|
|
38336f9869 | ||
|
|
63d83810d7 | ||
|
|
88e55e671c | ||
|
|
b7ff917d8c | ||
|
|
24748168c0 | ||
|
|
511edf3591 | ||
|
|
658683f816 | ||
|
|
a0f07abf8c | ||
|
|
bb68ff8d5e | ||
|
|
03824babbc | ||
|
|
23dbc68fb4 | ||
|
|
5a7e1804a9 | ||
|
|
2f29872745 | ||
|
|
da98addf76 | ||
|
|
a5178a67b1 | ||
|
|
f9de55ffd5 | ||
|
|
228708c328 | ||
|
|
0bbab720a5 | ||
|
|
2024798732 | ||
|
|
5e8fdc43af | ||
|
|
6f8472bc9f | ||
|
|
61631bb0a7 | ||
|
|
d7d836ba39 | ||
|
|
03f4f2ad53 | ||
|
|
5c11727f58 | ||
|
|
a4d9831691 | ||
|
|
963565b316 | ||
|
|
6d5cf17723 | ||
|
|
9a752d9288 | ||
|
|
e2491de16e | ||
|
|
011bafb6af | ||
|
|
0f68e74e38 | ||
|
|
69d0a641fe |
No files matched your search
@@ -55,6 +55,9 @@ jobs:
|
|||||||
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
||||||
done
|
done
|
||||||
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
|
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
|
||||||
|
for f in appsettings.Development.json appsettings.Pasture.json; do
|
||||||
|
[ ! -e "$W/$f" ] || { echo "::error::publish output holds $f, which points the app at a test server"; exit 1; }
|
||||||
|
done
|
||||||
echo "publish OK, $(du -sh "$W" | cut -f1)"
|
echo "publish OK, $(du -sh "$W" | cut -f1)"
|
||||||
|
|
||||||
- name: Snapshot the live directory
|
- name: Snapshot the live directory
|
||||||
|
|||||||
@@ -367,3 +367,7 @@ FodyWeavers.xsd
|
|||||||
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
|
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
|
||||||
.tools/
|
.tools/
|
||||||
wwwroot/css/app.css
|
wwwroot/css/app.css
|
||||||
|
|
||||||
|
# the e2e tests: the published app they serve and the browser state they keep
|
||||||
|
tests/e2e/.publish/
|
||||||
|
tests/e2e/.state/
|
||||||
@@ -43,7 +43,65 @@ dotnet run # dev server: https://loc
|
|||||||
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
|
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
|
||||||
```
|
```
|
||||||
|
|
||||||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There are no tests and no linter.
|
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||||||
|
|
||||||
|
## Testing: end to end against the pasture
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
|
||||||
|
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
|
||||||
|
time and published only then; the deploy fails if it ever ships.
|
||||||
|
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
|
||||||
|
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
|
||||||
|
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
|
||||||
|
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
|
||||||
|
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
|
||||||
|
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
|
||||||
|
town's report (`out/client.jsonl`).
|
||||||
|
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
||||||
|
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
||||||
|
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
|
||||||
|
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
|
||||||
|
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
|
||||||
|
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
|
||||||
|
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
|
||||||
|
- sideways scroll, unless the strip is marked `data-scroll-x`;
|
||||||
|
- boxes past the screen's sides;
|
||||||
|
- open menus, sheets and dialogs that are cut or outside the screen;
|
||||||
|
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
|
||||||
|
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
|
||||||
|
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
|
||||||
|
- control text under 12px.
|
||||||
|
|
||||||
|
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
|
||||||
|
`client.mobile.touch.*`, with the phone as the ref.
|
||||||
|
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
|
||||||
|
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
|
||||||
|
- `DesktopUnchangedTests` holds desktop's control sizes.
|
||||||
|
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
|
||||||
|
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
|
||||||
|
turned to the subsolar point.
|
||||||
|
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
|
||||||
|
to the banner.
|
||||||
|
- An idle globe draws no frame.
|
||||||
|
|
||||||
|
Photos and stats go to `tests/e2e/out/globe/`.
|
||||||
|
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
|
||||||
|
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
|
||||||
|
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
|
||||||
|
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
||||||
|
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
||||||
|
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
||||||
|
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
|
||||||
|
|
||||||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||||||
|
|
||||||
@@ -87,6 +145,7 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
- picking a hue leaves grey.
|
- picking a hue leaves grey.
|
||||||
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
||||||
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
||||||
|
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
|
||||||
- `Styles/neo.css` holds the neumorphic layer:
|
- `Styles/neo.css` holds the neumorphic layer:
|
||||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||||
old `neomorph is-nxsmall` classes);
|
old `neomorph is-nxsmall` classes);
|
||||||
@@ -95,9 +154,63 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||||
shadow with `shadow-none!`.
|
shadow with `shadow-none!`.
|
||||||
|
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
|
||||||
|
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
|
||||||
|
file input's own button (Choose File, Browse…) is raised like the app's buttons.
|
||||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||||
every collapse disappears.
|
every collapse disappears.
|
||||||
|
|
||||||
|
## Phones
|
||||||
|
|
||||||
|
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
|
||||||
|
- **Width and pointer are separate.**
|
||||||
|
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||||
|
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||||
|
`pointer-coarse:` at a call site.
|
||||||
|
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
|
||||||
|
- 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`). The
|
||||||
|
phone sweep fails anything less.
|
||||||
|
- **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, leaving a group, dropping a scheduled post and
|
||||||
|
signing out.
|
||||||
|
- **On a touch screen:**
|
||||||
|
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
||||||
|
pinned to the globe with a mouse only;
|
||||||
|
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||||
|
- pictures open in `Components/MediaViewer.razor`.
|
||||||
|
- **The feed on a phone:**
|
||||||
|
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
|
||||||
|
stats' Details are in More, and the author's picture is in the header;
|
||||||
|
- a new post starts from a floating button;
|
||||||
|
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
||||||
|
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||||
|
|
||||||
## daisyUI Blazor components (`Components/Daisy/`)
|
## daisyUI Blazor components (`Components/Daisy/`)
|
||||||
|
|
||||||
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
||||||
@@ -153,6 +266,7 @@ supplies:
|
|||||||
messages.
|
messages.
|
||||||
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
||||||
`AdditionalError` and `AdditionalErrors`.
|
`AdditionalError` and `AdditionalErrors`.
|
||||||
|
- `ConfirmService` asks before what can't be undone (see **Phones**).
|
||||||
|
|
||||||
**HTTP (`Services/IHttpService.cs`).**
|
**HTTP (`Services/IHttpService.cs`).**
|
||||||
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
||||||
@@ -162,10 +276,12 @@ supplies:
|
|||||||
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
||||||
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
||||||
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
||||||
- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` /
|
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||||
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub
|
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||||
redirects it).
|
PrivaPub redirects it).
|
||||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||||
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
|
`HttpClient`'s own is off.
|
||||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||||
@@ -183,8 +299,115 @@ supplies:
|
|||||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||||
threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads),
|
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||||||
`GroupService`, `InstanceService` and `ProvenanceService`.
|
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||||||
|
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
||||||
|
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
|
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
|
||||||
|
`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
|
||||||
|
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`
|
||||||
|
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`.
|
||||||
|
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
||||||
|
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
||||||
|
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
||||||
|
persona pins and unpins its own public and unlisted posts from the card's menu.
|
||||||
|
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
|
||||||
|
linking the new account's profile.
|
||||||
|
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
||||||
|
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
|
||||||
|
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
|
||||||
|
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
|
||||||
|
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
|
||||||
|
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
||||||
|
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
|
||||||
|
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
|
||||||
|
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
|
||||||
|
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out.
|
||||||
|
Only a group's owner and moderators see who is in it.
|
||||||
|
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
|
||||||
|
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
|
||||||
|
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
|
||||||
|
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
|
||||||
|
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
|
||||||
|
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
|
||||||
|
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
|
||||||
|
its media (blur).
|
||||||
|
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
|
||||||
|
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
||||||
|
post opened in its thread shows whole.
|
||||||
|
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
||||||
|
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
|
||||||
|
a poll instead of media.
|
||||||
|
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
|
||||||
|
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
|
||||||
|
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
|
||||||
|
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||||
|
- the ids are kept, so a post that fails never uploads its media again;
|
||||||
|
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||||
|
- a description changed after the upload is PUT before the post.
|
||||||
|
- **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
|
||||||
|
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 (streamed from the file,
|
||||||
|
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||||
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||||
|
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
|
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
|
||||||
|
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
|
||||||
|
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
|
||||||
|
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
|
||||||
|
with progress, a report and Stop. Imported posts are delivered to no one.
|
||||||
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||||
|
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
||||||
|
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||||
|
- **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.
|
||||||
|
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||||
|
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||||
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
|
it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too.
|
||||||
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
@@ -195,19 +418,62 @@ supplies:
|
|||||||
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
|
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
|
||||||
since the API may be reached under another name.
|
since the API may be reached under another name.
|
||||||
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
||||||
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
`GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll,
|
||||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
|
||||||
|
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
|
||||||
|
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
|
||||||
|
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
|
||||||
|
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
|
||||||
|
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
|
||||||
|
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
|
||||||
|
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
|
||||||
|
card shows from outside it must replace one of those objects or be added to the key; mutating one in place leaves the
|
||||||
|
card as it was. Without these a feed took seven minutes to reach a thousand cards and held 900 MB at two thousand.
|
||||||
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
||||||
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. CDN-fronted
|
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||||
posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server
|
time; `wake()` before anything that animates).
|
||||||
was before the CDN. The posts'
|
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||||
places reach it in one call per feed change; cards are matched by `data-post-id`.
|
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
|
||||||
|
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
|
||||||
|
- **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's
|
||||||
|
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||||
|
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||||
|
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||||
|
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
|
||||||
|
top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
|
||||||
|
the points.
|
||||||
|
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
|
||||||
|
a table). The globe has no tooltip. What it draws:
|
||||||
|
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
|
||||||
|
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
|
||||||
|
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
|
||||||
|
- the tropics, the polar circles and the CDN ring;
|
||||||
|
- the view's centre and spread, and rings around the posts in view;
|
||||||
|
- a small orrery in the top left corner, re-anchored to the camera before each render.
|
||||||
|
|
||||||
|
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
|
||||||
|
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
|
||||||
|
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
|
||||||
|
already made, so an idle globe still draws nothing.
|
||||||
|
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
|
||||||
|
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
|
||||||
|
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
|
||||||
|
classes, only the ring and the legend remain.
|
||||||
|
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
|
||||||
|
gets everything.
|
||||||
|
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||||
|
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||||
|
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||||
|
Earth). The label renderer owns `display`, so labels hide through `visibility`.
|
||||||
|
- CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring
|
||||||
|
where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by
|
||||||
|
`data-post-id`.
|
||||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera,
|
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||||
points and frame rate.
|
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
|
||||||
|
|
||||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
using PrivaPub.ClientModels.Group;
|
using PrivaPub.ClientModels.Group;
|
||||||
using PrivaPub.ClientModels.Post;
|
using PrivaPub.ClientModels.Post;
|
||||||
using PrivaPub.ClientModels.User;
|
using PrivaPub.ClientModels.User;
|
||||||
@@ -22,6 +23,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(PageSettings))]
|
[JsonSerializable(typeof(PageSettings))]
|
||||||
[JsonSerializable(typeof(AuthData))]
|
[JsonSerializable(typeof(AuthData))]
|
||||||
[JsonSerializable(typeof(FeedFilter))]
|
[JsonSerializable(typeof(FeedFilter))]
|
||||||
|
[JsonSerializable(typeof(PublicActionNotices))]
|
||||||
[JsonSerializable(typeof(ServerInfo))]
|
[JsonSerializable(typeof(ServerInfo))]
|
||||||
[JsonSerializable(typeof(List<ServerInfo>))]
|
[JsonSerializable(typeof(List<ServerInfo>))]
|
||||||
[JsonSerializable(typeof(UploadMedia))]
|
[JsonSerializable(typeof(UploadMedia))]
|
||||||
@@ -30,14 +32,42 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ValidationErrors))]
|
[JsonSerializable(typeof(ValidationErrors))]
|
||||||
[JsonSerializable(typeof(WebResult))]
|
[JsonSerializable(typeof(WebResult))]
|
||||||
[JsonSerializable(typeof(LoginForm))]
|
[JsonSerializable(typeof(LoginForm))]
|
||||||
|
[JsonSerializable(typeof(UserPasswordForm))]
|
||||||
|
[JsonSerializable(typeof(RemoveSelfForm))]
|
||||||
[JsonSerializable(typeof(JwtUser))]
|
[JsonSerializable(typeof(JwtUser))]
|
||||||
[JsonSerializable(typeof(ViewAvatar))]
|
[JsonSerializable(typeof(ViewAvatar))]
|
||||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||||
|
[JsonSerializable(typeof(InsertAvatarForm))]
|
||||||
[JsonSerializable(typeof(ViewGroup))]
|
[JsonSerializable(typeof(ViewGroup))]
|
||||||
[JsonSerializable(typeof(List<ViewGroup>))]
|
[JsonSerializable(typeof(List<ViewGroup>))]
|
||||||
|
[JsonSerializable(typeof(ViewGroupMember))]
|
||||||
|
[JsonSerializable(typeof(List<ViewGroupMember>))]
|
||||||
|
[JsonSerializable(typeof(InsertGroupForm))]
|
||||||
|
[JsonSerializable(typeof(UpdateGroupForm))]
|
||||||
|
[JsonSerializable(typeof(JoinGroupForm))]
|
||||||
|
[JsonSerializable(typeof(GroupMembershipForm))]
|
||||||
[JsonSerializable(typeof(ViewPost))]
|
[JsonSerializable(typeof(ViewPost))]
|
||||||
[JsonSerializable(typeof(List<ViewPost>))]
|
[JsonSerializable(typeof(List<ViewPost>))]
|
||||||
[JsonSerializable(typeof(InsertPostForm))]
|
[JsonSerializable(typeof(InsertPostForm))]
|
||||||
|
[JsonSerializable(typeof(DeletePostForm))]
|
||||||
|
[JsonSerializable(typeof(ViewReport))]
|
||||||
|
[JsonSerializable(typeof(List<ViewReport>))]
|
||||||
|
[JsonSerializable(typeof(DomainBlockForm))]
|
||||||
|
[JsonSerializable(typeof(ViewDomainBlock))]
|
||||||
|
[JsonSerializable(typeof(List<ViewDomainBlock>))]
|
||||||
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
|
[JsonSerializable(typeof(ViewCrawler))]
|
||||||
|
[JsonSerializable(typeof(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))]
|
||||||
public partial class ClientJsonContext : JsonSerializerContext
|
public partial class ClientJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -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,138 @@
|
|||||||
|
@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. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-reports>
|
||||||
|
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (reports.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var report in reports)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
|
||||||
|
@if (report.Forwarded)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
|
||||||
|
@report.CreatedAt.GetPassedTime(Localizer)
|
||||||
|
</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
@if (!report.IsResolved)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
|
||||||
|
data-report-resolve>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (report.ResolvedAt is { } resolvedAt)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["About"]</b>
|
||||||
|
@if (report.TargetAccountId is { Length: > 0 } targetId)
|
||||||
|
{
|
||||||
|
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="break-all">@report.TargetActorURI</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["From"]</b>
|
||||||
|
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
|
||||||
|
</p>
|
||||||
|
@if (report.Comment is { Length: > 0 } comment)
|
||||||
|
{
|
||||||
|
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
|
||||||
|
}
|
||||||
|
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
@foreach (var postId in report.PostIds)
|
||||||
|
{
|
||||||
|
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
|
||||||
|
}
|
||||||
|
@* a report from another server names posts by their ids there when we hold none of them *@
|
||||||
|
@if (report.PostIds.Count == 0)
|
||||||
|
{
|
||||||
|
@foreach (var uri in report.ObjectURIs)
|
||||||
|
{
|
||||||
|
<span class="break-all opacity-70">@uri</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewReport> reports = [];
|
||||||
|
bool resolved;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => reports = await AdminService.Reports(resolved);
|
||||||
|
|
||||||
|
async Task ShowResolved(bool value)
|
||||||
|
{
|
||||||
|
resolved = value;
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string CategoryName(string category) => category switch
|
||||||
|
{
|
||||||
|
"spam" => Localizer["Spam"],
|
||||||
|
"legal" => Localizer["Illegal"],
|
||||||
|
"violation" => Localizer["Breaks the rules"],
|
||||||
|
_ => Localizer["Other"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Resolve(ViewReport report)
|
||||||
|
{
|
||||||
|
busy.Add(report.ID);
|
||||||
|
var result = await AdminService.Resolve(report.ID);
|
||||||
|
busy.Remove(report.ID);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.Remove(report);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report resolved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@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. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-server-blocks>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-server-block-domain>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
|
||||||
|
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
|
||||||
|
</div>
|
||||||
|
<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))">
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-server-block="@block.Domain">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@block.Domain</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@(block.Severity == "Suspend" ? DColor.Error : DColor.Warning)">
|
||||||
|
@(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"])
|
||||||
|
</DBadge>
|
||||||
|
@if (block.RejectMedia)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
|
||||||
|
}
|
||||||
|
<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="() => Unblock(block)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (block.PublicComment is { Length: > 0 } publicComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs">@publicComment</p>
|
||||||
|
}
|
||||||
|
@if (block.PrivateComment is { Length: > 0 } privateComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70"><i class="ion-md-lock"></i> @privateComment</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewDomainBlock> blocks = [];
|
||||||
|
DomainBlockForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => blocks = await AdminService.ServerBlocks();
|
||||||
|
|
||||||
|
async Task Block()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AdminService.BlockServer(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = (ViewDomainBlock)result.Data;
|
||||||
|
blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)];
|
||||||
|
form = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Unblock(ViewDomainBlock block)
|
||||||
|
{
|
||||||
|
busy.Add(block.Domain);
|
||||||
|
var result = await AdminService.UnblockServer(block.Domain);
|
||||||
|
busy.Remove(block.Domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.Remove(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
@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. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-statistics>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Period"]</span>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
|
||||||
|
@foreach (var period in Periods)
|
||||||
|
{
|
||||||
|
<option value="@period">@Localizer["{0} days", period]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Servers by"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
|
||||||
|
<option value="traffic">@Localizer["Traffic"]</option>
|
||||||
|
<option value="failures">@Localizer["Failures"]</option>
|
||||||
|
<option value="latency">@Localizer["Latency"]</option>
|
||||||
|
<option value="host">@Localizer["Name"]</option>
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (overview is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
|
||||||
|
<DStat>
|
||||||
|
<Heading>@Localizer["Deliveries that arrived"]</Heading>
|
||||||
|
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
|
||||||
|
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
|
||||||
|
<ChildContent>@overview.Hosts.Active</ChildContent>
|
||||||
|
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading>@Localizer["Accounts seen"]</Heading>
|
||||||
|
<ChildContent>@overview.Accounts</ChildContent>
|
||||||
|
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
|
||||||
|
</DStat>
|
||||||
|
</DStats>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-xs">
|
||||||
|
@foreach (var (title, counts) in Outcomes)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 min-w-40">
|
||||||
|
<b class="text-sm">@title</b>
|
||||||
|
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
|
||||||
|
{
|
||||||
|
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hosts is not null)
|
||||||
|
{
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["server"]</th>
|
||||||
|
<th>@Localizer["software"]</th>
|
||||||
|
<th class="text-right">@Localizer["In"]</th>
|
||||||
|
<th class="text-right">@Localizer["Out"]</th>
|
||||||
|
<th class="text-right">@Localizer["Refused"]</th>
|
||||||
|
<th class="text-right">@Localizer["Failures"]</th>
|
||||||
|
<th class="text-right">p50</th>
|
||||||
|
<th>@Localizer["Delivery"]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var host in hosts.Hosts)
|
||||||
|
{
|
||||||
|
<tr data-statistics-host="@host.Host">
|
||||||
|
<td class="max-w-48 truncate">@host.Host</td>
|
||||||
|
<td>@host.Software @host.Version</td>
|
||||||
|
<td class="text-right tabular-nums">@host.In</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Out</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Refused</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Failures</td>
|
||||||
|
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
|
||||||
|
<td>
|
||||||
|
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (host.Delivery?.ConsecutiveFailures > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
@if (hosts.Total > PageSize)
|
||||||
|
{
|
||||||
|
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
|
||||||
|
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (crawler is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
||||||
|
<b class="text-sm">@Localizer["Crawler"]</b>
|
||||||
|
<span>
|
||||||
|
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
|
||||||
|
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
|
||||||
|
</span>
|
||||||
|
@foreach (var host in crawler.Recent)
|
||||||
|
{
|
||||||
|
<span class="flex flex-wrap gap-2">
|
||||||
|
<span class="truncate max-w-48">@host.Host</span>
|
||||||
|
<span class="opacity-70">@host.Software</span>
|
||||||
|
@if (host.LastCrawledAt is { } crawled)
|
||||||
|
{
|
||||||
|
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
|
||||||
|
}
|
||||||
|
@if (host.CrawlError is { Length: > 0 } crawlError)
|
||||||
|
{
|
||||||
|
<span class="text-error">@crawlError</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
const int PageSize = 50;
|
||||||
|
static readonly int[] Periods = [1, 7, 30, 90];
|
||||||
|
|
||||||
|
ViewStatisticsOverview overview;
|
||||||
|
ViewHostsPage hosts;
|
||||||
|
ViewCrawler crawler;
|
||||||
|
int days = 30;
|
||||||
|
string sort = "traffic";
|
||||||
|
int page = 1;
|
||||||
|
|
||||||
|
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
|
||||||
|
[
|
||||||
|
(Localizer["Received"], overview.Inbound),
|
||||||
|
(Localizer["Delivered"], overview.Outbound),
|
||||||
|
(Localizer["Channels"], overview.Channels)
|
||||||
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
overview = await AdminService.Overview(days);
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
crawler = await AdminService.Crawler();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
||||||
|
|
||||||
|
async Task ChangeDays(int value)
|
||||||
|
{
|
||||||
|
days = value;
|
||||||
|
page = 1;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeSort(string value)
|
||||||
|
{
|
||||||
|
sort = value;
|
||||||
|
page = 1;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangePage(int value)
|
||||||
|
{
|
||||||
|
page = value;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
+460
-51
@@ -1,8 +1,14 @@
|
|||||||
|
@using System.Text.RegularExpressions
|
||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
|
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
|
||||||
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
||||||
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. *@
|
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
|
||||||
|
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
|
||||||
|
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
|
||||||
|
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
|
||||||
|
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
|
||||||
|
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
|
||||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@@ -13,38 +19,127 @@
|
|||||||
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
||||||
</DInput>
|
</DInput>
|
||||||
}
|
}
|
||||||
<div class="relative">
|
<div class="flex flex-col gap-1">
|
||||||
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
|
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||||
placeholder="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
|
placeholder="@Placeholder" rows="3" data-compose />
|
||||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
@* under the words, not over their last line *@
|
||||||
|
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||||
|
@foreach (var account in suggestedAccounts)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||||
|
@* alt="": the handle is next to it. *@
|
||||||
|
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="truncate">@("@" + account.Acct)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@foreach (var tag in suggestedTags)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Quoting is not null)
|
||||||
|
{
|
||||||
|
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (form.HasPoll)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
|
||||||
|
@for (var i = 0; i < form.PollOptions.Count; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
|
||||||
|
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
|
||||||
|
@if (form.PollOptions.Count > 2)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if (form.PollOptions.Count < 4)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
|
||||||
|
}
|
||||||
|
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
|
||||||
|
@foreach (var (seconds, label) in PollLengths)
|
||||||
|
{
|
||||||
|
<option value="@seconds">@label</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
<label class="label text-xs">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
|
||||||
|
<span>@Localizer["Several choices"]</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (form.Media.Count > 0)
|
@if (form.Media.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2" data-compose-media>
|
||||||
@foreach (var media in form.Media)
|
@foreach (var media in form.Media)
|
||||||
{
|
{
|
||||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
|
||||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
@if (media.Preview is { Length: > 0 } preview)
|
||||||
{
|
{
|
||||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
|
||||||
}
|
}
|
||||||
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
<p class="inline-flex items-center gap-2 text-xs">
|
||||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
@switch (media.State)
|
||||||
|
{
|
||||||
|
case UploadState.Uploading:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Processing:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Failed:
|
||||||
|
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
|
||||||
|
@if (media.File is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
|
||||||
|
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
||||||
|
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||||
</div>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
|
||||||
<i class="ion-md-trash text-base text-error"></i>
|
<i class="ion-md-trash text-base text-error"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (AskingAltText)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
|
||||||
|
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
|
||||||
|
</div>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (form.IsLocated)
|
@if (form.IsLocated)
|
||||||
@@ -69,34 +164,47 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if (!form.IsLocated && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
{
|
{
|
||||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
|
||||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
|
||||||
|
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||||
<Trigger>
|
<Trigger>
|
||||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
|
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@foreach (var messageType in Enum.GetValues<MessageType>())
|
@foreach (var messageType in Enum.GetValues<MessageType>())
|
||||||
{
|
{
|
||||||
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
|
||||||
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer[messageType.ToString()]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DDropdown>
|
</DDropdown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
|
{
|
||||||
|
@* the language the post is written in, as each language names itself *@
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-auto" @bind-Value="form.Language" title="@Localizer["Language"]" data-compose-language>
|
||||||
|
@foreach (var (languageCode, languageName) in Languages.All)
|
||||||
|
{
|
||||||
|
<option value="@languageCode">@languageName</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
}
|
||||||
|
|
||||||
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
|
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
|
||||||
<i class="ion-md-warning text-base"></i>
|
<i class="ion-md-warning text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
|
||||||
@if (!form.IsLocated && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||||
{
|
{
|
||||||
@* A label styled as a button opens the hidden file input. *@
|
@* A label styled as a button opens the hidden file input. *@
|
||||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||||
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||||
<i class="ion-md-attach text-base"></i>
|
<i class="ion-md-attach text-base"></i>
|
||||||
@if (form.Media.Count != 0)
|
@if (form.Media.Count != 0)
|
||||||
{
|
{
|
||||||
@@ -115,12 +223,26 @@
|
|||||||
</DSelect>
|
</DSelect>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (InReplyTo is null && GroupId is null)
|
@if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
|
||||||
|
<i class="ion-md-stats text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
|
||||||
{
|
{
|
||||||
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
||||||
<i class="ion-md-locate text-base"></i>
|
<i class="ion-md-locate text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
|
||||||
|
<i class="ion-md-time text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
||||||
@@ -128,14 +250,28 @@
|
|||||||
{
|
{
|
||||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
}
|
}
|
||||||
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
<span>@SubmitLabel</span>
|
||||||
<i class="ion-md-send"></i>
|
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span>@Localizer["Publish it on"]</span>
|
||||||
|
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
|
||||||
|
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
|
||||||
|
</label>
|
||||||
|
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
@@ -143,37 +279,169 @@
|
|||||||
[Parameter] public Status InReplyTo { get; set; }
|
[Parameter] public Status InReplyTo { get; set; }
|
||||||
/// <summary>The community or circle the post is for.</summary>
|
/// <summary>The community or circle the post is for.</summary>
|
||||||
[Parameter] public string GroupId { get; set; }
|
[Parameter] public string GroupId { get; set; }
|
||||||
|
/// <summary>The post this one quotes (shown under the words).</summary>
|
||||||
|
[Parameter] public Status Quoting { get; set; }
|
||||||
|
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
|
||||||
|
[Parameter] public Status Editing { get; set; }
|
||||||
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
|
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
|
||||||
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
|
||||||
|
|
||||||
ComposeForm form = new();
|
ComposeForm form = new();
|
||||||
List<string> fileErrors = [];
|
List<string> fileErrors = [];
|
||||||
|
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
|
||||||
|
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
|
||||||
|
List<Account> suggestedAccounts = [];
|
||||||
|
List<StatusTag> suggestedTags = [];
|
||||||
|
// the newest lookup: an older one's answer comes too late to show
|
||||||
|
int suggesting;
|
||||||
string error;
|
string error;
|
||||||
bool isPublishing;
|
bool isPublishing;
|
||||||
|
|
||||||
int ContentLength => form.Content?.Length ?? 0;
|
int ContentLength => form.Content?.Length ?? 0;
|
||||||
|
|
||||||
|
// what is being written, once typing pauses, among the accounts and hashtags this server knows
|
||||||
|
async Task Typed()
|
||||||
|
{
|
||||||
|
var typed = Typing.Match(form.Content ?? "");
|
||||||
|
var lookup = ++suggesting;
|
||||||
|
if (!typed.Success || typed.Groups[2].Length < 2)
|
||||||
|
{
|
||||||
|
suggestedAccounts = [];
|
||||||
|
suggestedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Task.Delay(300);
|
||||||
|
if (lookup != suggesting)
|
||||||
|
return;
|
||||||
|
var hashtags = typed.Groups[1].Value == "#";
|
||||||
|
var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
|
||||||
|
if (lookup != suggesting)
|
||||||
|
return;
|
||||||
|
suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
|
||||||
|
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// the mention or hashtag being written, written out
|
||||||
|
void Complete(string written)
|
||||||
|
{
|
||||||
|
var typed = Typing.Match(form.Content ?? "");
|
||||||
|
suggesting++;
|
||||||
|
suggestedAccounts = [];
|
||||||
|
suggestedTags = [];
|
||||||
|
if (!typed.Success)
|
||||||
|
return;
|
||||||
|
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||||
|
focusPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||||
|
: Quoting is not null ? Localizer["Say something about it"]
|
||||||
|
: InReplyTo is null ? Localizer["What's happening?"]
|
||||||
|
: Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct];
|
||||||
|
|
||||||
|
string SubmitLabel => Editing is not null ? Localizer["Save"]
|
||||||
|
: form.IsScheduled ? Localizer["Schedule"]
|
||||||
|
: Quoting is not null ? Localizer["Quote"]
|
||||||
|
: Localizer[InReplyTo is null ? "Post" : "Reply"];
|
||||||
|
|
||||||
|
(int Seconds, string Label)[] PollLengths =>
|
||||||
|
[
|
||||||
|
(300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]),
|
||||||
|
(86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"])
|
||||||
|
];
|
||||||
|
|
||||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||||
|
|
||||||
long MaximumFileSize => form.MediaType switch
|
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
|
||||||
|
MediaConfiguration limits = new();
|
||||||
|
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
|
||||||
|
bool askingAltText;
|
||||||
|
bool altTextConfirmed;
|
||||||
|
|
||||||
|
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
|
||||||
|
|
||||||
|
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
|
||||||
|
|
||||||
|
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
|
||||||
|
|
||||||
|
bool OfKind(string type) => form.MediaType switch
|
||||||
{
|
{
|
||||||
MediaType.Images => 16_777_216,
|
MediaType.Images => type.StartsWith("image/"),
|
||||||
MediaType.Audio => 41_943_040,
|
MediaType.Video => type.StartsWith("video/"),
|
||||||
_ => 99_614_720
|
_ => type.StartsWith("audio/")
|
||||||
};
|
};
|
||||||
|
|
||||||
string AcceptedFileTypes => form.MediaType switch
|
// the server's types of the kind chosen, and the extensions a file picker may know them by
|
||||||
|
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
|
||||||
|
|
||||||
|
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||||
_ => default
|
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override void OnInitialized()
|
// what a file is: the browser's word, else its extension's
|
||||||
|
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
|
||||||
|
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
|
||||||
|
|
||||||
|
// a new post starts in the language the persona chose in Settings, else in the reader's
|
||||||
|
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||||
|
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
|
|
||||||
|
// a new post starts with the visibility the persona chose in Settings
|
||||||
|
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => MessageType.FollowersOnly,
|
||||||
|
"unlisted" => MessageType.Unlisted,
|
||||||
|
_ => MessageType.Public
|
||||||
|
};
|
||||||
|
|
||||||
|
DTextarea<string> textarea;
|
||||||
|
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||||
|
bool focusPending = true;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!focusPending)
|
||||||
|
return;
|
||||||
|
focusPending = false;
|
||||||
|
await textarea.Element.FocusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
IsLoading = false;
|
|
||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
|
form.MessageType = DefaultVisibility;
|
||||||
|
form.Language = DefaultLanguage;
|
||||||
|
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||||
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
|
limits = await MediaService.Limits();
|
||||||
|
if (Editing is not null)
|
||||||
|
{
|
||||||
|
var source = await StatusService.Source(Editing.Id);
|
||||||
|
form.Content = source?.Text;
|
||||||
|
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||||
|
form.SpoilerText = source?.SpoilerText;
|
||||||
|
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
|
||||||
|
form.Language = Editing.Language ?? form.Language;
|
||||||
|
form.Sensitive = Editing.Sensitive;
|
||||||
|
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
|
||||||
|
{
|
||||||
|
Id = m.Id,
|
||||||
|
State = UploadState.Ready,
|
||||||
|
Posted = true,
|
||||||
|
AltText = m.Description,
|
||||||
|
SentAltText = m.Description,
|
||||||
|
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
|
||||||
|
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
|
||||||
|
FileName = Path.GetFileName(m.Url ?? string.Empty)
|
||||||
|
})];
|
||||||
|
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
|
||||||
|
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
|
||||||
|
}
|
||||||
|
IsLoading = false;
|
||||||
if (InReplyTo is null)
|
if (InReplyTo is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -185,6 +453,8 @@
|
|||||||
"unlisted" => MessageType.Unlisted,
|
"unlisted" => MessageType.Unlisted,
|
||||||
_ => MessageType.Public
|
_ => MessageType.Public
|
||||||
};
|
};
|
||||||
|
// a reply is written in its thread's language, as a rule
|
||||||
|
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
|
||||||
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
|
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
|
||||||
form.SpoilerText = InReplyTo.SpoilerText;
|
form.SpoilerText = InReplyTo.SpoilerText;
|
||||||
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
|
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
|
||||||
@@ -198,6 +468,14 @@
|
|||||||
form.IsScopeOptionsOpen = false;
|
form.IsScopeOptionsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a scheduled post starts an hour ahead, which the reader moves as it likes
|
||||||
|
void ToggleScheduled()
|
||||||
|
{
|
||||||
|
form.IsScheduled = !form.IsScheduled;
|
||||||
|
if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt))
|
||||||
|
form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
async Task ToggleLocated()
|
async Task ToggleLocated()
|
||||||
{
|
{
|
||||||
form.IsLocated = !form.IsLocated;
|
form.IsLocated = !form.IsLocated;
|
||||||
@@ -227,40 +505,121 @@
|
|||||||
|
|
||||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||||
{
|
{
|
||||||
if (file.Size > MaximumFileSize)
|
var type = TypeOf(file);
|
||||||
|
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
|
||||||
|
{
|
||||||
|
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.Size > MaximumFileSize(type))
|
||||||
{
|
{
|
||||||
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ReadFile(file) is { } media)
|
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
|
||||||
|
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
|
||||||
form.Media.Add(media);
|
form.Media.Add(media);
|
||||||
else
|
altTextConfirmed = false;
|
||||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
Start(media);
|
||||||
|
if (type.StartsWith("image/"))
|
||||||
|
media.Preview = await Thumbnail(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
async Task<string> Thumbnail(IBrowserFile file)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
using var buffer = new MemoryStream();
|
||||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
await stream.CopyToAsync(buffer);
|
||||||
if (file.ContentType.StartsWith("image"))
|
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
|
||||||
return media;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Start(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel = new();
|
||||||
|
media.State = UploadState.Uploading;
|
||||||
|
media.Error = default;
|
||||||
|
media.Done = Upload(media, media.Cancel.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
|
||||||
|
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (uploaded.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = uploaded.ErrorMessage;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var attachment = (MediaAttachment)uploaded.Data;
|
||||||
|
media.Id = attachment.Id;
|
||||||
|
media.SentAltText = attachment.Description;
|
||||||
|
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var processed = await MediaService.Get(media.Id, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (processed.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = processed.ErrorMessage;
|
||||||
|
}
|
||||||
|
else if (processed.Data is MediaAttachment)
|
||||||
|
media.State = UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
||||||
|
void Retry(UploadMedia media) => Start(media);
|
||||||
|
|
||||||
|
void Remove(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
form.Media.Remove(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the reader chose to publish its pictures without descriptions
|
||||||
|
async Task PublishAnyway()
|
||||||
|
{
|
||||||
|
altTextConfirmed = true;
|
||||||
|
askingAltText = false;
|
||||||
|
await Publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media)
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
async Task Publish()
|
async Task Publish()
|
||||||
{
|
{
|
||||||
error = default;
|
error = default;
|
||||||
@@ -269,12 +628,54 @@
|
|||||||
error = Localizer["Missing content, either message or media"];
|
error = Localizer["Missing content, either message or media"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2)
|
||||||
|
{
|
||||||
|
error = Localizer["A poll needs two choices at least."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
|
||||||
|
{
|
||||||
|
askingAltText = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// what is still uploading or processing is waited for
|
||||||
|
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
|
||||||
|
{
|
||||||
|
isPublishing = true;
|
||||||
|
await Task.WhenAll(form.Media.Select(m => m.Done));
|
||||||
|
isPublishing = false;
|
||||||
|
}
|
||||||
|
if (Editing is not null)
|
||||||
|
{
|
||||||
|
isPublishing = true;
|
||||||
|
var edited = await StatusService.Edit(Editing.Id, form);
|
||||||
|
isPublishing = false;
|
||||||
|
if (edited.IsInvalid)
|
||||||
|
{
|
||||||
|
error = edited.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (CascadingState.PersonaId is null)
|
if (CascadingState.PersonaId is null)
|
||||||
{
|
{
|
||||||
error = Localizer["Choose a persona first."];
|
error = Localizer["Choose a persona first."];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.IsScheduled && form.ScheduledAtUtc is not { } at)
|
||||||
|
{
|
||||||
|
error = Localizer["Choose when to publish it."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5))
|
||||||
|
{
|
||||||
|
error = Localizer["A post is scheduled at least five minutes ahead."];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isPublishing = true;
|
isPublishing = true;
|
||||||
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||||||
isPublishing = false;
|
isPublishing = false;
|
||||||
@@ -284,9 +685,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.Data is ScheduledStatus scheduled)
|
||||||
|
{
|
||||||
|
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||||
|
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
||||||
|
await OnPublished.InvokeAsync(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||||
var published = result.Data as FeedPost;
|
var published = result.Data as FeedPost;
|
||||||
form = new() { GroupId = GroupId };
|
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
||||||
await OnPublished.InvokeAsync(published);
|
await OnPublished.InvokeAsync(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
|
||||||
|
dialog in the middle from md. Closing it in any way answers no. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
|
||||||
|
<Heading>@Confirm.Heading</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (Confirm.Text is { Length: > 0 } text)
|
||||||
|
{
|
||||||
|
<p class="text-sm">@text</p>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
|
||||||
|
@Confirm.ConfirmText
|
||||||
|
</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Confirm.Changed += Refresh;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Refresh() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
void Closed(bool open)
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
Confirm.Answer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Confirm.Changed -= Refresh;
|
||||||
|
}
|
||||||
+752
-60
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,8 @@
|
|||||||
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
||||||
@if (Href is null)
|
@if (Href is null)
|
||||||
{
|
{
|
||||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
|
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
|
||||||
|
@onmousedown:preventDefault="KeepFocus">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,8 @@ else
|
|||||||
[Parameter] public string Type { get; set; } = "button";
|
[Parameter] public string Type { get; set; } = "button";
|
||||||
[Parameter] public string Href { get; set; }
|
[Parameter] public string Href { get; set; }
|
||||||
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
||||||
|
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
|
||||||
|
[Parameter] public bool KeepFocus { get; set; }
|
||||||
|
|
||||||
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
||||||
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
@inherits DComponentBase
|
@inherits DComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||||
too. With Hover the CSS hover rule opens it instead. *@
|
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
|
||||||
|
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||||
|
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||||
@onclick="Toggle">
|
@onclick="Toggle">
|
||||||
@Trigger
|
@Trigger
|
||||||
</div>
|
</div>
|
||||||
@if (IsOpen)
|
@if (IsOpen)
|
||||||
{
|
{
|
||||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
|
||||||
|
aria-hidden="true" @onclick="Close"></div>
|
||||||
}
|
}
|
||||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
|
||||||
|
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +36,21 @@
|
|||||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||||
[Parameter] public string TriggerClass { get; set; }
|
[Parameter] public string TriggerClass { get; set; }
|
||||||
[Parameter] public string ContentClass { get; set; }
|
[Parameter] public string ContentClass { get; set; }
|
||||||
|
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||||
|
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||||
|
|
||||||
|
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
|
||||||
|
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
|
||||||
|
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
|
||||||
|
|
||||||
|
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||||
|
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||||
|
|
||||||
|
ElementReference trigger;
|
||||||
|
ElementReference content;
|
||||||
|
IDisposable overlay;
|
||||||
|
bool focusContent;
|
||||||
|
bool focusTrigger;
|
||||||
|
|
||||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||||
@@ -50,17 +70,54 @@
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
Task Toggle() => SetOpen(!IsOpen);
|
protected override void OnParametersSet() => TrackOverlay();
|
||||||
|
|
||||||
Task Close() => SetOpen(false);
|
void TrackOverlay()
|
||||||
|
|
||||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
async Task SetOpen(bool isOpen)
|
|
||||||
{
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await SetOpen(false, true);
|
||||||
|
StateHasChanged();
|
||||||
|
}));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => overlay?.Dispose();
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (focusContent)
|
||||||
|
{
|
||||||
|
focusContent = false;
|
||||||
|
await content.FocusAsync();
|
||||||
|
}
|
||||||
|
if (focusTrigger)
|
||||||
|
{
|
||||||
|
focusTrigger = false;
|
||||||
|
await trigger.FocusAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Toggle() => SetOpen(!IsOpen, IsOpen);
|
||||||
|
|
||||||
|
Task Close() => SetOpen(false, true);
|
||||||
|
|
||||||
|
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
|
||||||
|
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
|
||||||
|
|
||||||
|
async Task SetOpen(bool isOpen, bool giveFocusBack)
|
||||||
|
{
|
||||||
|
focusContent = isOpen && !IsOpen;
|
||||||
|
focusTrigger = !isOpen && IsOpen && giveFocusBack;
|
||||||
IsOpen = isOpen;
|
IsOpen = isOpen;
|
||||||
|
TrackOverlay();
|
||||||
await IsOpenChanged.InvokeAsync(isOpen);
|
await IsOpenChanged.InvokeAsync(isOpen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
@inherits DComponentBase
|
@inherits DComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
||||||
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
||||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
|
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
|
||||||
|
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
|
||||||
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
||||||
class="@CssClass" @onkeydown="OnKeyDown">
|
class="@CssClass" @onkeydown="OnKeyDown">
|
||||||
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
||||||
|
@if (Heading is not null || ShowClose)
|
||||||
|
{
|
||||||
|
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||||
@if (Heading is not null)
|
@if (Heading is not null)
|
||||||
{
|
{
|
||||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
}
|
||||||
|
@if (ShowClose)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
|
||||||
|
@onclick="Close" data-modal-close>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@ChildContent
|
@ChildContent
|
||||||
@if (Actions is not null)
|
@if (Actions is not null)
|
||||||
@@ -35,11 +53,17 @@
|
|||||||
[Parameter] public DModalPlacement Placement { get; set; }
|
[Parameter] public DModalPlacement Placement { get; set; }
|
||||||
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
||||||
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
||||||
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
|
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
|
||||||
[Parameter] public string CloseText { get; set; } = "close";
|
[Parameter] public string CloseText { get; set; } = "close";
|
||||||
|
/// <summary>A visible close button beside the heading.</summary>
|
||||||
|
[Parameter] public bool ShowClose { get; set; } = true;
|
||||||
[Parameter] public string BoxClass { get; set; }
|
[Parameter] public string BoxClass { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||||
|
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||||
|
|
||||||
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
IDisposable overlay;
|
||||||
ElementReference box;
|
ElementReference box;
|
||||||
bool wasOpen;
|
bool wasOpen;
|
||||||
bool focusPending;
|
bool focusPending;
|
||||||
@@ -52,6 +76,7 @@
|
|||||||
DModalPlacement.Bottom => "modal-bottom",
|
DModalPlacement.Bottom => "modal-bottom",
|
||||||
DModalPlacement.Start => "modal-start",
|
DModalPlacement.Start => "modal-start",
|
||||||
DModalPlacement.End => "modal-end",
|
DModalPlacement.End => "modal-end",
|
||||||
|
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +85,26 @@
|
|||||||
if (IsOpen && !wasOpen)
|
if (IsOpen && !wasOpen)
|
||||||
focusPending = true;
|
focusPending = true;
|
||||||
wasOpen = IsOpen;
|
wasOpen = IsOpen;
|
||||||
|
TrackOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TrackOverlay()
|
||||||
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await SetOpen(false);
|
||||||
|
StateHasChanged();
|
||||||
|
}));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => overlay?.Dispose();
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!focusPending)
|
if (!focusPending)
|
||||||
@@ -78,6 +121,7 @@
|
|||||||
{
|
{
|
||||||
IsOpen = isOpen;
|
IsOpen = isOpen;
|
||||||
wasOpen = isOpen;
|
wasOpen = isOpen;
|
||||||
|
TrackOverlay();
|
||||||
await IsOpenChanged.InvokeAsync(isOpen);
|
await IsOpenChanged.InvokeAsync(isOpen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,7 @@ public enum DModalPlacement
|
|||||||
Top,
|
Top,
|
||||||
Bottom,
|
Bottom,
|
||||||
Start,
|
Start,
|
||||||
End
|
End,
|
||||||
|
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
|
||||||
|
ResponsiveBottom
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
||||||
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
||||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96). *@
|
are WrapperClass (pinned rows need it to have a height, e.g. h-96); it is marked as a strip that scrolls sideways on
|
||||||
|
purpose (data-scroll-x), which the phone tests allow. *@
|
||||||
@if (Scroll)
|
@if (Scroll)
|
||||||
{
|
{
|
||||||
<div class="@Cx("overflow-x-auto", WrapperClass)">
|
<div class="@Cx("overflow-x-auto", WrapperClass)" data-scroll-x>
|
||||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
|
|
||||||
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
||||||
value is the content; rows, placeholder and the like pass through. *@
|
value is the content; rows, placeholder and the like pass through. *@
|
||||||
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||||
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>The textarea itself, for focusing it.</summary>
|
||||||
|
public ElementReference Element { get; private set; }
|
||||||
|
|
||||||
[Parameter] public DColor Color { get; set; }
|
[Parameter] public DColor Color { get; set; }
|
||||||
[Parameter] public DSize Size { get; set; }
|
[Parameter] public DSize Size { get; set; }
|
||||||
[Parameter] public bool Ghost { get; set; }
|
[Parameter] public bool Ghost { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace decePubClient.Components.Daisy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
|
||||||
|
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
|
||||||
|
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDOverlayHost
|
||||||
|
{
|
||||||
|
IDisposable Open(Func<Task> close);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
[Parameter] public bool Disabled { get; set; }
|
[Parameter] public bool Disabled { get; set; }
|
||||||
[Parameter] public string Class { get; set; }
|
[Parameter] public string Class { get; set; }
|
||||||
|
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
|
||||||
|
[Parameter] public string AccessibleName { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
var index = i;
|
var index = i;
|
||||||
var tab = tabs[i];
|
var tab = tabs[i];
|
||||||
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
||||||
|
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
|
||||||
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
||||||
@onclick="() => Select(index)">
|
@onclick="() => Select(index)">
|
||||||
@tab.Header
|
@tab.Header
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
||||||
persona's communities and circles, Tag the hashtag being followed. The stats icon shows how the current feed was
|
persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being
|
||||||
|
read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was
|
||||||
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
|
||||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
tabs wait; from md only the selected tab shows its name until xl *@
|
||||||
|
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
|
||||||
|
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||||
|
aria-label="@Localizer["Feeds"]">
|
||||||
@foreach (var type in Types)
|
@foreach (var type in Types)
|
||||||
{
|
{
|
||||||
<DTab Class="gap-1 whitespace-nowrap">
|
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
|
||||||
<Header>
|
<Header>
|
||||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||||
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
|
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
|
||||||
|
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
||||||
|
}
|
||||||
|
else if (type is TimelineType.Direct && UnreadConversations > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadConversations]" data-unread-conversations>@UnreadConversations</DBadge>
|
||||||
|
}
|
||||||
</Header>
|
</Header>
|
||||||
</DTab>
|
</DTab>
|
||||||
}
|
}
|
||||||
@@ -21,10 +33,15 @@
|
|||||||
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||||
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
||||||
</NerdTip>
|
</NerdTip>
|
||||||
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||||
<i class="ion-md-planet text-base"></i>
|
<i class="ion-md-planet text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
@* a phone held sideways has no room for the banner: the globe opens full screen *@
|
||||||
|
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
|
||||||
|
data-globe-full>
|
||||||
|
<i class="ion-md-planet text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||||
<i class="ion-md-create text-base"></i>
|
<i class="ion-md-create text-base"></i>
|
||||||
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
@@ -49,35 +66,116 @@
|
|||||||
</DSelect>
|
</DSelect>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (Filter.TimelineType is TimelineType.Lists)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if (Lists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.ListId" ValueChanged="SelectList" ValueExpression="() => Filter.ListId">
|
||||||
|
<Prefix><i class="ion-md-list"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var list in Lists)
|
||||||
|
{
|
||||||
|
<option value="@list.Id">@list.Title</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
else if (Filter.TimelineType is TimelineType.Tag)
|
else if (Filter.TimelineType is TimelineType.Tag)
|
||||||
{
|
{
|
||||||
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<i class="ion-md-pricetag"></i><b>#@Filter.Tag</b>
|
||||||
|
@if (tag is not null)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts]</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(tag.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isFollowing)"
|
||||||
|
OnClick="ToggleTagFollow" data-tag-follow>
|
||||||
|
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
||||||
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
||||||
|
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
||||||
|
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
|
||||||
|
[Parameter] public int UnreadNotifications { get; set; }
|
||||||
|
/// <summary>How many of the newest direct conversations hold a message the persona has not read.</summary>
|
||||||
|
[Parameter] public int UnreadConversations { get; set; }
|
||||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||||
[Parameter] public bool IsComposing { get; set; }
|
[Parameter] public bool IsComposing { get; set; }
|
||||||
[Parameter] public EventCallback OnCompose { get; set; }
|
[Parameter] public EventCallback OnCompose { get; set; }
|
||||||
|
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
|
||||||
|
[Parameter] public EventCallback OnGlobeFull { get; set; }
|
||||||
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
||||||
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
||||||
|
|
||||||
static readonly TimelineType[] Always =
|
static readonly TimelineType[] Always =
|
||||||
[
|
[
|
||||||
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
||||||
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks
|
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks, TimelineType.Lists
|
||||||
];
|
];
|
||||||
|
|
||||||
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
||||||
|
|
||||||
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
||||||
|
|
||||||
|
ElementReference strip;
|
||||||
|
int revealed = -1;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (ActiveIndex == revealed)
|
||||||
|
return;
|
||||||
|
revealed = ActiveIndex;
|
||||||
|
CascadingState.Status.RevealActive(strip);
|
||||||
|
}
|
||||||
|
|
||||||
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
||||||
|
|
||||||
|
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||||
|
|
||||||
|
Tag tag;
|
||||||
|
string loadedTag;
|
||||||
|
bool isFollowing;
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
// the hashtag being read: its week and whether the persona follows it, read again when it changes
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
var wanted = Filter.TimelineType is TimelineType.Tag ? Filter.Tag : null;
|
||||||
|
if (wanted == loadedTag)
|
||||||
|
return;
|
||||||
|
loadedTag = wanted;
|
||||||
|
tag = wanted is { Length: > 0 } ? await DiscoveryService.Tag(wanted) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleTagFollow()
|
||||||
|
{
|
||||||
|
isFollowing = true;
|
||||||
|
var result = await DiscoveryService.FollowTag(tag.Name, tag.Following != true);
|
||||||
|
isFollowing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = (Tag)result.Data;
|
||||||
|
Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]);
|
||||||
|
}
|
||||||
|
|
||||||
string TabName(TimelineType type) => type switch
|
string TabName(TimelineType type) => type switch
|
||||||
{
|
{
|
||||||
TimelineType.Federation => Localizer["Federated"],
|
TimelineType.Federation => Localizer["Federated"],
|
||||||
@@ -90,6 +188,14 @@
|
|||||||
Filter.TimelineType = Types[index];
|
Filter.TimelineType = Types[index];
|
||||||
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
||||||
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
||||||
|
if (Filter.TimelineType is TimelineType.Lists && Lists.All(l => l.Id != Filter.ListId))
|
||||||
|
Filter.ListId = Lists.FirstOrDefault()?.Id;
|
||||||
|
await OnChanged.InvokeAsync(Filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SelectList(string listId)
|
||||||
|
{
|
||||||
|
Filter.ListId = listId;
|
||||||
await OnChanged.InvokeAsync(Filter);
|
await OnChanged.InvokeAsync(Filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,60 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||||
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
|
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||||
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
|
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
|
||||||
<div class="relative w-full h-full">
|
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
|
||||||
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
|
||||||
|
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
|
||||||
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
|
||||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
|
||||||
<span class="badge badge-ghost badge-xs gap-1">
|
brings the feed back. *@
|
||||||
<i class="ion-md-planet"></i>
|
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||||||
@if (stats is { Error: null } live)
|
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
|
||||||
|
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
|
||||||
|
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
|
||||||
|
<dl id="@($"{uid}-sun")" class="sr-only">
|
||||||
|
@if (sun is not null)
|
||||||
{
|
{
|
||||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
|
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||||
|
{
|
||||||
|
<dt>@row.Label</dt>
|
||||||
|
<dd>@row.Value</dd>
|
||||||
}
|
}
|
||||||
else if (stats?.Error is { Length: > 0 })
|
}
|
||||||
|
</dl>
|
||||||
|
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
|
||||||
|
|
||||||
|
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||||
|
@if (sun is not null)
|
||||||
{
|
{
|
||||||
<span>@Localizer["The globe could not start."]</span>
|
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
|
||||||
|
data-globe-moment>
|
||||||
|
<i class="@SunIcon(sun)" aria-hidden="true"></i>
|
||||||
|
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (error is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Expanded)
|
||||||
|
{
|
||||||
|
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
|
||||||
|
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>@Localizer["loading"]…</span>
|
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
|
||||||
|
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
|
||||||
|
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
|
||||||
</NerdTip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -34,30 +64,104 @@
|
|||||||
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
||||||
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
||||||
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
||||||
|
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
|
||||||
|
[Parameter] public bool Expanded { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||||
|
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
|
||||||
|
ElementReference closeButton;
|
||||||
|
ElementReference expandButton;
|
||||||
|
IDisposable overlay;
|
||||||
|
bool focusClose;
|
||||||
|
bool focusExpand;
|
||||||
|
|
||||||
|
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
|
||||||
|
|
||||||
|
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
|
||||||
|
static readonly string[] InstrumentKeys =
|
||||||
|
[
|
||||||
|
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
|
||||||
|
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
|
||||||
|
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
|
||||||
|
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
|
||||||
|
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
|
||||||
|
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
|
||||||
|
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
|
||||||
|
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
|
||||||
|
];
|
||||||
|
|
||||||
|
async Task Open()
|
||||||
|
{
|
||||||
|
focusClose = true;
|
||||||
|
await ExpandedChanged.InvokeAsync(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Close()
|
||||||
|
{
|
||||||
|
focusExpand = true;
|
||||||
|
await ExpandedChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
|
||||||
|
|
||||||
ElementReference element;
|
ElementReference element;
|
||||||
IJSObjectReference module;
|
IJSObjectReference module;
|
||||||
PeriodicTimer statsTimer;
|
DotNetObjectReference<GlobePanel> reference;
|
||||||
readonly CancellationTokenSource cancellation = new();
|
readonly CancellationTokenSource cancellation = new();
|
||||||
string sentFingerprint;
|
int? sentFingerprint;
|
||||||
GlobeStats stats;
|
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
|
||||||
|
bool postsHandedOver = true;
|
||||||
|
SunStats sun;
|
||||||
|
string error;
|
||||||
|
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
postsHandedOver = true;
|
||||||
|
// full screen, the Back button closes it
|
||||||
|
if (Expanded && overlay is null)
|
||||||
|
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||||
|
else if (!Expanded && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (focusClose && Expanded)
|
||||||
|
{
|
||||||
|
focusClose = false;
|
||||||
|
await closeButton.FocusAsync();
|
||||||
|
}
|
||||||
|
if (focusExpand && !Expanded)
|
||||||
|
{
|
||||||
|
focusExpand = false;
|
||||||
|
await expandButton.FocusAsync(preventScroll: true);
|
||||||
|
}
|
||||||
await base.OnAfterRenderAsync(firstRender);
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||||
await module.InvokeVoidAsync("create", element, ScrollContainerId);
|
reference = DotNetObjectReference.Create(this);
|
||||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
|
||||||
_ = ReadStats(cancellation.Token);
|
{
|
||||||
|
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (postsHandedOver)
|
||||||
|
{
|
||||||
|
postsHandedOver = false;
|
||||||
await SendPosts();
|
await SendPosts();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (JSDisconnectedException)
|
catch (JSDisconnectedException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -75,7 +179,17 @@
|
|||||||
|
|
||||||
var located = Posts.Where(p => p.Location is not null).ToList();
|
var located = Posts.Where(p => p.Location is not null).ToList();
|
||||||
var self = InstanceService.Known(InstanceService.SelfHost);
|
var self = InstanceService.Known(InstanceService.SelfHost);
|
||||||
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}:{p.Location.CdnKey}")) + $"#{self?.Geo?.Latitude}";
|
// a hash, not a string of every place: a feed of thousands is looked at on every page it loads
|
||||||
|
var hash = new HashCode();
|
||||||
|
foreach (var post in located)
|
||||||
|
{
|
||||||
|
hash.Add(post.Key);
|
||||||
|
hash.Add(post.Location.Latitude);
|
||||||
|
hash.Add(post.Location.Longitude);
|
||||||
|
hash.Add(post.Location.CdnKey);
|
||||||
|
}
|
||||||
|
hash.Add(self?.Geo?.Latitude);
|
||||||
|
var fingerprint = hash.ToHashCode();
|
||||||
if (fingerprint == sentFingerprint)
|
if (fingerprint == sentFingerprint)
|
||||||
return;
|
return;
|
||||||
sentFingerprint = fingerprint;
|
sentFingerprint = fingerprint;
|
||||||
@@ -100,15 +214,18 @@
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task ReadStats(CancellationToken cancellationToken)
|
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
|
||||||
|
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task SunChanged()
|
||||||
{
|
{
|
||||||
|
if (module is null)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
|
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
|
||||||
{
|
StateHasChanged();
|
||||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -118,31 +235,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<NerdRow> StatsRows()
|
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public void GlobeFailed(string message)
|
||||||
{
|
{
|
||||||
if (stats is null)
|
error = message;
|
||||||
yield break;
|
StateHasChanged();
|
||||||
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
|
|
||||||
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
|
|
||||||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
|
||||||
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
|
|
||||||
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
|
|
||||||
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
|
|
||||||
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
|
|
||||||
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString());
|
|
||||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
|
||||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
|
|
||||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
|
||||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
|
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||||||
|
{
|
||||||
|
yield return NerdRow.Of(Localizer["moment"], DateTime.Parse(sun.At, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal));
|
||||||
|
yield return NerdRow.Of(Localizer["subsolar point"], $"{Fixed(sun.Latitude)}, {Fixed(sun.Longitude)}");
|
||||||
|
yield return NerdRow.Of(Localizer["declination"], $"{Fixed(sun.Latitude)}°");
|
||||||
|
yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°");
|
||||||
|
yield return NerdRow.Of(Localizer["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°");
|
||||||
|
yield return NerdRow.Of(Localizer["Earth–Sun distance"],
|
||||||
|
$"{sun.Distance.ToString("0.00000", CultureInfo.InvariantCulture)} AU ({sun.Distance * 149_597_870.7:N0} km)");
|
||||||
|
yield return NerdRow.Of(Localizer["irradiance"],
|
||||||
|
$"{sun.Irradiance * 1361:0} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture)}%)");
|
||||||
|
yield return NerdRow.Of(Localizer["Place"], sun.Place is { } place ? $"{place} ({Fixed(sun.PlaceLatitude)}, {Fixed(sun.PlaceLongitude)})" : null);
|
||||||
|
yield return NerdRow.Of(Localizer["elevation"], sun.Elevation is { } elevation ? $"{Fixed(elevation)}°" : null);
|
||||||
|
yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null);
|
||||||
|
yield return NerdRow.Of(Localizer["seasons"], $"{Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]}");
|
||||||
|
yield return NerdRow.Of(Localizer["next"], sun.NextEvent is { } next ? Localizer["{0} in {1} days", Localizer[EventName(next)], sun.NextEventDays] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Fixed(double? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "–";
|
||||||
|
|
||||||
|
/// <summary>The Sun's moment in the reader's time zone.</summary>
|
||||||
|
static string LocalTime(string at) =>
|
||||||
|
DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time)
|
||||||
|
? time.ToLocalTime().ToString(SUtility.dateTimeFormatExt, CultureInfo.InvariantCulture)
|
||||||
|
: at;
|
||||||
|
|
||||||
|
static string SunIcon(SunStats sun) => sun.Phase switch
|
||||||
|
{
|
||||||
|
"day" => "ion-md-sunny",
|
||||||
|
"golden" => "ion-md-partly-sunny",
|
||||||
|
"civil" or "nautical" or "astronomical" => "ion-md-cloudy-night",
|
||||||
|
"night" => "ion-md-moon",
|
||||||
|
_ => "ion-md-time"
|
||||||
|
};
|
||||||
|
|
||||||
|
string PhaseName(string phase, bool? morning) => phase switch
|
||||||
|
{
|
||||||
|
"day" => Localizer["daylight"],
|
||||||
|
"night" => Localizer["night"],
|
||||||
|
_ => Localizer["{0}, {1}", Localizer[phase switch
|
||||||
|
{
|
||||||
|
"golden" => "golden hour",
|
||||||
|
"civil" => "civil twilight",
|
||||||
|
"nautical" => "nautical twilight",
|
||||||
|
_ => "astronomical twilight"
|
||||||
|
}], Localizer[morning == true ? "morning" : "evening"]]
|
||||||
|
};
|
||||||
|
|
||||||
|
static string SeasonName(string season) => season switch
|
||||||
|
{
|
||||||
|
"spring" => "spring",
|
||||||
|
"summer" => "summer",
|
||||||
|
"autumn" => "autumn",
|
||||||
|
_ => "winter"
|
||||||
|
};
|
||||||
|
|
||||||
|
static string EventName(string next) => next switch
|
||||||
|
{
|
||||||
|
"march-equinox" => "March equinox",
|
||||||
|
"june-solstice" => "June solstice",
|
||||||
|
"september-equinox" => "September equinox",
|
||||||
|
_ => "December solstice"
|
||||||
|
};
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
overlay?.Dispose();
|
||||||
await cancellation.CancelAsync();
|
await cancellation.CancelAsync();
|
||||||
cancellation.Dispose();
|
cancellation.Dispose();
|
||||||
statsTimer?.Dispose();
|
reference?.Dispose();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (module is not null)
|
if (module is not null)
|
||||||
@@ -162,28 +332,30 @@
|
|||||||
|
|
||||||
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||||||
|
|
||||||
sealed class GlobeStats
|
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||||
|
sealed class SunStats
|
||||||
{
|
{
|
||||||
public double? Latitude { get; set; }
|
public string At { get; set; }
|
||||||
public double? Longitude { get; set; }
|
public bool Now { get; set; }
|
||||||
public double? Altitude { get; set; }
|
public double Latitude { get; set; }
|
||||||
public int Visible { get; set; }
|
public double Longitude { get; set; }
|
||||||
public int VisibleLocated { get; set; }
|
public double Distance { get; set; }
|
||||||
public double? CentroidLatitude { get; set; }
|
public double Irradiance { get; set; }
|
||||||
public double? CentroidLongitude { get; set; }
|
public double EclipticLongitude { get; set; }
|
||||||
public double SpreadKm { get; set; }
|
public double Obliquity { get; set; }
|
||||||
public int Points { get; set; }
|
public string Place { get; set; }
|
||||||
public int Posts { get; set; }
|
public double? PlaceLatitude { get; set; }
|
||||||
public int Servers { get; set; }
|
public double? PlaceLongitude { get; set; }
|
||||||
public int CdnGroups { get; set; }
|
public double? Elevation { get; set; }
|
||||||
public int Fps { get; set; }
|
public string Phase { get; set; }
|
||||||
public string Renderer { get; set; }
|
public bool? Morning { get; set; }
|
||||||
public string Texture { get; set; }
|
public string SolarTime { get; set; }
|
||||||
public bool AutoRotate { get; set; }
|
public double? DayLength { get; set; }
|
||||||
public bool Paused { get; set; }
|
public string Season { get; set; }
|
||||||
public string Hovered { get; set; }
|
public string Hemisphere { get; set; }
|
||||||
public int Arcs { get; set; }
|
public string SeasonNorth { get; set; }
|
||||||
public int Rings { get; set; }
|
public string SeasonSouth { get; set; }
|
||||||
public string Error { get; set; }
|
public string NextEvent { get; set; }
|
||||||
|
public int? NextEventDays { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
|
||||||
|
have done so, newest first, each opening its profile. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||||
|
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (accounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3">@Localizer["Nobody this server knows of."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||||
|
/// <summary>Who boosted it, rather than who liked it.</summary>
|
||||||
|
[Parameter] public bool Boosts { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
List<Account> accounts = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts);
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
|
||||||
|
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
|
||||||
|
keys), and the original in a new tab for whoever wants the file. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||||
|
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
|
||||||
|
<Heading>
|
||||||
|
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
|
||||||
|
</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
|
||||||
|
<figure class="flex flex-col gap-2 min-h-0">
|
||||||
|
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
|
||||||
|
data-blurhash="@Current.Blurhash" />
|
||||||
|
@if (Current.Description is { Length: > 0 } description)
|
||||||
|
{
|
||||||
|
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
|
||||||
|
}
|
||||||
|
</figure>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
@if (Pictures.Count > 1)
|
||||||
|
{
|
||||||
|
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
|
||||||
|
title="@Localizer["Previous picture"]" data-picture-previous>
|
||||||
|
<i class="ion-md-arrow-back" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
|
||||||
|
<i class="ion-md-open" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["Open original"]</span>
|
||||||
|
</a>
|
||||||
|
@if (Pictures.Count > 1)
|
||||||
|
{
|
||||||
|
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
|
||||||
|
title="@Localizer["Next picture"]" data-picture-next>
|
||||||
|
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The post's pictures, in its order.</summary>
|
||||||
|
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
|
||||||
|
/// <summary>The one opened.</summary>
|
||||||
|
[Parameter] public int Start { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
int index;
|
||||||
|
|
||||||
|
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Previous() => index = Math.Max(0, index - 1);
|
||||||
|
|
||||||
|
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
|
||||||
|
|
||||||
|
void OnKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "ArrowLeft")
|
||||||
|
Previous();
|
||||||
|
else if (e.Key == "ArrowRight")
|
||||||
|
Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
|
||||||
|
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
|
||||||
|
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
|
||||||
|
@if (Heading is { Length: > 0 })
|
||||||
|
{
|
||||||
|
<p class="font-bold opacity-80">@Heading</p>
|
||||||
|
}
|
||||||
|
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||||
|
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||||
|
{
|
||||||
|
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||||
|
<dd class="break-all">@row.Value</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
@if (Pending)
|
||||||
|
{
|
||||||
|
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Heading { get; set; }
|
||||||
|
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
|
||||||
|
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
|
||||||
|
[Parameter] public bool Pending { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
}
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
|
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
|
||||||
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
|
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
|
||||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
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
|
@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 {
|
@code {
|
||||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
@@ -16,8 +38,19 @@
|
|||||||
[Parameter] public DTooltipPlacement Placement { get; set; }
|
[Parameter] public DTooltipPlacement Placement { get; set; }
|
||||||
[Parameter] public DTooltipAlign Align { get; set; }
|
[Parameter] public DTooltipAlign Align { get; set; }
|
||||||
[Parameter] public string Class { get; set; }
|
[Parameter] public string Class { get; set; }
|
||||||
|
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
|
||||||
|
[Parameter] public bool TapOpens { get; set; } = true;
|
||||||
|
|
||||||
bool opened;
|
bool opened;
|
||||||
|
bool isSheetOpen;
|
||||||
|
|
||||||
|
async Task Tap()
|
||||||
|
{
|
||||||
|
if (CascadingState.Status.CanHover())
|
||||||
|
return;
|
||||||
|
isSheetOpen = true;
|
||||||
|
await Open();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
@@ -29,21 +62,5 @@
|
|||||||
await OnOpen.InvokeAsync();
|
await OnOpen.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
|
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
|
||||||
@if (Heading is { Length: > 0 })
|
|
||||||
{
|
|
||||||
<p class="font-bold opacity-80">@Heading</p>
|
|
||||||
}
|
|
||||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
|
||||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
|
||||||
{
|
|
||||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
|
||||||
<dd class="break-all">@row.Value</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
@if (Pending)
|
|
||||||
{
|
|
||||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
|
||||||
}
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
|
||||||
|
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
|
||||||
|
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
|
||||||
|
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
|
||||||
|
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
|
||||||
|
data-sheet>
|
||||||
|
<div class="md:hidden flex items-center gap-3 mb-3">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@Heading</b>
|
||||||
|
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
|
||||||
|
@onclick="Close" data-sheet-close>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
/// <summary>The sheet's heading on a phone.</summary>
|
||||||
|
[Parameter] public string Heading { get; set; }
|
||||||
|
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||||
|
[Parameter] public string Class { get; set; }
|
||||||
|
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
|
||||||
|
IDisposable overlay;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
// open, the Back button closes it
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsOpen && overlay is null)
|
||||||
|
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||||
|
else if (!IsOpen && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
overlay?.Dispose();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Close() => OnClose.InvokeAsync();
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
|
||||||
|
(PublicNoticeService), in MainLayout. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
||||||
|
data-public-notice>
|
||||||
|
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<p class="text-sm">
|
||||||
|
@(Notices.Act == PublicAct.Participation
|
||||||
|
? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
|
||||||
|
: Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""])
|
||||||
|
</p>
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] PublicNoticeService Notices { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Notices.Changed += Refresh;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Refresh() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
void Closed(bool open)
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
Notices.Answer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Notices.Changed -= Refresh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@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 Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||||
|
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
|
||||||
|
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
|
||||||
|
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
|
||||||
|
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
|
||||||
|
</DSelect>
|
||||||
|
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
|
||||||
|
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
|
||||||
|
@if (Host is { } host)
|
||||||
|
{
|
||||||
|
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public Account Account { get; set; }
|
||||||
|
/// <summary>The post it was opened from, sent with the report.</summary>
|
||||||
|
[Parameter] public Status Post { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
ReportForm form = new();
|
||||||
|
bool isSending;
|
||||||
|
string error;
|
||||||
|
|
||||||
|
// the 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
isSending = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]);
|
||||||
|
await OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@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. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-account>
|
||||||
|
<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>
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
|
||||||
|
placeholder="@Localizer["Current password"]" data-account-current />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
|
||||||
|
placeholder="@Localizer["New password"]" data-account-new />
|
||||||
|
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.RepeatedPassword" Immediate="true" autocomplete="new-password"
|
||||||
|
placeholder="@Localizer["Repeat the new password"]" data-account-repeat />
|
||||||
|
</div>
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-password-save>
|
||||||
|
<i class="ion-md-key"></i><span>@Localizer["Change the password"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
|
||||||
|
<p class="text-sm">@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]</p>
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password"
|
||||||
|
placeholder="@Localizer["Current password"]" data-account-delete-password />
|
||||||
|
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || !understood)" data-account-delete-confirm>
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
|
||||||
|
UserPasswordForm password = new();
|
||||||
|
RemoveSelfForm removal = new();
|
||||||
|
bool understood;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
async Task ChangePassword()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.ChangePassword(password);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
password = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Password changed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AuthService.DeleteAccount(removal);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CascadingState.SignOut();
|
||||||
|
Navigation.NavigateTo("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's follows, lists, blocks, mutes, blocked servers and bookmarks, exported and imported as the CSV files
|
||||||
|
Mastodon writes, which most fediverse servers read too: a persona moves here from another server, or away, with what
|
||||||
|
it gathered there. An import acts one row at a time, through PrivaPub, and can be stopped. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Export"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Files in Mastodon's format, which most fediverse servers import."]</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || exporting is not null || importing is not null)" OnClick="() => Export(data)"
|
||||||
|
data-export-kind="@data">
|
||||||
|
@if (exporting == data)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-download"></i>
|
||||||
|
}
|
||||||
|
<span>@Name(data)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Import"]</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["What the file holds"]</span>
|
||||||
|
<DSelect TValue="PortableData" Size="DSize.Sm" @bind-Value="importKind" disabled="@(importing is not null)" data-import-kind>
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<option value="@data">@Name(data)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".csv,text/csv" OnChange="Pick" disabled="@(importing is not null)" data-import-file />
|
||||||
|
@if (importing is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || file is null || exporting is not null)" OnClick="Import"
|
||||||
|
data-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i><span>@Localizer["Import"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" OnClick="Stop">@Localizer["Stop"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (report is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-import-report>
|
||||||
|
<DProgress class="w-full max-w-md" Value="@(report.Done + report.Failed.Count)" Max="@Math.Max(1, report.Total)" />
|
||||||
|
<span class="text-sm tabular-nums">
|
||||||
|
@Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]
|
||||||
|
</span>
|
||||||
|
<ErrorViewer AdditionalErrors="report.Failed" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] PortabilityService PortabilityService { get; set; }
|
||||||
|
[Inject] IBlazorDownloadFileService DownloadFile { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
// an export file of 5000 rows stays well under this
|
||||||
|
const long MaximumFileSize = 4 * 1024 * 1024;
|
||||||
|
static readonly PortableData[] Kinds = Enum.GetValues<PortableData>();
|
||||||
|
|
||||||
|
PortableData? exporting;
|
||||||
|
PortableData? importing;
|
||||||
|
PortableData importKind = PortableData.Follows;
|
||||||
|
string file;
|
||||||
|
ImportReport report;
|
||||||
|
CancellationTokenSource stopping;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
string Name(PortableData data) => data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => Localizer["Follows"],
|
||||||
|
PortableData.Lists => Localizer["Lists"],
|
||||||
|
PortableData.Blocks => Localizer["Blocked accounts"],
|
||||||
|
PortableData.Mutes => Localizer["Muted accounts"],
|
||||||
|
PortableData.ServerBlocks => Localizer["Blocked servers"],
|
||||||
|
_ => Localizer["Bookmarks"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Export(PortableData data)
|
||||||
|
{
|
||||||
|
exporting = data;
|
||||||
|
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
|
exporting = null;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
file = null;
|
||||||
|
report = null;
|
||||||
|
if (eventArgs.File.Size > MaximumFileSize)
|
||||||
|
{
|
||||||
|
Toast.AddWarning(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", eventArgs.File.Name,
|
||||||
|
eventArgs.File.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(eventArgs.File.OpenReadStream(MaximumFileSize));
|
||||||
|
file = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessError(ex, $"{nameof(DataTransfer)}.{nameof(Pick)}:{eventArgs.File.Name}");
|
||||||
|
Toast.AddWarning(Localizer["The file \"{0}\" could not be read.", eventArgs.File.Name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
importing = importKind;
|
||||||
|
stopping = new();
|
||||||
|
report = new();
|
||||||
|
var progress = new Progress<ImportReport>(step =>
|
||||||
|
{
|
||||||
|
report = step;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
report = await PortabilityService.Import(importKind, file, progress, stopping.Token);
|
||||||
|
importing = null;
|
||||||
|
stopping.Dispose();
|
||||||
|
stopping = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Stop() => stopping?.Cancel();
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
stopping?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The accounts the current persona muted or blocked (/api/v1/mutes, /api/v1/blocks), and the servers it blocked
|
||||||
|
(/api/v1/domain_blocks), each one undone here. A server blocked hides everything from it and removes the persona's
|
||||||
|
followers there. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-mutes>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Muted accounts"]</h3>
|
||||||
|
@if (mutes.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is muted."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in mutes)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unmute)">
|
||||||
|
@Localizer["Unmute"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (mutesMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreMutes">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked accounts"]</h3>
|
||||||
|
@if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unblock)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (blocksMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreBlocks">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-domain-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked servers"]</h3>
|
||||||
|
<EditForm Model="draft" OnValidSubmit="BlockDomain" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-domain-new>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || string.IsNullOrWhiteSpace(draft.Domain) || busy.Contains(Domain(draft.Domain)))">
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
@if (domains.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var domain in domains)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-domain="@domain">
|
||||||
|
<i class="ion-md-globe opacity-70"></i>
|
||||||
|
<span class="flex-1 min-w-0 truncate">@domain</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(domain))" OnClick="() => UnblockDomain(domain)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
sealed class DomainDraft
|
||||||
|
{
|
||||||
|
public string Domain { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Account> mutes = [];
|
||||||
|
List<Account> blocks = [];
|
||||||
|
List<string> domains = [];
|
||||||
|
string mutesMaxId;
|
||||||
|
string blocksMaxId;
|
||||||
|
DomainDraft draft = new();
|
||||||
|
// the accounts and servers whose undo is on its way
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
mutes = [];
|
||||||
|
blocks = [];
|
||||||
|
mutesMaxId = null;
|
||||||
|
blocksMaxId = null;
|
||||||
|
await Task.WhenAll(MoreMutes(), MoreBlocks());
|
||||||
|
domains = await AccountService.DomainBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreMutes()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Mutes(mutesMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
mutes.AddRange(page.Accounts.Where(a => mutes.All(m => m.Id != a.Id)));
|
||||||
|
mutesMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreBlocks()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Blocks(blocksMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
blocks.AddRange(page.Accounts.Where(a => blocks.All(b => b.Id != a.Id)));
|
||||||
|
blocksMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Undo(Account account, AccountAction action)
|
||||||
|
{
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.Act(account.Id, action);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action is AccountAction.Unmute)
|
||||||
|
mutes.RemoveAll(m => m.Id == account.Id);
|
||||||
|
else
|
||||||
|
blocks.RemoveAll(b => b.Id == account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// what a person types as a server (a URL, a handle's host, any case) as the bare host PrivaPub keys it by
|
||||||
|
static string Domain(string typed)
|
||||||
|
{
|
||||||
|
var text = typed?.Trim().TrimStart('@') ?? "";
|
||||||
|
if (Uri.TryCreate(text.Contains("://") ? text : "https://" + text, UriKind.Absolute, out var uri))
|
||||||
|
text = uri.Host;
|
||||||
|
return text.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task BlockDomain()
|
||||||
|
{
|
||||||
|
var domain = Domain(draft.Domain);
|
||||||
|
if (domain.Length == 0 || domains.Contains(domain))
|
||||||
|
{
|
||||||
|
draft = new();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.BlockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft = new();
|
||||||
|
domains = [.. domains.Append(domain).Order()];
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UnblockDomain(string domain)
|
||||||
|
{
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.UnblockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domains.Remove(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Rendering
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's notification policy (/api/v2/notifications/policy): what notifications from people it does not follow,
|
||||||
|
people who do not follow it, new accounts, private mentions it did not ask for and accounts this server limits become.
|
||||||
|
Filtered ones wait in a request per account (/api/v1/notifications/requests), let in (with that account from then
|
||||||
|
on) or dismissed here. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-4" data-notification-policy>
|
||||||
|
<EditForm Model="policy" OnValidSubmit="Save" class="flex flex-col gap-2">
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["People you don't follow"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowing" data-policy="for_not_following">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["People who don't follow you"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowers" data-policy="for_not_followers">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["New accounts"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNewAccounts" data-policy="for_new_accounts">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["Private mentions you did not ask for"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForPrivateMentions" data-policy="for_private_mentions">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm">@Localizer["Accounts this server limits"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForLimitedAccounts" data-policy="for_limited_accounts">@Actions</DSelect>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Filtered notifications wait below, one request per account; dropped ones are never kept."]</p>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isSaving)" data-policy-save>
|
||||||
|
@Localizer["Save"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-notification-requests>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Filtered notifications"]</h3>
|
||||||
|
@if (requests.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No filtered notifications."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var request in requests)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0" data-request="@request.Account.Acct">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 min-w-0">
|
||||||
|
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(request.Account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(request.Account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(request.Account.Name, request.Account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + request.Account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<DBadge Size="DSize.Sm">@Localizer["{0} notifications", request.NotificationsCount]</DBadge>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||||
|
OnClick="@(() => Answer(request, true))" data-request-accept>@Localizer["Let in"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||||
|
OnClick="@(() => Answer(request, false))" data-request-dismiss>@Localizer["Dismiss"]</DButton>
|
||||||
|
</div>
|
||||||
|
@if (request.LastStatus?.Content is { Length: > 0 } content)
|
||||||
|
{
|
||||||
|
<div class="post-content text-sm opacity-80 break-words line-clamp-3">@((MarkupString)CustomEmojis.Html(content, request.LastStatus.Emojis))</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
|
|
||||||
|
NotificationPolicy policy = new();
|
||||||
|
|
||||||
|
// what a notification can become, the same for every rule
|
||||||
|
void Actions(RenderTreeBuilder __builder)
|
||||||
|
{
|
||||||
|
<option value="accept">@Localizer["Accept"]</option>
|
||||||
|
<option value="filter">@Localizer["Filter"]</option>
|
||||||
|
<option value="drop">@Localizer["Drop"]</option>
|
||||||
|
}
|
||||||
|
List<NotificationRequest> requests = [];
|
||||||
|
// the requests whose answer is on its way
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
bool isSaving;
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
policy = await TimelineService.NotificationPolicy() ?? new();
|
||||||
|
requests = await TimelineService.NotificationRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await TimelineService.SetNotificationPolicy(policy);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
policy = (NotificationPolicy)result.Data ?? policy;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Notification policy saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(NotificationRequest request, bool accept)
|
||||||
|
{
|
||||||
|
busy.Add(request.Id);
|
||||||
|
var result = await TimelineService.AnswerNotificationRequest(request.Id, accept);
|
||||||
|
busy.Remove(request.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.Remove(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's
|
||||||
|
layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from
|
||||||
|
the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full" data-archive>
|
||||||
|
<section class="flex flex-col gap-2" data-archive-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
|
||||||
|
@if (state.ExportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-archive"></i>
|
||||||
|
}
|
||||||
|
<span>@Localizer["Make the archive"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (state.ExportState == "ready")
|
||||||
|
{
|
||||||
|
@if (link is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Get the link"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||||
|
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (state.ExportState == "failed")
|
||||||
|
{
|
||||||
|
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-archive-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]</p>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
|
||||||
|
@foreach (var part in Parts)
|
||||||
|
{
|
||||||
|
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
|
||||||
|
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
@if (!Importing)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
|
||||||
|
data-archive-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
|
||||||
|
@Localizer["Stop"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (uploading)
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
|
||||||
|
}
|
||||||
|
@if (state.ImportState is not (null or "none"))
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
|
||||||
|
@if (state.ImportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
|
||||||
|
}
|
||||||
|
<span class="text-sm">
|
||||||
|
@(state.ImportState switch
|
||||||
|
{
|
||||||
|
"queued" => Localizer["Waiting to start."],
|
||||||
|
"running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal],
|
||||||
|
"done" => Localizer["Imported."],
|
||||||
|
"stopped" => Localizer["Stopped."],
|
||||||
|
_ => Localizer["The import failed: {0}", state.ImportError ?? ""]
|
||||||
|
})
|
||||||
|
</span>
|
||||||
|
@if (state.ImportFrom is { Length: > 0 } from)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
|
||||||
|
}
|
||||||
|
<ul class="text-xs tabular-nums">
|
||||||
|
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
<li data-archive-count="@what">@count × @CountName(what)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] ArchiveService Archives { get; set; }
|
||||||
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
|
[Inject] AppConfiguration Configuration { get; set; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
const string FileInput = "input[data-archive-file]";
|
||||||
|
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||||
|
static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
||||||
|
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
||||||
|
|
||||||
|
ViewPersonaArchive state = new();
|
||||||
|
string loadedFor;
|
||||||
|
HashSet<string> chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"];
|
||||||
|
bool chosenFlag;
|
||||||
|
bool busy;
|
||||||
|
string link;
|
||||||
|
string pickedName;
|
||||||
|
bool uploading;
|
||||||
|
long uploaded;
|
||||||
|
long uploadSize;
|
||||||
|
double uploadedPercent;
|
||||||
|
IJSObjectReference transfer;
|
||||||
|
DotNetObjectReference<PersonaArchivePanel> self;
|
||||||
|
CancellationTokenSource polling;
|
||||||
|
|
||||||
|
bool Importing => uploading || state.ImportState is "queued" or "running";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// another persona: its own archive
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!IsLoading && loadedFor != CascadingState.PersonaId)
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
loadedFor = CascadingState.PersonaId;
|
||||||
|
link = null;
|
||||||
|
if (loadedFor is null)
|
||||||
|
return;
|
||||||
|
state = await Archives.State(loadedFor) ?? new ViewPersonaArchive();
|
||||||
|
if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running")
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Poll()
|
||||||
|
{
|
||||||
|
if (polling is not null)
|
||||||
|
return;
|
||||||
|
polling = new();
|
||||||
|
var token = polling.Token;
|
||||||
|
var persona = loadedFor;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(PollEvery);
|
||||||
|
while (await timer.WaitForNextTickAsync(token))
|
||||||
|
{
|
||||||
|
if (persona != loadedFor)
|
||||||
|
break;
|
||||||
|
if (await Archives.State(persona, token) is { } read)
|
||||||
|
state = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running"))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
polling = null;
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string PartName(string part) => part switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["Posts and their pictures"],
|
||||||
|
"profile" => Localizer["Profile: name, words, fields, pictures"],
|
||||||
|
"follows" => Localizer["Follows, asked again"],
|
||||||
|
"blocks" => Localizer["Blocks"],
|
||||||
|
"mutes" => Localizer["Mutes"],
|
||||||
|
"domainblocks" => Localizer["Blocked servers"],
|
||||||
|
"lists" => Localizer["Lists"],
|
||||||
|
"bookmarks" => Localizer["Bookmarks"],
|
||||||
|
"filters" => Localizer["Filters"],
|
||||||
|
"tags" => Localizer["Followed hashtags"],
|
||||||
|
"notificationpolicy" => Localizer["Notification policy"],
|
||||||
|
"pins" => Localizer["Pinned posts"],
|
||||||
|
"located" => Localizer["Located posts"],
|
||||||
|
"scheduled" => Localizer["Scheduled posts"],
|
||||||
|
"likes" => Localizer["Likes, given again"],
|
||||||
|
_ => part
|
||||||
|
};
|
||||||
|
|
||||||
|
string CountName(string what) => what switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["posts imported"],
|
||||||
|
"posts already here" => Localizer["posts already here"],
|
||||||
|
"posts deleted here" => Localizer["posts deleted here, left deleted"],
|
||||||
|
"boosts skipped" => Localizer["boosts left out"],
|
||||||
|
"direct or circle posts skipped" => Localizer["private posts left out"],
|
||||||
|
"not the archive's own" => Localizer["posts by others left out"],
|
||||||
|
"not a post" => Localizer["other items left out"],
|
||||||
|
"media" => Localizer["pictures and files imported"],
|
||||||
|
"media not in the archive" => Localizer["pictures not in the archive"],
|
||||||
|
"media refused" => Localizer["pictures refused"],
|
||||||
|
"located posts" => Localizer["located posts imported"],
|
||||||
|
"located posts already here" => Localizer["located posts already here"],
|
||||||
|
"scheduled posts" => Localizer["scheduled posts imported"],
|
||||||
|
"scheduled posts past or refused" => Localizer["scheduled posts past or refused"],
|
||||||
|
"followed hashtags" => Localizer["hashtags followed"],
|
||||||
|
"notification policy" => Localizer["notification policy set"],
|
||||||
|
"filters" => Localizer["filters imported"],
|
||||||
|
"pins" => Localizer["posts pinned"],
|
||||||
|
"blocked servers" => Localizer["servers blocked"],
|
||||||
|
"blocks" => Localizer["accounts blocked"],
|
||||||
|
"mutes" => Localizer["accounts muted"],
|
||||||
|
"follows" => Localizer["follows asked"],
|
||||||
|
"lists" => Localizer["lists made"],
|
||||||
|
"list members" => Localizer["list members added"],
|
||||||
|
"list members not followed" => Localizer["list members not followed"],
|
||||||
|
"bookmarks" => Localizer["bookmarks imported"],
|
||||||
|
"likes" => Localizer["likes given"],
|
||||||
|
"profile" => Localizer["profile updated"],
|
||||||
|
"pictures refused" => Localizer["pictures refused"],
|
||||||
|
_ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]],
|
||||||
|
_ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]],
|
||||||
|
_ => what
|
||||||
|
};
|
||||||
|
|
||||||
|
void Choose(string part, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
chosen.Add(part);
|
||||||
|
else
|
||||||
|
chosen.Remove(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||||
|
|
||||||
|
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||||
|
|
||||||
|
async Task Export()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Export(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)result.Data;
|
||||||
|
link = null;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Link()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Ticket(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||||
|
self ??= DotNetObjectReference.Create(this);
|
||||||
|
var started = await Archives.StartUpload();
|
||||||
|
if (started.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload = (ViewArchiveUpload)started.Data;
|
||||||
|
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||||
|
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri;
|
||||||
|
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||||
|
uploading = false;
|
||||||
|
if (!sent.Ok)
|
||||||
|
{
|
||||||
|
await Archives.CancelUpload(upload.Id);
|
||||||
|
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] });
|
||||||
|
if (asked.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(asked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)asked.Data;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Stop()
|
||||||
|
{
|
||||||
|
var result = await Archives.Stop(loadedFor);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public Task Progress(long received, long total)
|
||||||
|
{
|
||||||
|
(uploaded, uploadSize) = (received, total);
|
||||||
|
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||||
|
return InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UploadOutcome
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
polling?.Cancel();
|
||||||
|
self?.Dispose();
|
||||||
|
if (transfer is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transfer.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
|
||||||
|
to one another: no server, PrivaPub's included, shows that they share a root. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-personas>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var persona in CascadingState.Personas)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
|
||||||
|
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
|
||||||
|
@if (persona.Id == CascadingState.PersonaId)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm">@Localizer["A new persona"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.UserName" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-persona-username>
|
||||||
|
<Prefix>@@</Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["Display name"]" data-persona-name />
|
||||||
|
</div>
|
||||||
|
<DTextarea @bind-Value="form.Biography" Immediate="true" rows="3" autocomplete="off" maxlength="@ProfileForm.NoteLength"
|
||||||
|
placeholder="@Localizer["Biography"]" data-persona-biography />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-persona-create>
|
||||||
|
<i class="ion-md-person-add"></i><span>@Localizer["Make the persona"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
|
||||||
|
InsertAvatarForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
string switching;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
async Task Use(ViewAvatar persona)
|
||||||
|
{
|
||||||
|
switching = persona.Id;
|
||||||
|
var result = await CascadingState.SwitchPersona(persona.Id);
|
||||||
|
switching = null;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
form.UserName = form.UserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
var result = await AuthService.CreatePersona(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form = new();
|
||||||
|
await CascadingState.ReloadPersonas();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Persona made."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The current persona's profile (/api/v1/accounts/update_credentials): its pictures, name, biography and fields as it
|
||||||
|
wrote them, whether it approves followers by hand, is a bot, is listed and searchable, and how its new posts start.
|
||||||
|
PrivaPub tells the persona's followers' servers of the change. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (account is null)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||||
|
<div class="relative w-full">
|
||||||
|
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||||
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
|
||||||
|
alt="@Localizer["Avatar"]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>@Localizer["Avatar"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
|
</label>
|
||||||
|
@if (HasOwn(account.Avatar) && form.Avatar is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>@Localizer["Header"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
|
</label>
|
||||||
|
@if (HasOwn(account.Header) && form.Header is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 max-w-md">
|
||||||
|
<span class="text-sm">@Localizer["Display name"]</span>
|
||||||
|
<DInput TValue="string" @bind-Value="form.DisplayName" Immediate="true" autocomplete="off" maxlength="@ProfileForm.DisplayNameLength"
|
||||||
|
data-profile-name />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Biography"]</span>
|
||||||
|
<DTextarea @bind-Value="form.Note" Immediate="true" rows="4" autocomplete="off" maxlength="@ProfileForm.NoteLength" data-profile-note />
|
||||||
|
<span class="text-xs opacity-70 tabular-nums self-end">@(form.Note?.Length ?? 0)/@ProfileForm.NoteLength</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Profile fields"]</legend>
|
||||||
|
@foreach (var field in form.Fields)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2" data-profile-field="@form.Fields.IndexOf(field)">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-36" @bind-Value="field.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Label"]" />
|
||||||
|
<DInput TValue="string" class="flex-[2] min-w-48" @bind-Value="field.Value" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Content"]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<DToggle LabelText="@Localizer["Approve followers by hand"]" @bind-Value="form.Locked" data-profile-locked />
|
||||||
|
<DToggle LabelText="@Localizer["This persona is automated (a bot)"]" @bind-Value="form.Bot" />
|
||||||
|
<DToggle LabelText="@Localizer["List it in the directory and suggest it to others"]" @bind-Value="form.Discoverable" />
|
||||||
|
<DToggle LabelText="@Localizer["Let its public posts be found by search"]" @bind-Value="form.Indexable" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-4">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Visibility"]</span>
|
||||||
|
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||||
|
@foreach (var type in Visibilities)
|
||||||
|
{
|
||||||
|
<option value="@type">@Localizer[type.ToString()]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Language"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="form.Language">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach (var (languageCode, languageName) in Languages.All)
|
||||||
|
{
|
||||||
|
<option value="@languageCode">@languageName</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Who may quote them"]</span>
|
||||||
|
<DSelect TValue="QuotePolicy" Size="DSize.Sm" @bind-Value="form.QuotePolicy">
|
||||||
|
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||||
|
{
|
||||||
|
<option value="@policy">@QuotePolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DToggle class="self-end" LabelText="@Localizer["Mark media as sensitive"]" @bind-Value="form.Sensitive" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalErrors="pictureErrors" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-end">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving)">@Localizer["Reset"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-profile-save>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||||
|
|
||||||
|
Account account;
|
||||||
|
ProfileForm form = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
List<string> pictureErrors = [];
|
||||||
|
// the picture types the server takes, read with the account
|
||||||
|
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
|
||||||
|
string PictureTypes => string.Join(",", pictureTypes);
|
||||||
|
|
||||||
|
// a picture of its own, not the server's default
|
||||||
|
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
// the account as the persona sees itself, with its Source
|
||||||
|
account = await AuthService.CurrentAccount();
|
||||||
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
form = account is null ? new() : ProfileForm.Of(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
string QuotePolicyName(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => Localizer["Its followers"],
|
||||||
|
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||||
|
_ => Localizer["Anyone"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs, bool isAvatar)
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
var file = eventArgs.File;
|
||||||
|
if (!pictureTypes.Contains(file.ContentType))
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.Size > ProfileForm.MaximumPictureSize)
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
|
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
|
||||||
|
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
|
||||||
|
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||||
|
if (isAvatar)
|
||||||
|
form.Avatar = picture;
|
||||||
|
else
|
||||||
|
form.Header = picture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
|
||||||
|
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(buffer);
|
||||||
|
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemovePicture(bool avatar)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.RemovePicture(avatar);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.UpdateProfile(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Profile saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
||||||
@if (Toast.Messages.Count > 0)
|
@if (Toast.Messages.Count > 0)
|
||||||
{
|
{
|
||||||
<DToast class="z-[1000]">
|
@* (on a phone above the round New post button) *@
|
||||||
|
<DToast class="z-[1000] max-md:bottom-20">
|
||||||
@foreach (var message in Toast.Messages)
|
@foreach (var message in Toast.Messages)
|
||||||
{
|
{
|
||||||
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ public static class GenericExtensions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a count as a phone's narrow action buttons show it: 999, 1.2k, 12k, 1.2M
|
||||||
|
public static string Compact(this int count) => count switch
|
||||||
|
{
|
||||||
|
< 1000 => count.ToString(CultureInfo.CurrentCulture),
|
||||||
|
< 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k",
|
||||||
|
< 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k",
|
||||||
|
_ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M"
|
||||||
|
};
|
||||||
|
|
||||||
public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
|
public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
|
||||||
{
|
{
|
||||||
>= 1_048_576 => localizer["{0}MB", size / 1_048_576],
|
>= 1_048_576 => localizer["{0}MB", size / 1_048_576],
|
||||||
@@ -92,6 +101,7 @@ public static class GenericExtensions
|
|||||||
TimelineType.Favourites => "ion-md-heart",
|
TimelineType.Favourites => "ion-md-heart",
|
||||||
TimelineType.Bookmarks => "ion-md-bookmark",
|
TimelineType.Bookmarks => "ion-md-bookmark",
|
||||||
TimelineType.Tag => "ion-md-pricetag",
|
TimelineType.Tag => "ion-md-pricetag",
|
||||||
|
TimelineType.Lists => "ion-md-list",
|
||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace decePubClient.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A post's or an account's custom emoji (`:shortcode:` among its <c>emojis</c>) shown as their pictures. The shortcode
|
||||||
|
/// stays as the picture's alt text, so it is what a screen reader reads and what is copied. Only shortcodes the
|
||||||
|
/// server listed for that post or account are replaced, and only with an http(s) picture.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class CustomEmojis
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@":([A-Za-z0-9_+-]{1,64}):")]
|
||||||
|
private static partial Regex Shortcode();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(<[^>]*>)")]
|
||||||
|
private static partial Regex Tag();
|
||||||
|
|
||||||
|
/// <summary>Plain text (a name, a content warning): escaped, then its shortcodes as pictures.</summary>
|
||||||
|
public static MarkupString Text(string text, IReadOnlyCollection<CustomEmoji> emojis) =>
|
||||||
|
new(Replace(WebUtility.HtmlEncode(text ?? string.Empty), emojis));
|
||||||
|
|
||||||
|
/// <summary>HTML the server sanitized: the shortcodes in its text, never inside a tag.</summary>
|
||||||
|
public static string Html(string html, IReadOnlyCollection<CustomEmoji> emojis) =>
|
||||||
|
emojis.Count == 0 || string.IsNullOrEmpty(html)
|
||||||
|
? html
|
||||||
|
: string.Concat(Tag().Split(html).Select(part => part.StartsWith('<') ? part : Replace(part, emojis)));
|
||||||
|
|
||||||
|
static string Replace(string text, IReadOnlyCollection<CustomEmoji> emojis)
|
||||||
|
{
|
||||||
|
if (emojis.Count == 0 || !text.Contains(':'))
|
||||||
|
return text;
|
||||||
|
var known = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var emoji in emojis)
|
||||||
|
if (!string.IsNullOrEmpty(emoji.Shortcode) && Uri.TryCreate(emoji.Url, UriKind.Absolute, out var url)
|
||||||
|
&& url.Scheme is "https" or "http")
|
||||||
|
known.TryAdd(emoji.Shortcode, url.AbsoluteUri);
|
||||||
|
return Shortcode().Replace(text, match => known.TryGetValue(match.Groups[1].Value, out var url)
|
||||||
|
? $"<img class=\"custom-emoji\" src=\"{WebUtility.HtmlEncode(url)}\" alt=\"{match.Value}\" title=\"{match.Value}\" draggable=\"false\" />"
|
||||||
|
: match.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace decePubClient.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>The languages a post can be marked as written in: ISO 639-1 codes and each language's own name.</summary>
|
||||||
|
public static class Languages
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<(string Code, string Name)> All =
|
||||||
|
[
|
||||||
|
("ar", "العربية"), ("bg", "Български"), ("ca", "Català"), ("cs", "Čeština"), ("cy", "Cymraeg"), ("da", "Dansk"),
|
||||||
|
("de", "Deutsch"), ("el", "Ελληνικά"), ("en", "English"), ("eo", "Esperanto"), ("es", "Español"), ("et", "Eesti"),
|
||||||
|
("eu", "Euskara"), ("fa", "فارسی"), ("fi", "Suomi"), ("fr", "Français"), ("ga", "Gaeilge"), ("gl", "Galego"),
|
||||||
|
("he", "עברית"), ("hi", "हिन्दी"), ("hr", "Hrvatski"), ("hu", "Magyar"), ("id", "Bahasa Indonesia"), ("is", "Íslenska"),
|
||||||
|
("it", "Italiano"), ("ja", "日本語"), ("ko", "한국어"), ("lt", "Lietuvių"), ("lv", "Latviešu"), ("nl", "Nederlands"),
|
||||||
|
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"), ("ru", "Русский"), ("sk", "Slovenčina"),
|
||||||
|
("sl", "Slovenščina"), ("sr", "Српски"), ("sv", "Svenska"), ("th", "ไทย"), ("tr", "Türkçe"), ("uk", "Українська"),
|
||||||
|
("vi", "Tiếng Việt"), ("zh", "中文")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
public PublicCacheData PublicCacheData { get; private set; } = new();
|
public PublicCacheData PublicCacheData { get; private set; } = new();
|
||||||
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
||||||
public bool IsSignedIn { get; private set; }
|
public bool IsSignedIn { get; private set; }
|
||||||
|
/// <summary>The session has been read once: from then on a null PersonaId means the login has no persona, not "not yet".</summary>
|
||||||
|
public bool IsSessionLoaded { get; private set; }
|
||||||
/// <summary>The root's login name, shown to its owner only.</summary>
|
/// <summary>The root's login name, shown to its owner only.</summary>
|
||||||
public string RootUserName { get; private set; }
|
public string RootUserName { get; private set; }
|
||||||
/// <summary>The root's personas.</summary>
|
/// <summary>The root's personas.</summary>
|
||||||
@@ -93,6 +95,7 @@
|
|||||||
if (!IsSignedIn)
|
if (!IsSignedIn)
|
||||||
{
|
{
|
||||||
ClearSession();
|
ClearSession();
|
||||||
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,11 +108,16 @@
|
|||||||
{
|
{
|
||||||
PersonaId = null;
|
PersonaId = null;
|
||||||
Persona = null;
|
Persona = null;
|
||||||
|
IsSessionLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loaded once the persona is known (or couldn't be used): a page rendered while it is switched to would otherwise
|
||||||
|
// see a loaded session without one, and the feed would say it is empty
|
||||||
await SwitchPersona(personaId, cancellationToken);
|
await SwitchPersona(personaId, cancellationToken);
|
||||||
|
IsSessionLoaded = true;
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
||||||
@@ -127,6 +135,29 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the root's personas again, after one was made.</summary>
|
||||||
|
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Personas = await AuthService.Personas(cancellationToken);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
|
||||||
|
/// chooser shows the new name and picture too.</summary>
|
||||||
|
public void PersonaChanged(Account account)
|
||||||
|
{
|
||||||
|
if (account is null || PersonaId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Persona = account;
|
||||||
|
if (Personas.FirstOrDefault(p => p.Id == PersonaId) is { } persona)
|
||||||
|
{
|
||||||
|
persona.Name = account.DisplayName;
|
||||||
|
persona.PictureURL = account.Avatar;
|
||||||
|
}
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
||||||
public async Task SignOut(CancellationToken cancellationToken = default)
|
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(Account))]
|
[JsonSerializable(typeof(Account))]
|
||||||
[JsonSerializable(typeof(List<Account>))]
|
[JsonSerializable(typeof(List<Account>))]
|
||||||
[JsonSerializable(typeof(Relationship))]
|
[JsonSerializable(typeof(Relationship))]
|
||||||
|
[JsonSerializable(typeof(List<Relationship>))]
|
||||||
|
[JsonSerializable(typeof(List<string>))]
|
||||||
|
[JsonSerializable(typeof(SearchResults))]
|
||||||
[JsonSerializable(typeof(MediaAttachment))]
|
[JsonSerializable(typeof(MediaAttachment))]
|
||||||
|
[JsonSerializable(typeof(InstanceV2))]
|
||||||
[JsonSerializable(typeof(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
[JsonSerializable(typeof(List<Notification>))]
|
[JsonSerializable(typeof(List<Notification>))]
|
||||||
[JsonSerializable(typeof(Conversation))]
|
[JsonSerializable(typeof(Conversation))]
|
||||||
@@ -31,6 +35,30 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(TokenResponse))]
|
[JsonSerializable(typeof(TokenResponse))]
|
||||||
[JsonSerializable(typeof(MastodonError))]
|
[JsonSerializable(typeof(MastodonError))]
|
||||||
[JsonSerializable(typeof(PostStatusForm))]
|
[JsonSerializable(typeof(PostStatusForm))]
|
||||||
|
[JsonSerializable(typeof(StatusEditForm))]
|
||||||
|
[JsonSerializable(typeof(StatusSource))]
|
||||||
|
[JsonSerializable(typeof(List<StatusEdit>))]
|
||||||
|
[JsonSerializable(typeof(ReportRequest))]
|
||||||
|
[JsonSerializable(typeof(NotificationPolicy))]
|
||||||
|
[JsonSerializable(typeof(List<NotificationRequest>))]
|
||||||
|
[JsonSerializable(typeof(Poll))]
|
||||||
|
[JsonSerializable(typeof(PollVoteForm))]
|
||||||
|
[JsonSerializable(typeof(MastodonList))]
|
||||||
|
[JsonSerializable(typeof(List<MastodonList>))]
|
||||||
|
[JsonSerializable(typeof(ListAccountsForm))]
|
||||||
|
[JsonSerializable(typeof(Filter))]
|
||||||
|
[JsonSerializable(typeof(List<Filter>))]
|
||||||
|
[JsonSerializable(typeof(FilterForm))]
|
||||||
|
[JsonSerializable(typeof(FilterStatus))]
|
||||||
|
[JsonSerializable(typeof(ScheduledStatus))]
|
||||||
|
[JsonSerializable(typeof(List<ScheduledStatus>))]
|
||||||
|
[JsonSerializable(typeof(ScheduleForm))]
|
||||||
|
[JsonSerializable(typeof(Tag))]
|
||||||
|
[JsonSerializable(typeof(List<Tag>))]
|
||||||
|
[JsonSerializable(typeof(List<TrendLink>))]
|
||||||
|
[JsonSerializable(typeof(GroupedNotifications))]
|
||||||
|
[JsonSerializable(typeof(MarkersForm))]
|
||||||
|
[JsonSerializable(typeof(UnreadCount))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -16,12 +16,49 @@ public static class APIs
|
|||||||
public const string POST_Login = "clientapi/user/login";
|
public const string POST_Login = "clientapi/user/login";
|
||||||
public const string GET_Logout = "clientapi/user/logout";
|
public const string GET_Logout = "clientapi/user/logout";
|
||||||
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
||||||
|
public const string POST_UpdatePassword = "clientapi/user/update/password";
|
||||||
|
public const string POST_DeleteAccount = "clientapi/user/delete";
|
||||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
||||||
public const string GET_Personas = "clientapi/avatar/private/list";
|
public const string GET_Personas = "clientapi/avatar/private/list";
|
||||||
|
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||||
public const string GET_Groups = "clientapi/group/list";
|
public const string GET_Groups = "clientapi/group/list";
|
||||||
|
public const string GET_GroupMembers = "clientapi/group/members";
|
||||||
|
public const string POST_InsertGroup = "clientapi/group/insert";
|
||||||
|
public const string POST_UpdateGroup = "clientapi/group/update";
|
||||||
|
public const string POST_JoinGroup = "clientapi/group/join";
|
||||||
|
public const string POST_LeaveGroup = "clientapi/group/leave";
|
||||||
|
public const string POST_ApproveGroupMember = "clientapi/group/approve";
|
||||||
|
public const string POST_RejectGroupMember = "clientapi/group/reject";
|
||||||
|
public const string POST_RemoveGroupMember = "clientapi/group/remove";
|
||||||
public const string GET_Posts = "clientapi/post/list";
|
public const string GET_Posts = "clientapi/post/list";
|
||||||
public const string GET_Nearby = "clientapi/post/nearby";
|
public const string GET_Nearby = "clientapi/post/nearby";
|
||||||
public const string POST_InsertPost = "clientapi/post/insert";
|
public const string POST_InsertPost = "clientapi/post/insert";
|
||||||
|
public const string POST_DeletePost = "clientapi/post/delete";
|
||||||
|
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||||
|
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
||||||
|
public const string GET_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 const string GET_AdminBackups = "clientapi/admin/backups";
|
||||||
|
public const string POST_AdminBackup = "clientapi/admin/backups";
|
||||||
|
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket";
|
||||||
|
public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore";
|
||||||
|
public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads";
|
||||||
|
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
|
||||||
|
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
|
||||||
|
public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
|
||||||
|
public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
|
||||||
|
public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
|
||||||
|
public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class OAuth
|
public static class OAuth
|
||||||
@@ -36,6 +73,12 @@ public static class APIs
|
|||||||
public static class Mastodon
|
public static class Mastodon
|
||||||
{
|
{
|
||||||
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
||||||
|
public const string PATCH_UpdateCredentials = "api/v1/accounts/update_credentials";
|
||||||
|
public const string GET_Mutes = "api/v1/mutes";
|
||||||
|
public const string GET_Blocks = "api/v1/blocks";
|
||||||
|
public const string GET_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string POST_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string DELETE_DomainBlocks = "api/v1/domain_blocks";
|
||||||
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
||||||
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
||||||
public const string GET_Statuses = "api/v1/statuses";
|
public const string GET_Statuses = "api/v1/statuses";
|
||||||
@@ -43,17 +86,71 @@ public static class APIs
|
|||||||
public const string GET_Favourites = "api/v1/favourites";
|
public const string GET_Favourites = "api/v1/favourites";
|
||||||
public const string GET_Bookmarks = "api/v1/bookmarks";
|
public const string GET_Bookmarks = "api/v1/bookmarks";
|
||||||
public const string GET_Conversations = "api/v1/conversations";
|
public const string GET_Conversations = "api/v1/conversations";
|
||||||
|
public 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 GET_Notifications = "api/v1/notifications";
|
||||||
public const string POST_Media = "api/v2/media";
|
public const string POST_Media = "api/v2/media";
|
||||||
|
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public const string GET_InstanceV2 = "api/v2/instance";
|
||||||
|
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
|
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||||
|
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||||
|
public const string GET_Search = "api/v2/search";
|
||||||
|
|
||||||
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
||||||
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
||||||
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
||||||
|
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
||||||
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
||||||
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
|
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
|
||||||
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
|
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
|
||||||
|
public static string M_Reaction(string statusId, string emoji) =>
|
||||||
|
$"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}";
|
||||||
|
public static string POST_M_PollVotes(string pollId) => $"api/v1/polls/{Uri.EscapeDataString(pollId)}/votes";
|
||||||
|
public static string POST_M_FollowRequest(string accountId, string answer) => $"api/v1/follow_requests/{Uri.EscapeDataString(accountId)}/{answer}";
|
||||||
|
public const string GET_Lists = "api/v1/lists";
|
||||||
|
public const string POST_List = "api/v1/lists";
|
||||||
|
public const string GET_AccountSearch = "api/v1/accounts/search";
|
||||||
|
public static string PUT_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
|
||||||
|
public static string DELETE_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
|
||||||
|
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
|
||||||
|
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
|
||||||
|
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
|
||||||
|
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
|
||||||
|
public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source";
|
||||||
|
public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
|
||||||
|
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
||||||
|
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
||||||
|
public const string POST_Reports = "api/v1/reports";
|
||||||
|
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
|
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
|
||||||
|
public const string GET_NotificationRequests = "api/v1/notifications/requests";
|
||||||
|
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
||||||
|
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
||||||
|
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
|
public 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 const string POST_Markers = "api/v1/markers";
|
||||||
|
public const string GET_TrendingTags = "api/v1/trends/tags";
|
||||||
|
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
||||||
|
public const string GET_TrendingLinks = "api/v1/trends/links";
|
||||||
|
public const string GET_Directory = "api/v1/directory";
|
||||||
|
public static string GET_M_Tag(string name) => $"api/v1/tags/{Uri.EscapeDataString(name)}";
|
||||||
|
public static string POST_M_TagAction(string name, string action) => $"api/v1/tags/{Uri.EscapeDataString(name)}/{action}";
|
||||||
|
public static string PUT_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string DELETE_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
|
||||||
|
public const string GET_Filters = "api/v2/filters";
|
||||||
|
public const string POST_Filter = "api/v2/filters";
|
||||||
|
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string DELETE_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string POST_M_FilterStatus(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}/statuses";
|
||||||
|
public static string DELETE_M_FilterStatus(string filterStatusId) => $"api/v2/filters/statuses/{Uri.EscapeDataString(filterStatusId)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PrivaPub
|
public static class PrivaPub
|
||||||
@@ -65,5 +162,7 @@ public static class APIs
|
|||||||
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
||||||
|
|
||||||
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
||||||
|
public static string POST_M_Join(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/join";
|
||||||
|
public static string POST_M_Leave(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/leave";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ public class ComposeForm
|
|||||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
|
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
@@ -37,6 +39,26 @@ public class ComposeForm
|
|||||||
public double? Latitude { get; set; }
|
public double? Latitude { get; set; }
|
||||||
public double? Longitude { get; set; }
|
public double? Longitude { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A poll instead of media: two to four choices, open ExpiresIn seconds, one choice or several.</summary>
|
||||||
|
public bool HasPoll { get; set; }
|
||||||
|
public List<string> PollOptions { get; set; } = ["", ""];
|
||||||
|
public int PollExpiresIn { get; set; } = 86400;
|
||||||
|
public bool PollMultiple { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The language the post is written in (ISO 639-1); null leaves it to PrivaPub.</summary>
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The post this one quotes.</summary>
|
||||||
|
public string QuotedStatusId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now.</summary>
|
||||||
|
public bool IsScheduled { get; set; }
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the post is to be published, in UTC, or null when it goes now or ScheduledAt is not a time.</summary>
|
||||||
|
public DateTime? ScheduledAtUtc => IsScheduled && DateTime.TryParse(ScheduledAt, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AssumeLocal, out var local) ? local.ToUniversalTime() : null;
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public MediaType MediaType { get; set; } = MediaType.Images;
|
public MediaType MediaType { get; set; } = MediaType.Images;
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public class FeedFilter
|
|||||||
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
||||||
/// <summary>The community or circle the Groups tab shows.</summary>
|
/// <summary>The community or circle the Groups tab shows.</summary>
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
|
/// <summary>The persona's list the Lists tab shows.</summary>
|
||||||
|
public string ListId { get; set; }
|
||||||
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
||||||
public string Tag { get; set; }
|
public string Tag { get; set; }
|
||||||
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
||||||
|
|||||||
+38
-3
@@ -17,7 +17,11 @@ public class FeedPost
|
|||||||
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public Account BoostedBy { get; init; }
|
public Account BoostedBy { get; init; }
|
||||||
public Notification Notification { get; init; }
|
public Notification Notification { get; set; }
|
||||||
|
/// <summary>For a notification standing for a group (likes of one post, follows of one hour): how many, and a few of
|
||||||
|
/// the accounts, newest first.</summary>
|
||||||
|
public int GroupCount { get; set; } = 1;
|
||||||
|
public List<Account> GroupAccounts { get; set; } = [];
|
||||||
public Conversation Conversation { get; init; }
|
public Conversation Conversation { get; init; }
|
||||||
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
||||||
public bool Located { get; init; }
|
public bool Located { get; init; }
|
||||||
@@ -30,6 +34,11 @@ public class FeedPost
|
|||||||
|
|
||||||
public string Host => Author?.Host;
|
public string Host => Author?.Host;
|
||||||
|
|
||||||
|
/// <summary>When the post was written (for a follow, when it happened), in UTC; the globe shows the Sun of that moment.</summary>
|
||||||
|
public DateTime? WrittenAt => (Status?.CreatedAt ?? Notification?.CreatedAt) is { } at
|
||||||
|
? (at.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(at, DateTimeKind.Utc) : at.ToUniversalTime())
|
||||||
|
: null;
|
||||||
|
|
||||||
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
||||||
|
|
||||||
public static FeedPost Of(Status status) => status.Reblog is { } original
|
public static FeedPost Of(Status status) => status.Reblog is { } original
|
||||||
@@ -38,12 +47,38 @@ public class FeedPost
|
|||||||
|
|
||||||
public static FeedPost Of(Notification notification) => new()
|
public static FeedPost Of(Notification notification) => new()
|
||||||
{
|
{
|
||||||
Key = notification.Id,
|
Key = notification.GroupKey ?? notification.Id,
|
||||||
CursorId = notification.Id,
|
CursorId = notification.Id,
|
||||||
Status = notification.Status?.Reblog ?? notification.Status,
|
Status = notification.Status?.Reblog ?? notification.Status,
|
||||||
Notification = notification
|
Notification = notification,
|
||||||
|
GroupAccounts = notification.Account is null ? [] : [notification.Account]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>A group of notifications as one item: its newest notification, how many it holds, and a few accounts.</summary>
|
||||||
|
public static FeedPost Of(NotificationGroup group, IReadOnlyDictionary<string, Account> accounts, IReadOnlyDictionary<string, Status> statuses)
|
||||||
|
{
|
||||||
|
var sample = group.SampleAccountIds.Select(accounts.GetValueOrDefault).Where(a => a is not null).ToList();
|
||||||
|
var status = group.StatusId is null ? null : statuses.GetValueOrDefault(group.StatusId);
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Key = group.GroupKey,
|
||||||
|
CursorId = group.MostRecentNotificationId,
|
||||||
|
Status = status?.Reblog ?? status,
|
||||||
|
Notification = new Notification
|
||||||
|
{
|
||||||
|
Id = group.MostRecentNotificationId,
|
||||||
|
Type = group.Type,
|
||||||
|
CreatedAt = group.LatestPageNotificationAt,
|
||||||
|
Account = sample.FirstOrDefault(),
|
||||||
|
Status = status,
|
||||||
|
Emoji = group.Emoji,
|
||||||
|
GroupKey = group.GroupKey
|
||||||
|
},
|
||||||
|
GroupCount = Math.Max(1, group.NotificationsCount),
|
||||||
|
GroupAccounts = sample
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static FeedPost Of(Conversation conversation) => new()
|
public static FeedPost Of(Conversation conversation) => new()
|
||||||
{
|
{
|
||||||
Key = conversation.Id,
|
Key = conversation.Id,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ public class Account
|
|||||||
public string LastStatusAt { get; set; }
|
public string LastStatusAt { get; set; }
|
||||||
public List<CustomEmoji> Emojis { get; set; } = [];
|
public List<CustomEmoji> Emojis { get; set; } = [];
|
||||||
public List<AccountField> Fields { get; set; } = [];
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>The account it moved to (PrivaPub shows it once the new account names this one among its aliases).</summary>
|
||||||
|
public Account Moved { get; set; }
|
||||||
|
/// <summary>Whether it is listed in the directory and suggested; null when its server does not say.</summary>
|
||||||
|
public bool? Discoverable { get; set; }
|
||||||
|
/// <summary>Whether its public posts may be found by full-text search.</summary>
|
||||||
|
public bool? Indexable { get; set; }
|
||||||
|
/// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its
|
||||||
|
/// posting defaults.</summary>
|
||||||
|
public AccountSource Source { get; set; }
|
||||||
|
|
||||||
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
||||||
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
||||||
@@ -33,6 +42,27 @@ public class Account
|
|||||||
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AccountSource
|
||||||
|
{
|
||||||
|
/// <summary>The biography as written, before PrivaPub renders it.</summary>
|
||||||
|
public string Note { get; set; }
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>The default visibility of a new post: public, unlisted or private.</summary>
|
||||||
|
public string Privacy { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
/// <summary>Who may quote the persona's posts: public, followers or nobody.</summary>
|
||||||
|
public string QuotePolicy { get; set; }
|
||||||
|
public int FollowRequestsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page of accounts (mutes, blocks), and the cursor of the next one: null at the end.</summary>
|
||||||
|
public class AccountPage
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public string MaxId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class AccountField
|
public class AccountField
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@@ -53,6 +83,20 @@ public class Relationship
|
|||||||
public bool Following { get; set; }
|
public bool Following { get; set; }
|
||||||
public bool FollowedBy { get; set; }
|
public bool FollowedBy { get; set; }
|
||||||
public bool Blocking { get; set; }
|
public bool Blocking { get; set; }
|
||||||
|
public bool BlockedBy { get; set; }
|
||||||
public bool Muting { get; set; }
|
public bool Muting { get; set; }
|
||||||
|
public bool MutingNotifications { get; set; }
|
||||||
|
public bool ShowingReblogs { get; set; }
|
||||||
|
public bool Notifying { get; set; }
|
||||||
public bool Requested { get; set; }
|
public bool Requested { get; set; }
|
||||||
|
public bool RequestedBy { get; set; }
|
||||||
|
public bool DomainBlocking { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/search: what matched, a remote handle or URL resolved by PrivaPub.</summary>
|
||||||
|
public class SearchResults
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public List<Status> Statuses { get; set; } = [];
|
||||||
|
public List<StatusTag> Hashtags { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A hashtag as PrivaPub describes it: its last week of use in public posts it holds, and whether the persona
|
||||||
|
/// follows it (null when nobody is signed in).</summary>
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
public bool? Following { get; set; }
|
||||||
|
|
||||||
|
public int WeekUses => History.Sum(h => h.UsesCount);
|
||||||
|
public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One day of a tag's or a link's use; Mastodon sends the numbers as strings, the day as unix time.</summary>
|
||||||
|
public class TagDay
|
||||||
|
{
|
||||||
|
public string Day { get; set; }
|
||||||
|
public string Uses { get; set; }
|
||||||
|
public string Accounts { get; set; }
|
||||||
|
|
||||||
|
public int UsesCount => int.TryParse(Uses, out var n) ? n : 0;
|
||||||
|
public int AccountsCount => int.TryParse(Accounts, out var n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A link many authors shared this week: its preview card with its days.</summary>
|
||||||
|
public class TrendLink : PreviewCard
|
||||||
|
{
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
|
||||||
|
public int WeekAccounts => History.Sum(h => h.AccountsCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A persona's filter (Mastodon's v2 filters): words and posts it would rather not see in some contexts, to be
|
||||||
|
/// warned about, hidden or blurred. Statuses carry the filters they match (Status.Filtered); the client applies the
|
||||||
|
/// context and the action.</summary>
|
||||||
|
public class Filter
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public List<FilterKeyword> Keywords { get; set; } = [];
|
||||||
|
public List<FilterStatus> Statuses { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsActive => ExpiresAt is null || ExpiresAt > DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeyword
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string StatusId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterResult
|
||||||
|
{
|
||||||
|
public Filter Filter { get; set; }
|
||||||
|
public List<string> KeywordMatches { get; set; } = [];
|
||||||
|
public List<string> StatusMatches { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a filter applies; a card is told which of these it is shown in.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterContext>))]
|
||||||
|
public enum FilterContext
|
||||||
|
{
|
||||||
|
[JsonStringEnumMemberName("home")] Home,
|
||||||
|
[JsonStringEnumMemberName("notifications")] Notifications,
|
||||||
|
[JsonStringEnumMemberName("public")] Public,
|
||||||
|
[JsonStringEnumMemberName("thread")] Thread,
|
||||||
|
[JsonStringEnumMemberName("account")] Account
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterAction>))]
|
||||||
|
public enum FilterAction
|
||||||
|
{
|
||||||
|
/// <summary>The post is folded behind the filter's title.</summary>
|
||||||
|
[JsonStringEnumMemberName("warn")] Warn,
|
||||||
|
/// <summary>The post is not shown at all.</summary>
|
||||||
|
[JsonStringEnumMemberName("hide")] Hide,
|
||||||
|
/// <summary>The post's media are blurred, as sensitive media are.</summary>
|
||||||
|
[JsonStringEnumMemberName("blur")] Blur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What making or changing a filter sends; ExpiresIn 0 means never.</summary>
|
||||||
|
public class FilterForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public int? ExpiresIn { get; set; }
|
||||||
|
public List<FilterKeywordForm> KeywordsAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeywordForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
[JsonPropertyName("_destroy")]
|
||||||
|
public bool? Destroy { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
|
||||||
|
public class InstanceV2
|
||||||
|
{
|
||||||
|
public InstanceConfiguration Configuration { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A persona's list of accounts it follows (Mastodon's List): read as its own feed, and when exclusive kept out of
|
||||||
|
/// the home feed. Lists are the persona's own; PrivaPub never federates them.</summary>
|
||||||
|
public class MastodonList
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public ListRepliesPolicy RepliesPolicy { get; set; } = ListRepliesPolicy.List;
|
||||||
|
public bool Exclusive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which replies a list's feed shows besides its members' replies to themselves and to the persona.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<ListRepliesPolicy>))]
|
||||||
|
public enum ListRepliesPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Replies to any account the persona follows.</summary>
|
||||||
|
[JsonStringEnumMemberName("followed")] Followed,
|
||||||
|
/// <summary>Replies to the list's members.</summary>
|
||||||
|
[JsonStringEnumMemberName("list")] List,
|
||||||
|
[JsonStringEnumMemberName("none")] None
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListAccountsForm
|
||||||
|
{
|
||||||
|
public List<string> AccountIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -12,6 +12,45 @@ public class Notification
|
|||||||
public Account Account { get; set; }
|
public Account Account { get; set; }
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public string Emoji { get; set; }
|
public string Emoji { get; set; }
|
||||||
|
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
|
||||||
|
public string GroupKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary>
|
||||||
|
public class GroupedNotifications
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public List<Status> Statuses { get; set; } = [];
|
||||||
|
public List<NotificationGroup> NotificationGroups { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationGroup
|
||||||
|
{
|
||||||
|
public string GroupKey { get; set; }
|
||||||
|
public int NotificationsCount { get; set; }
|
||||||
|
public string Type { get; set; }
|
||||||
|
public string MostRecentNotificationId { get; set; }
|
||||||
|
public string PageMinId { get; set; }
|
||||||
|
public string PageMaxId { get; set; }
|
||||||
|
public DateTime LatestPageNotificationAt { get; set; }
|
||||||
|
public List<string> SampleAccountIds { get; set; } = [];
|
||||||
|
public string StatusId { get; set; }
|
||||||
|
public string Emoji { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkersForm
|
||||||
|
{
|
||||||
|
public MarkerForm Notifications { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkerForm
|
||||||
|
{
|
||||||
|
public string LastReadId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UnreadCount
|
||||||
|
{
|
||||||
|
public int Count { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A direct conversation: its participants and its last post.</summary>
|
/// <summary>A direct conversation: its participants and its last post.</summary>
|
||||||
|
|||||||
@@ -176,4 +176,117 @@ public class PostStatusForm
|
|||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
public string Visibility { get; set; }
|
public string Visibility { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
/// <summary>When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now.</summary>
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
public PollForm Poll { get; set; }
|
||||||
|
public string QuotedStatusId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PollForm
|
||||||
|
{
|
||||||
|
public List<string> Options { get; set; } = [];
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
public bool Multiple { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||||
|
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||||
|
public class StatusEditForm
|
||||||
|
{
|
||||||
|
public string Status { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public List<string> MediaIds { get; set; } = [];
|
||||||
|
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||||
|
public class MediaAttributeForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||||
|
public class StatusSource
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v2/notifications/policy: what notifications from people the persona does not follow, people who do not
|
||||||
|
/// follow it, new accounts, unasked private mentions and limited accounts become: accept, filter or drop.</summary>
|
||||||
|
public class NotificationPolicy
|
||||||
|
{
|
||||||
|
public string ForNotFollowing { get; set; }
|
||||||
|
public string ForNotFollowers { get; set; }
|
||||||
|
public string ForNewAccounts { get; set; }
|
||||||
|
public string ForPrivateMentions { get; set; }
|
||||||
|
public string ForLimitedAccounts { get; set; }
|
||||||
|
public NotificationPolicySummary Summary { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationPolicySummary
|
||||||
|
{
|
||||||
|
public int PendingRequestsCount { get; set; }
|
||||||
|
public int PendingNotificationsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/notifications/requests: the notifications the policy filtered from one account, waiting to be let in
|
||||||
|
/// (and that account from then on) or dismissed.</summary>
|
||||||
|
public class NotificationRequest
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
/// <summary>A number, as a string (Mastodon's).</summary>
|
||||||
|
public string NotificationsCount { get; set; }
|
||||||
|
public Account Account { get; set; }
|
||||||
|
public Status LastStatus { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
|
||||||
|
/// a remote account's server gets it too, from the server's own actor.</summary>
|
||||||
|
public class ReportRequest
|
||||||
|
{
|
||||||
|
public string AccountId { get; set; }
|
||||||
|
public List<string> StatusIds { get; set; } = [];
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>spam, legal or other.</summary>
|
||||||
|
public string Category { get; set; }
|
||||||
|
public bool Forward { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
|
||||||
|
public class StatusEdit
|
||||||
|
{
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public List<CustomEmoji> Emojis { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
|
||||||
|
public class ScheduledStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime ScheduledAt { get; set; }
|
||||||
|
public ScheduledParams Params { get; set; } = new();
|
||||||
|
public List<MediaAttachment> MediaAttachments { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScheduledParams
|
||||||
|
{
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public string Visibility { get; set; }
|
||||||
|
public string InReplyToId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScheduleForm
|
||||||
|
{
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,11 @@ public class PrivaPubStatus
|
|||||||
{
|
{
|
||||||
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
||||||
public string ObjectType { get; set; }
|
public string ObjectType { get; set; }
|
||||||
|
/// <summary>Its community's moderators locked it: PrivaPub refuses a reply.</summary>
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
/// <summary>The persona's reply or boost of a post whose author must approve it first (GoToSocial's interaction
|
||||||
|
/// policies): "pending" until the author answers, "rejected" if they refuse; null otherwise.</summary>
|
||||||
|
public string Approval { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Excerpt { get; set; }
|
public string Excerpt { get; set; }
|
||||||
public string Cover { get; set; }
|
public string Cover { get; set; }
|
||||||
@@ -16,6 +21,8 @@ public class PrivaPubStatus
|
|||||||
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
|
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
|
||||||
public PrivaPubPlace Place { get; set; }
|
public PrivaPubPlace Place { get; set; }
|
||||||
public PrivaPubVotes Votes { get; set; }
|
public PrivaPubVotes Votes { get; set; }
|
||||||
|
/// <summary>Whose wall it was written on (Smithereen's walls, FEP-400e), when not its author's own.</summary>
|
||||||
|
public Account Wall { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PrivaPubSource
|
public class PrivaPubSource
|
||||||
@@ -68,9 +75,13 @@ public class PrivaPubEvent
|
|||||||
public string TimeZone { get; set; }
|
public string TimeZone { get; set; }
|
||||||
public List<PrivaPubPlace> Places { get; set; } = [];
|
public List<PrivaPubPlace> Places { get; set; } = [];
|
||||||
public string OnlineUrl { get; set; }
|
public string OnlineUrl { get; set; }
|
||||||
|
/// <summary>Mobilizon's: "free", "restricted" (the organiser approves), "invite" or "external" (not joined from here).</summary>
|
||||||
|
public string JoinMode { get; set; }
|
||||||
public int? Participants { get; set; }
|
public int? Participants { get; set; }
|
||||||
public int? Capacity { get; set; }
|
public int? Capacity { get; set; }
|
||||||
public string Status { get; set; }
|
public string Status { get; set; }
|
||||||
|
/// <summary>The persona's own: "pending", "accepted" or "rejected"; null when it has not joined.</summary>
|
||||||
|
public string Participation { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PrivaPubPlace
|
public class PrivaPubPlace
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ public class Status
|
|||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public PrivaPubStatus Privapub { get; set; }
|
public PrivaPubStatus Privapub { get; set; }
|
||||||
public Quote Quote { get; set; }
|
public Quote Quote { get; set; }
|
||||||
|
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
|
||||||
|
public List<EmojiReaction> EmojiReactions { get; set; } = [];
|
||||||
|
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
|
||||||
|
public List<FilterResult> Filtered { get; set; } = [];
|
||||||
public int QuotesCount { get; set; }
|
public int QuotesCount { get; set; }
|
||||||
|
/// <summary>Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona.</summary>
|
||||||
|
public QuoteApproval QuoteApproval { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StatusMention
|
public class StatusMention
|
||||||
@@ -85,9 +91,28 @@ public class Poll
|
|||||||
public int VotesCount { get; set; }
|
public int VotesCount { get; set; }
|
||||||
public int? VotersCount { get; set; }
|
public int? VotersCount { get; set; }
|
||||||
public bool? Voted { get; set; }
|
public bool? Voted { get; set; }
|
||||||
|
/// <summary>The options the current persona chose (indexes).</summary>
|
||||||
|
public List<int> OwnVotes { get; set; } = [];
|
||||||
public List<PollOption> Options { get; set; } = [];
|
public List<PollOption> Options { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class EmojiReaction
|
||||||
|
{
|
||||||
|
/// <summary>The emoji itself, or a custom emoji's shortcode.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
/// <summary>Whether the current persona reacted with it.</summary>
|
||||||
|
public bool Me { get; set; }
|
||||||
|
/// <summary>A custom emoji's image, through PrivaPub's proxy.</summary>
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/v1/polls/{id}/votes.</summary>
|
||||||
|
public class PollVoteForm
|
||||||
|
{
|
||||||
|
public List<int> Choices { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public class PollOption
|
public class PollOption
|
||||||
{
|
{
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
@@ -107,3 +132,10 @@ public class PreviewCard
|
|||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public string Blurhash { get; set; }
|
public string Blurhash { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class QuoteApproval
|
||||||
|
{
|
||||||
|
public List<string> Automatic { get; set; } = [];
|
||||||
|
public List<string> Manual { get; set; } = [];
|
||||||
|
public string CurrentUser { get; set; }
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ namespace decePubClient.Models
|
|||||||
public bool ThemeIsDarkGray { get; set; } = false;
|
public bool ThemeIsDarkGray { get; set; } = false;
|
||||||
|
|
||||||
public bool ShowDonatorBadge { 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;
|
||||||
|
|
||||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
namespace decePubClient.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The persona's profile as Settings edits it, which is what update_credentials takes: the words as written (its
|
||||||
|
/// account's Source), the flags, the posting defaults, and a new avatar or header when one was picked. Fields always
|
||||||
|
/// holds FieldCount rows; a row without a name is dropped.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfileForm
|
||||||
|
{
|
||||||
|
public const int FieldCount = 4;
|
||||||
|
public const int DisplayNameLength = 30;
|
||||||
|
public const int NoteLength = 500;
|
||||||
|
public const int FieldLength = 255;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
public string Note { get; set; }
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
public bool Bot { get; set; }
|
||||||
|
public bool Discoverable { get; set; }
|
||||||
|
public bool Indexable { get; set; }
|
||||||
|
public MessageType Privacy { get; set; } = MessageType.Public;
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
public UploadMedia Avatar { get; set; }
|
||||||
|
public UploadMedia Header { get; set; }
|
||||||
|
|
||||||
|
public static ProfileForm Of(Account account)
|
||||||
|
{
|
||||||
|
var source = account.Source ?? new();
|
||||||
|
var fields = source.Fields.Count > 0 ? source.Fields : account.Fields;
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
DisplayName = account.DisplayName,
|
||||||
|
Note = source.Note,
|
||||||
|
Locked = account.Locked,
|
||||||
|
Bot = account.Bot,
|
||||||
|
Discoverable = account.Discoverable == true,
|
||||||
|
Indexable = account.Indexable == true,
|
||||||
|
Privacy = source.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => MessageType.FollowersOnly,
|
||||||
|
"unlisted" => MessageType.Unlisted,
|
||||||
|
_ => MessageType.Public
|
||||||
|
},
|
||||||
|
Sensitive = source.Sensitive,
|
||||||
|
Language = source.Language,
|
||||||
|
QuotePolicy = source.QuotePolicy switch
|
||||||
|
{
|
||||||
|
"followers" => QuotePolicy.Followers,
|
||||||
|
"nobody" => QuotePolicy.Nobody,
|
||||||
|
_ => QuotePolicy.Public
|
||||||
|
},
|
||||||
|
Fields = [.. fields.Take(FieldCount).Select(f => new AccountField { Name = f.Name, Value = f.Value }),
|
||||||
|
.. Enumerable.Range(0, Math.Max(0, FieldCount - fields.Count)).Select(_ => new AccountField())]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form as update_credentials reads it, with the pictures as files.</summary>
|
||||||
|
public MultipartFormDataContent Content()
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ new StringContent(DisplayName?.Trim() ?? ""), "display_name" },
|
||||||
|
{ new StringContent(Note ?? ""), "note" },
|
||||||
|
{ new StringContent(Flag(Locked)), "locked" },
|
||||||
|
{ new StringContent(Flag(Bot)), "bot" },
|
||||||
|
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
||||||
|
{ new StringContent(Flag(Indexable)), "indexable" },
|
||||||
|
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||||
|
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
||||||
|
{ new StringContent(Language ?? ""), "source[language]" },
|
||||||
|
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
||||||
|
};
|
||||||
|
// every row is sent, so a row emptied here is removed there
|
||||||
|
for (var i = 0; i < Fields.Count; i++)
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(Fields[i].Name?.Trim() ?? ""), $"fields_attributes[{i}][name]");
|
||||||
|
content.Add(new StringContent(Fields[i].Value?.Trim() ?? ""), $"fields_attributes[{i}][value]");
|
||||||
|
}
|
||||||
|
if (Avatar is not null)
|
||||||
|
content.Add(Picture(Avatar), "avatar", Avatar.FileName);
|
||||||
|
if (Header is not null)
|
||||||
|
content.Add(Picture(Header), "header", Header.FileName);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
// PrivaPub keeps pictures up to this size and resizes them
|
||||||
|
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
// streamed from the browser's file, read again for each attempt
|
||||||
|
static StreamContent Picture(UploadMedia media)
|
||||||
|
{
|
||||||
|
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||||
|
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
|
||||||
|
/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name.
|
||||||
|
/// </summary>
|
||||||
|
public class PublicActionNotices
|
||||||
|
{
|
||||||
|
/// <summary>Told about reactions and poll votes.</summary>
|
||||||
|
public List<string> PersonaIds { get; set; } = [];
|
||||||
|
/// <summary>Told about joining events.</summary>
|
||||||
|
public List<string> EventPersonaIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PublicAct
|
||||||
|
{
|
||||||
|
Reaction = 0,
|
||||||
|
Participation = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
using decePubClient.Resources;
|
||||||
|
|
||||||
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
/// <summary>A report of an account, and of the post it was made from, as the report dialog edits it.</summary>
|
||||||
|
public class ReportForm
|
||||||
|
{
|
||||||
|
public const int CommentLength = 1000;
|
||||||
|
|
||||||
|
public ReportCategory Category { get; set; } = ReportCategory.Other;
|
||||||
|
[StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>A copy to a remote account's own server, from PrivaPub's actor.</summary>
|
||||||
|
public bool Forward { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Who may quote a persona's posts (Mastodon's quote_policy).</summary>
|
||||||
|
public enum QuotePolicy
|
||||||
|
{
|
||||||
|
Public = 1,
|
||||||
|
Followers,
|
||||||
|
Nobody
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
|
||||||
|
public enum ReportCategory
|
||||||
|
{
|
||||||
|
Other = 0,
|
||||||
|
Spam = 1,
|
||||||
|
Legal = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ namespace decePubClient.Models.Types
|
|||||||
Notifications,
|
Notifications,
|
||||||
Favourites,
|
Favourites,
|
||||||
Bookmarks,
|
Bookmarks,
|
||||||
Tag
|
Tag,
|
||||||
|
Lists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-4
@@ -1,17 +1,48 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
public enum UploadState
|
||||||
|
{
|
||||||
|
Uploading,
|
||||||
|
Processing,
|
||||||
|
Ready,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||||
|
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||||
|
/// </summary>
|
||||||
public class UploadMedia
|
public class UploadMedia
|
||||||
{
|
{
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
public string FileName { get; set; }
|
public string FileName { get; set; }
|
||||||
public string AltText { get; set; }
|
public string AltText { get; set; }
|
||||||
public byte[] Blob { get; set; } = [];
|
public long Size { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public string Base64Preview { get; set; }
|
public IBrowserFile File { get; set; }
|
||||||
|
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public long Size { get; set; }
|
public string Preview { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public UploadState State { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Error { get; set; }
|
||||||
|
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string SentAltText { get; set; }
|
||||||
|
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public bool Posted { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public CancellationTokenSource Cancel { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public Task Done { get; set; } = Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
@page "/administration"
|
@page "/administration"
|
||||||
|
@attribute [Authorize(Policy = Policies.IsModerator)]
|
||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
<Title>@Localizer["Settings"]</Title>
|
<Title>@Localizer["Administration"]</Title>
|
||||||
|
|
||||||
|
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
|
||||||
|
up and restore the server. The
|
||||||
|
other sections wait for PrivaPub endpoints that do not exist yet. *@
|
||||||
|
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
|
||||||
@@ -25,18 +30,57 @@
|
|||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Reports"]</span>
|
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<ReportsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<AuthorizeView Policy="@Policies.IsAdmin">
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<ServerBlocksPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<StatisticsPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<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">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<Title>@Localizer["Thread"]</Title>
|
<Title>@Localizer["Thread"]</Title>
|
||||||
|
|
||||||
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
||||||
The cards do their own actions; a reply or a deletion updates the thread here. *@
|
The cards do their own actions; a reply or a deletion updates the thread here. The persona's thread filters apply
|
||||||
|
to the posts around the one it opened, never to that one. Opening a remote thread makes PrivaPub read its replies
|
||||||
|
from their servers, so the thread is read twice more, a few seconds later, and what was found joins it. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
@@ -32,14 +34,14 @@
|
|||||||
{
|
{
|
||||||
@foreach (var post in ancestors)
|
@foreach (var post in ancestors)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" 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" />
|
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
|
|
||||||
@foreach (var post in descendants)
|
@foreach (var post in descendants)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" 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>
|
</div>
|
||||||
@@ -53,10 +55,14 @@
|
|||||||
|
|
||||||
[Parameter] public string StatusId { get; set; }
|
[Parameter] public string StatusId { get; set; }
|
||||||
|
|
||||||
|
// when the thread is read again, after PrivaPub has had time to fetch the replies held on other servers
|
||||||
|
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
|
||||||
|
|
||||||
FeedPost current;
|
FeedPost current;
|
||||||
List<FeedPost> ancestors = [];
|
List<FeedPost> ancestors = [];
|
||||||
List<FeedPost> descendants = [];
|
List<FeedPost> descendants = [];
|
||||||
string loadedId;
|
string loadedId;
|
||||||
|
CancellationTokenSource rereading;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -77,6 +83,9 @@
|
|||||||
|
|
||||||
async Task LoadData()
|
async Task LoadData()
|
||||||
{
|
{
|
||||||
|
rereading?.Cancel();
|
||||||
|
rereading?.Dispose();
|
||||||
|
rereading = null;
|
||||||
IsLoading = true;
|
IsLoading = true;
|
||||||
var status = await StatusService.Get(StatusId);
|
var status = await StatusService.Get(StatusId);
|
||||||
if (status is null)
|
if (status is null)
|
||||||
@@ -95,6 +104,42 @@
|
|||||||
var all = ancestors.Append(current).Concat(descendants).ToList();
|
var all = ancestors.Append(current).Concat(descendants).ToList();
|
||||||
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
|
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
InstanceService.Locate(all);
|
InstanceService.Locate(all);
|
||||||
|
|
||||||
|
if (new[] { current, ancestors.FirstOrDefault() }.Any(p => p is not null && p.Host != InstanceService.SelfHost))
|
||||||
|
{
|
||||||
|
rereading = new();
|
||||||
|
_ = Reread(status.Id, rereading.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the answers PrivaPub found since, in the thread's order; the cards already shown stay as they are, and one the
|
||||||
|
// thread no longer lists (a reply just made, not yet in it) stays at the end
|
||||||
|
async Task Reread(string statusId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var pause in Rereads)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(pause, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var context = await StatusService.Context(statusId, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested || context is null)
|
||||||
|
return;
|
||||||
|
var shown = descendants.ToDictionary(p => p.Key);
|
||||||
|
var found = context.Descendants.Where(s => !shown.ContainsKey(s.Id)).Select(FeedPost.Of).ToList();
|
||||||
|
if (found.Count == 0)
|
||||||
|
continue;
|
||||||
|
var listed = context.Descendants.Select(s => s.Id).ToHashSet();
|
||||||
|
descendants = [.. context.Descendants.Select(s => shown.GetValueOrDefault(s.Id) ?? found.First(p => p.Key == s.Id)),
|
||||||
|
.. descendants.Where(p => !listed.Contains(p.Key))];
|
||||||
|
await InstanceService.Resolve(found.Select(p => p.Host));
|
||||||
|
InstanceService.Locate(found);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Remove(FeedPost post)
|
void Remove(FeedPost post)
|
||||||
@@ -127,4 +172,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Task Back() => await JS.InvokeVoidAsync("history.back");
|
async Task Back() => await JS.InvokeVoidAsync("history.back");
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (rereading is not null)
|
||||||
|
{
|
||||||
|
await rereading.CancelAsync();
|
||||||
|
rereading.Dispose();
|
||||||
|
}
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
@page "/explore"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Explore"]</Title>
|
||||||
|
|
||||||
|
@* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already
|
||||||
|
holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the
|
||||||
|
hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-compass text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
|
||||||
|
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||||
|
aria-label="@Localizer["Explore"]">
|
||||||
|
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
|
||||||
|
</DTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Hashtags or ExploreTab.Followed)
|
||||||
|
{
|
||||||
|
@if (tags.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty>
|
||||||
|
<i class="ion-ios-remove-circle-outline"></i> @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."])
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var item in tags)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
|
||||||
|
<button type="button" class="flex flex-col items-start justify-center min-w-0 flex-1 text-left pointer-coarse:min-h-11" @onclick="() => OpenTag(item.Name)">
|
||||||
|
<b class="truncate">#@item.Name</b>
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
|
||||||
|
</button>
|
||||||
|
<span class="flex items-end gap-px h-6" aria-hidden="true">
|
||||||
|
@foreach (var day in Enumerable.Reverse(item.History))
|
||||||
|
{
|
||||||
|
<span class="w-1.5 rounded-sm bg-primary/60" style="@($"height:{Bar(day.UsesCount, item)}%")"></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(item.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isActing)"
|
||||||
|
OnClick="@(() => ToggleFollow(item))">
|
||||||
|
<span>@(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"])</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Posts)
|
||||||
|
{
|
||||||
|
@if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@foreach (var post in posts)
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Public" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Links)
|
||||||
|
{
|
||||||
|
@if (links.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var link in links)
|
||||||
|
{
|
||||||
|
<a class="flex gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@link.Url" target="_blank" rel="noopener noreferrer" data-link="@link.Url">
|
||||||
|
@if (link.Image is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
@* alt="": the title is next to it. *@
|
||||||
|
<img alt="" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-lg" src="@image" />
|
||||||
|
}
|
||||||
|
<span class="flex flex-col min-w-0 gap-1">
|
||||||
|
<b class="text-sm break-words">@link.Title</b>
|
||||||
|
@if (link.Description is { Length: > 0 } description)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-80 line-clamp-2">@description</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-60">@link.ProviderName · @Localizer["{0} people this week", link.WeekAccounts]</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<DSelect TValue="DirectoryOrder" Size="DSize.Sm" class="w-auto" Value="order" ValueChanged="ChangeOrder" ValueExpression="() => order">
|
||||||
|
<option value="@DirectoryOrder.Active">@Localizer["Recently active"]</option>
|
||||||
|
<option value="@DirectoryOrder.New">@Localizer["Newcomers"]</option>
|
||||||
|
</DSelect>
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@localOnly" @onchange="ToggleLocal" />
|
||||||
|
<span>@Localizer["Only this server"]</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (people.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var account in people)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (morePeople)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isActing)" OnClick="LoadMorePeople">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
ElementReference strip;
|
||||||
|
int revealed = -1;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if ((int)tab == revealed)
|
||||||
|
return;
|
||||||
|
revealed = (int)tab;
|
||||||
|
CascadingState.Status.RevealActive(strip);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExploreTab
|
||||||
|
{
|
||||||
|
Hashtags,
|
||||||
|
Posts,
|
||||||
|
Links,
|
||||||
|
People,
|
||||||
|
Followed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||||
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
|
|
||||||
|
ExploreTab tab = ExploreTab.Hashtags;
|
||||||
|
List<Tag> tags = [];
|
||||||
|
List<FeedPost> posts = [];
|
||||||
|
List<TrendLink> links = [];
|
||||||
|
List<Account> people = [];
|
||||||
|
DirectoryOrder order = DirectoryOrder.Active;
|
||||||
|
bool localOnly;
|
||||||
|
bool morePeople;
|
||||||
|
bool isActing;
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
static int Bar(int uses, Tag tag) => tag.History.Max(h => h.UsesCount) is > 0 and var most ? Math.Max(8, uses * 100 / most) : 8;
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): what it follows is another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
switch (tab)
|
||||||
|
{
|
||||||
|
case ExploreTab.Hashtags:
|
||||||
|
tags = await DiscoveryService.TrendingTags();
|
||||||
|
break;
|
||||||
|
case ExploreTab.Followed:
|
||||||
|
tags = await DiscoveryService.FollowedTags();
|
||||||
|
break;
|
||||||
|
case ExploreTab.Posts:
|
||||||
|
posts = [.. (await DiscoveryService.TrendingStatuses()).Select(FeedPost.Of)];
|
||||||
|
await InstanceService.Resolve(posts.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
|
InstanceService.Locate(posts);
|
||||||
|
break;
|
||||||
|
case ExploreTab.Links:
|
||||||
|
links = await DiscoveryService.TrendingLinks();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
people = await DiscoveryService.Directory(order, localOnly, 0);
|
||||||
|
morePeople = people.Count == DiscoveryService.DirectoryPage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SelectTab(int index)
|
||||||
|
{
|
||||||
|
tab = (ExploreTab)index;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeOrder(DirectoryOrder value)
|
||||||
|
{
|
||||||
|
order = value;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleLocal(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
localOnly = e.Value is true;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMorePeople()
|
||||||
|
{
|
||||||
|
isActing = true;
|
||||||
|
var known = people.Select(a => a.Id).ToHashSet();
|
||||||
|
var page = await DiscoveryService.Directory(order, localOnly, people.Count);
|
||||||
|
isActing = false;
|
||||||
|
people.AddRange(page.Where(a => known.Add(a.Id)));
|
||||||
|
morePeople = page.Count == DiscoveryService.DirectoryPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleFollow(Tag item)
|
||||||
|
{
|
||||||
|
isActing = true;
|
||||||
|
var result = await DiscoveryService.FollowTag(item.Name, item.Following != true);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Following = ((Tag)result.Data).Following;
|
||||||
|
if (tab is ExploreTab.Followed && item.Following != true)
|
||||||
|
tags.Remove(item);
|
||||||
|
Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OpenTag(string tagName)
|
||||||
|
{
|
||||||
|
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.Tag = tagName;
|
||||||
|
filter.TimelineType = TimelineType.Tag;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@page "/notifications/filtered"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Filtered notifications"]</Title>
|
||||||
|
|
||||||
|
@* The persona's notification policy and the requests it filtered, as in Settings; the Notifications tab links here when
|
||||||
|
requests are waiting. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-notifications-off text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Filtered notifications"]</h1>
|
||||||
|
</div>
|
||||||
|
<NotificationPolicyEditor />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
@page "/filters"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Filters"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
||||||
|
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
|
||||||
|
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
|
||||||
|
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-funnel text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Filters"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
placeholder="@Localizer["A new filter's title"]" data-filter-new>
|
||||||
|
<Prefix><i class="ion-md-add"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
|
||||||
|
<span>@Localizer["Make a filter"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no filter yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var filter in filters)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-filter-id="@filter.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@filter.Title</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@ActionName(filter.FilterAction)</DBadge>
|
||||||
|
@if (!filter.IsActive)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Expired"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (filter.ExpiresAt is { } expires)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" title="@NerdRow.Time(expires)">@Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count]</span>
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == filter.Id)" OnClick="@(() => Toggle(filter))" title="@Localizer["Edit"]">
|
||||||
|
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
|
||||||
|
<DInput TValue="string" class="w-full" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
aria-label="@Localizer["Title"]" data-filter-title />
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Where"]</legend>
|
||||||
|
@foreach (var context in Contexts)
|
||||||
|
{
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@edit.Contexts.Contains(context)"
|
||||||
|
@onchange="@((ChangeEventArgs e) => ToggleContext(context, e.Value is true))" />
|
||||||
|
<span>@ContextName(context)</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DSelect TValue="FilterAction" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.Action" aria-label="@Localizer["How"]">
|
||||||
|
<Prefix><i class="ion-md-funnel"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var action in Actions)
|
||||||
|
{
|
||||||
|
<option value="@action">@ActionText(action)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.ExpiresIn" aria-label="@Localizer["Expires"]">
|
||||||
|
<Prefix><i class="ion-md-time"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var (seconds, label) in Expiries)
|
||||||
|
{
|
||||||
|
<option value="@seconds">@label</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Words"]</h2>
|
||||||
|
<div class="flex flex-col gap-2" data-filter-keywords>
|
||||||
|
@foreach (var row in edit.Keywords.Where(k => !k.Removed))
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2" data-keyword="@row.Keyword">
|
||||||
|
<span class="flex-1 min-w-0 break-words text-sm">@row.Keyword</span>
|
||||||
|
<label class="label text-xs">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" checked="@row.WholeWord" @onchange="@((ChangeEventArgs e) => row.WholeWord = e.Value is true)" />
|
||||||
|
<span>@Localizer["Whole word"]</span>
|
||||||
|
</label>
|
||||||
|
<DButton Size="DSize.Xs" OnClick="@(() => RemoveKeyword(row))" title="@Localizer["Remove"]">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.NewKeyword" Immediate="true" autocomplete="off" maxlength="100"
|
||||||
|
placeholder="@Localizer["A word or a phrase"]" data-filter-keyword-new />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(string.IsNullOrWhiteSpace(edit.NewKeyword))" OnClick="AddKeyword">
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filter.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
@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>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary"
|
||||||
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title) || edit.Contexts.Count == 0)">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (isConfirmingDelete)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const int KeepExpiry = -1;
|
||||||
|
static readonly FilterContext[] Contexts = [FilterContext.Home, FilterContext.Notifications, FilterContext.Public, FilterContext.Thread, FilterContext.Account];
|
||||||
|
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
||||||
|
|
||||||
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
|
||||||
|
List<Filter> filters = [];
|
||||||
|
TitleForm draft = new();
|
||||||
|
FilterEdit edit = new();
|
||||||
|
Filter open;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
bool isConfirmingDelete;
|
||||||
|
|
||||||
|
(int Seconds, string Label)[] Expiries =>
|
||||||
|
[
|
||||||
|
(KeepExpiry, Localizer["Keep its expiry"]),
|
||||||
|
(0, Localizer["Never expires"]),
|
||||||
|
(1800, Localizer["Expires in 30 minutes"]),
|
||||||
|
(3600, Localizer["Expires in 1 hour"]),
|
||||||
|
(43200, Localizer["Expires in 12 hours"]),
|
||||||
|
(86400, Localizer["Expires in 1 day"]),
|
||||||
|
(604800, Localizer["Expires in 1 week"])
|
||||||
|
];
|
||||||
|
|
||||||
|
string ContextName(FilterContext context) => context switch
|
||||||
|
{
|
||||||
|
FilterContext.Home => Localizer["Home and lists"],
|
||||||
|
FilterContext.Notifications => Localizer["Notifications"],
|
||||||
|
FilterContext.Public => Localizer["Public feeds"],
|
||||||
|
FilterContext.Thread => Localizer["Threads"],
|
||||||
|
_ => Localizer["Profiles"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionName(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Hide"],
|
||||||
|
FilterAction.Blur => Localizer["Blur"],
|
||||||
|
_ => Localizer["Warn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionText(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Leave the post out"],
|
||||||
|
FilterAction.Blur => Localizer["Blur its media"],
|
||||||
|
_ => Localizer["Fold the post behind the filter's title"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its filters are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
filters = await FilterService.All();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a new filter applies everywhere and warns until it is told otherwise
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(null, new FilterForm { Title = draft.Title.Trim(), Context = [.. Contexts], FilterAction = FilterAction.Warn });
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = (Filter)result.Data;
|
||||||
|
filters.Add(filter);
|
||||||
|
draft = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
||||||
|
Toggle(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Toggle(Filter filter)
|
||||||
|
{
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = filter;
|
||||||
|
edit = new FilterEdit
|
||||||
|
{
|
||||||
|
Title = filter.Title,
|
||||||
|
Contexts = [.. filter.Context],
|
||||||
|
Action = filter.FilterAction,
|
||||||
|
ExpiresIn = KeepExpiry,
|
||||||
|
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ToggleContext(FilterContext context, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
edit.Contexts.Add(context);
|
||||||
|
else
|
||||||
|
edit.Contexts.Remove(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddKeyword()
|
||||||
|
{
|
||||||
|
var keyword = edit.NewKeyword?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(keyword) || edit.Keywords.Any(k => !k.Removed && string.Equals(k.Keyword, keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
edit.Keywords.Add(new KeywordRow { Keyword = keyword, WholeWord = true });
|
||||||
|
edit.NewKeyword = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveKeyword(KeywordRow row)
|
||||||
|
{
|
||||||
|
if (row.Id is null)
|
||||||
|
edit.Keywords.Remove(row);
|
||||||
|
else
|
||||||
|
row.Removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
AddKeyword();
|
||||||
|
var form = new FilterForm
|
||||||
|
{
|
||||||
|
Title = edit.Title.Trim(),
|
||||||
|
Context = [.. Contexts.Where(edit.Contexts.Contains)],
|
||||||
|
FilterAction = edit.Action,
|
||||||
|
ExpiresIn = edit.ExpiresIn == KeepExpiry ? null : edit.ExpiresIn,
|
||||||
|
KeywordsAttributes =
|
||||||
|
[
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && k.Removed).Select(k => new FilterKeywordForm { Id = k.Id, Destroy = true }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && !k.Removed && k.WholeWord != k.WasWholeWord)
|
||||||
|
.Select(k => new FilterKeywordForm { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is null).Select(k => new FilterKeywordForm { Keyword = k.Keyword, WholeWord = k.WholeWord })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(open.Id, form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = (Filter)result.Data;
|
||||||
|
var index = filters.FindIndex(f => f.Id == saved.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
filters[index] = saved;
|
||||||
|
open = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} saved.", saved.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Delete(open.Id);
|
||||||
|
isSaving = false;
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.RemoveAll(f => f.Id == open.Id);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} deleted.", open.Title]);
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveStatus(Filter filter, FilterStatus item)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.RemoveStatus(item.Id);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.Statuses.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TitleForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class FilterEdit
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public HashSet<FilterContext> Contexts { get; set; } = [];
|
||||||
|
public FilterAction Action { get; set; } = FilterAction.Warn;
|
||||||
|
public int ExpiresIn { get; set; } = KeepExpiry;
|
||||||
|
public List<KeywordRow> Keywords { get; set; } = [];
|
||||||
|
public string NewKeyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class KeywordRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
public bool WasWholeWord { get; set; }
|
||||||
|
public bool Removed { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
@page "/groups"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Groups"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones
|
||||||
|
it runs, edited, their invitation shown, the requests to join answered and members taken out. A circle's members are
|
||||||
|
never shown to anyone but its owner and moderators, here as on every server. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-people text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Groups"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm">@Localizer["A new group"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.UserName" Immediate="true" autocomplete="off" maxlength="32"
|
||||||
|
placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-group-username>
|
||||||
|
<Prefix>@@</Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.Name" Immediate="true" autocomplete="off" maxlength="64"
|
||||||
|
placeholder="@Localizer["Name"]" data-group-name />
|
||||||
|
</div>
|
||||||
|
<DTextarea @bind-Value="draft.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" placeholder="@Localizer["Description"]" />
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsCommunity" LabelText="@Localizer["A community, open to the fediverse (otherwise a private circle)"]" data-group-community />
|
||||||
|
@if (draft.IsCommunity)
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="draft.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
||||||
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in PostingPolicies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="draft.ManuallyApprovesMembers" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="draft.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
||||||
|
placeholder="@Localizer["Invitation password (optional)"]" />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-create>
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Make the group"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<EditForm Model="joining" OnValidSubmit="Join" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-join>
|
||||||
|
<b class="text-sm">@Localizer["Join by invitation"]</b>
|
||||||
|
<DInput TValue="string" @bind-Value="joining.InvitationCode" Immediate="true" autocomplete="off" maxlength="64"
|
||||||
|
placeholder="@Localizer["Invitation code"]" data-group-code>
|
||||||
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="joining.InvitationPassword" Immediate="true" autocomplete="current-password" maxlength="128"
|
||||||
|
placeholder="@Localizer["Invitation password, if it has one"]" />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(joining.InvitationCode))" data-group-join-submit>
|
||||||
|
<i class="ion-md-log-in"></i><span>@Localizer["Join"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona is in no group yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var group in groups)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-group-id="@group.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") text-lg"></i>
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate">@(group.Name ?? group.UserName)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + group.Handle)</span>
|
||||||
|
</span>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(group.IsCommunity ? Localizer["Community"] : Localizer["Circle"])</DBadge>
|
||||||
|
<span class="text-sm tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person"></i> @group.MembersCount</span>
|
||||||
|
@if (group.IsOwner)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == group.Id)" OnClick="@(() => Toggle(group))" data-group-manage>
|
||||||
|
<i class="ion-md-settings"></i><span class="hidden sm:inline">@Localizer["Manage"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Leave(group))" data-group-leave>
|
||||||
|
<i class="ion-md-exit"></i><span>@Localizer["Leave"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == group.Id)
|
||||||
|
{
|
||||||
|
@if (group.InvitationCode is { Length: > 0 } invitation)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
|
||||||
|
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
|
||||||
|
aria-label="@Localizer["Invitation code"]" data-group-invitation />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-2">
|
||||||
|
<DInput TValue="string" @bind-Value="edit.Name" Immediate="true" autocomplete="off" maxlength="64" aria-label="@Localizer["Name"]" />
|
||||||
|
<DTextarea @bind-Value="edit.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" aria-label="@Localizer["Description"]" />
|
||||||
|
@if (group.IsCommunity)
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="edit.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
||||||
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in PostingPolicies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="editDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="editApproves" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="edit.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
||||||
|
placeholder="@Localizer["A new invitation password"]" />
|
||||||
|
@if (group.IsPasswordRequired)
|
||||||
|
{
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RemoveInvitationPassword" LabelText="@Localizer["Remove the invitation password"]" />
|
||||||
|
}
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RegenerateInvitationCode" LabelText="@Localizer["A new invitation code (the old one stops working)"]" />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-save>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count(m => !m.IsPending)</span></h2>
|
||||||
|
<div class="flex flex-col gap-2" data-group-members>
|
||||||
|
@foreach (var member in members.OrderBy(m => m.IsPending ? 0 : 1).ThenBy(m => m.Since))
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3 min-w-0" data-member="@member.ActorUri" data-pending="@(member.IsPending ? "true" : "false")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(member.PictureUrl ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate text-sm">@(member.Name ?? member.Handle)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + member.Handle)</span>
|
||||||
|
</span>
|
||||||
|
@if (member.IsPending)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["asks to join"]</DBadge>
|
||||||
|
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, true))" data-member-approve>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Let in"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, false))" data-member-reject>
|
||||||
|
<i class="ion-md-close"></i><span>@Localizer["Decline"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (member.Role == "owner")
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["owner"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Remove(member))" title="@Localizer["Take out of the group"]" data-member-remove>
|
||||||
|
<i class="ion-md-remove-circle"></i><span class="hidden sm:inline">@Localizer["Take out"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
|
||||||
|
|
||||||
|
[Inject] GroupService GroupService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
List<ViewGroup> groups = [];
|
||||||
|
List<ViewGroupMember> members = [];
|
||||||
|
InsertGroupForm draft = new() { PostingPolicy = "followers" };
|
||||||
|
JoinGroupForm joining = new();
|
||||||
|
UpdateGroupForm edit = new();
|
||||||
|
bool editDiscoverable;
|
||||||
|
bool editApproves;
|
||||||
|
ViewGroup open;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
string PolicyName(string policy) => policy switch
|
||||||
|
{
|
||||||
|
"anyone" => Localizer["Anyone may post"],
|
||||||
|
"moderators" => Localizer["Only its moderators post"],
|
||||||
|
_ => Localizer["Its followers post"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its groups are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
// (the forms are the persona's: validated as they are filled, so they name it from the start)
|
||||||
|
draft.AvatarId = CascadingState.PersonaId;
|
||||||
|
joining.AvatarId = CascadingState.PersonaId;
|
||||||
|
groups = await GroupService.List(CascadingState.PersonaId);
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
if (!draft.IsCommunity)
|
||||||
|
draft.IsDiscoverable = false;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Insert(draft);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = (ViewGroup)result.Data;
|
||||||
|
draft = new() { AvatarId = CascadingState.PersonaId, PostingPolicy = "followers" };
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} made.", group.Name ?? group.UserName]);
|
||||||
|
await LoadData();
|
||||||
|
if (groups.FirstOrDefault(g => g.Id == group.Id) is { } made)
|
||||||
|
await Toggle(made);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Join()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Join(joining);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = (ViewGroup)result.Data;
|
||||||
|
joining = new() { AvatarId = CascadingState.PersonaId };
|
||||||
|
Toast.AddResponseSuccess(Localizer["You joined {0}.", group.Name ?? group.UserName]);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Leave(ViewGroup group)
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
|
||||||
|
return;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Remove(group);
|
||||||
|
Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Toggle(ViewGroup group)
|
||||||
|
{
|
||||||
|
if (open?.Id == group.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = group;
|
||||||
|
edit = new UpdateGroupForm { AvatarId = CascadingState.PersonaId, GroupId = group.Id, Name = group.Name, Description = group.Description, PostingPolicy = group.PostingPolicy };
|
||||||
|
editDiscoverable = group.IsDiscoverable;
|
||||||
|
editApproves = group.ManuallyApprovesMembers;
|
||||||
|
members = await GroupService.Members(CascadingState.PersonaId, group.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
edit.IsDiscoverable = open.IsCommunity ? editDiscoverable : null;
|
||||||
|
edit.ManuallyApprovesMembers = editApproves;
|
||||||
|
if (string.IsNullOrEmpty(edit.InvitationPassword))
|
||||||
|
edit.InvitationPassword = null;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Update(edit);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (ViewGroup)result.Data;
|
||||||
|
var index = groups.FindIndex(g => g.Id == updated.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
groups[index] = updated;
|
||||||
|
open = updated;
|
||||||
|
edit.InvitationPassword = null;
|
||||||
|
edit.RemoveInvitationPassword = false;
|
||||||
|
edit.RegenerateInvitationCode = false;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(ViewGroupMember member, bool approve)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = approve
|
||||||
|
? await GroupService.Approve(CascadingState.PersonaId, open.Id, member.ActorUri)
|
||||||
|
: await GroupService.Reject(CascadingState.PersonaId, open.Id, member.ActorUri);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
members = await GroupService.Members(CascadingState.PersonaId, open.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Remove(ViewGroupMember member)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await GroupService.Remove(CascadingState.PersonaId, open.Id, member.ActorUri);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
members.Remove(member);
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is out of the group.", member.Name ?? member.Handle]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
-23
@@ -7,27 +7,46 @@
|
|||||||
|
|
||||||
@* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
|
@* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
|
||||||
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
|
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
|
||||||
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js); newer ones are polled every
|
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
|
||||||
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
|
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. *@
|
||||||
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
||||||
|
|
||||||
<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">
|
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
||||||
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
|
<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>
|
</aside>
|
||||||
|
|
||||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
|
||||||
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
@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>
|
||||||
|
}
|
||||||
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
|
||||||
OnChanged="ChangeFeed" />
|
OnChanged="ChangeFeed" />
|
||||||
|
|
||||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
<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 class="flex flex-col gap-4">
|
<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)
|
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
|
||||||
{
|
|
||||||
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
|
||||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||||
</div>
|
</PhoneSheet>
|
||||||
|
|
||||||
|
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||||
|
{
|
||||||
|
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
||||||
|
<i class="ion-md-notifications-off"></i>
|
||||||
|
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (pending.Count > 0)
|
@if (pending.Count > 0)
|
||||||
@@ -67,15 +86,16 @@
|
|||||||
}
|
}
|
||||||
else if (posts.Count == 0)
|
else if (posts.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="w-full text-center text-lg">
|
<p class="w-full text-center text-lg" data-feed-empty>
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@foreach (var post in posts)
|
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
||||||
{
|
(tests/e2e/Perf/FeedPerfTests) *@
|
||||||
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||||
}
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
|
</Virtualize>
|
||||||
|
|
||||||
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
||||||
@if (isLoadingMore)
|
@if (isLoadingMore)
|
||||||
@@ -103,24 +123,44 @@
|
|||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
[Inject] TimelineService TimelineService { get; set; }
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
[Inject] GroupService GroupService { get; set; }
|
[Inject] GroupService GroupService { get; set; }
|
||||||
|
[Inject] ListService ListService { get; set; }
|
||||||
|
[Inject] StreamingService Streaming { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] RequestStats RequestStats { get; set; }
|
[Inject] RequestStats RequestStats { get; set; }
|
||||||
|
[Inject] FeedMemory Memory { get; set; }
|
||||||
|
|
||||||
FeedFilter Filter { get; set; } = new();
|
FeedFilter Filter { get; set; } = new();
|
||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
List<FeedPost> pending = [];
|
List<FeedPost> pending = [];
|
||||||
List<ViewGroup> groups = [];
|
List<ViewGroup> groups = [];
|
||||||
|
List<MastodonList> lists = [];
|
||||||
|
int unreadNotifications;
|
||||||
|
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
||||||
|
int filteredRequests;
|
||||||
|
int unreadConversations;
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
string loadError;
|
string loadError;
|
||||||
bool isLoadingMore;
|
bool isLoadingMore;
|
||||||
bool isComposing;
|
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;
|
(double Latitude, double Longitude)? position;
|
||||||
string positionError;
|
string positionError;
|
||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
|
bool waitingForPersona;
|
||||||
FeedQuery lastQuery;
|
FeedQuery lastQuery;
|
||||||
DateTime? lastPollAt;
|
DateTime? lastPollAt;
|
||||||
TimeSpan? lastLoadTook;
|
TimeSpan? lastLoadTook;
|
||||||
|
// the scroll a recalled feed goes back to, once rendered
|
||||||
|
double? recalledScroll;
|
||||||
|
|
||||||
GlobePanel globe;
|
GlobePanel globe;
|
||||||
IJSObjectReference feedModule;
|
IJSObjectReference feedModule;
|
||||||
@@ -131,6 +171,7 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
Streaming.Received += OnStream;
|
||||||
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
||||||
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
||||||
Filter.TimelineType = TimelineType.Home;
|
Filter.TimelineType = TimelineType.Home;
|
||||||
@@ -143,20 +184,57 @@
|
|||||||
return;
|
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)
|
if (CascadingState.PersonaId is null)
|
||||||
{
|
{
|
||||||
IsLoading = false;
|
waitingForPersona = !CascadingState.IsSessionLoaded;
|
||||||
|
IsLoading = waitingForPersona;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await Recall())
|
||||||
|
return;
|
||||||
await Reload();
|
await Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
/// <summary>Shows the feed again as the reader left it, when it is the same feed of the same persona and recent.</summary>
|
||||||
|
async Task<bool> Recall()
|
||||||
|
{
|
||||||
|
if (!Memory.Holds(CascadingState.PersonaId))
|
||||||
|
return false;
|
||||||
|
groups = Memory.Groups;
|
||||||
|
lists = Memory.Lists;
|
||||||
|
if (Query != Memory.Query)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
loadCancellation = new();
|
||||||
|
posts = Memory.Posts;
|
||||||
|
pending = Memory.Pending;
|
||||||
|
minId = Memory.MinId;
|
||||||
|
maxId = Memory.MaxId;
|
||||||
|
recalledScroll = Memory.ScrollTop;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
lastQuery = Query;
|
||||||
|
IsLoading = false;
|
||||||
|
Memory.Forget();
|
||||||
|
await Streaming.Follow(StreamOf(Filter), loadCancellation.Token);
|
||||||
|
await ReadNotifications(loadCancellation.Token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
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;
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
|
||||||
|
return;
|
||||||
|
waitingForPersona = false;
|
||||||
await Reload();
|
await Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,13 +246,25 @@
|
|||||||
|
|
||||||
reference = DotNetObjectReference.Create(this);
|
reference = DotNetObjectReference.Create(this);
|
||||||
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
|
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
|
||||||
|
if (recalledScroll is { } top)
|
||||||
|
await feedModule.InvokeVoidAsync("scrollToOffset", ScrollId, top);
|
||||||
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
|
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
|
||||||
pollTimer = new(PollInterval);
|
pollTimer = new(PollInterval);
|
||||||
_ = PollNewer(pageCancellation.Token);
|
_ = PollNewer(pageCancellation.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
|
||||||
|
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
|
||||||
|
FilterContext? FilterIn => Filter.TimelineType switch
|
||||||
|
{
|
||||||
|
TimelineType.Home or TimelineType.Lists => FilterContext.Home,
|
||||||
|
TimelineType.Local or TimelineType.Federation or TimelineType.Tag or TimelineType.Nearby or TimelineType.Groups => FilterContext.Public,
|
||||||
|
TimelineType.Notifications => FilterContext.Notifications,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
||||||
Filter.Tag, position?.Latitude, position?.Longitude);
|
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
|
||||||
|
|
||||||
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
||||||
async Task Reload()
|
async Task Reload()
|
||||||
@@ -193,11 +283,16 @@
|
|||||||
|
|
||||||
if (CascadingState.PersonaId != loadedPersonaId)
|
if (CascadingState.PersonaId != loadedPersonaId)
|
||||||
{
|
{
|
||||||
|
// a stream is the persona's: another one opens its own
|
||||||
|
await Streaming.Stop();
|
||||||
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
||||||
|
lists = await ListService.All(cancellationToken);
|
||||||
loadedPersonaId = CascadingState.PersonaId;
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
}
|
}
|
||||||
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
||||||
Filter.GroupId = groups.FirstOrDefault()?.Id;
|
Filter.GroupId = groups.FirstOrDefault()?.Id;
|
||||||
|
if (Filter.TimelineType is TimelineType.Lists && lists.All(l => l.Id != Filter.ListId))
|
||||||
|
Filter.ListId = lists.FirstOrDefault()?.Id;
|
||||||
lastQuery = Query;
|
lastQuery = Query;
|
||||||
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
||||||
{
|
{
|
||||||
@@ -223,6 +318,8 @@
|
|||||||
maxId = page.MaxId;
|
maxId = page.MaxId;
|
||||||
minId = page.MinId;
|
minId = page.MinId;
|
||||||
lastPollAt = DateTime.UtcNow;
|
lastPollAt = DateTime.UtcNow;
|
||||||
|
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
||||||
|
await ReadNotifications(cancellationToken);
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await Locate(posts, cancellationToken);
|
await Locate(posts, cancellationToken);
|
||||||
if (feedModule is not null)
|
if (feedModule is not null)
|
||||||
@@ -275,7 +372,14 @@
|
|||||||
{
|
{
|
||||||
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|
// no stream tells of direct messages: their tab's badge is counted again on every tick
|
||||||
|
if (!IsLoading && CascadingState.IsOnline && Filter.TimelineType is not TimelineType.Direct
|
||||||
|
&& await TimelineService.UnreadConversations(cancellationToken) is var unread && unread != unreadConversations)
|
||||||
|
{
|
||||||
|
unreadConversations = unread;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
if (IsLoading || CascadingState.IsOffline || minId is null || Streaming.IsOpen || Filter.TimelineType is TimelineType.Nearby
|
||||||
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
||||||
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
||||||
continue;
|
continue;
|
||||||
@@ -287,9 +391,15 @@
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
||||||
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
|
var page = ((FeedPage)result.Data).Posts;
|
||||||
|
// a conversation with a newer message is the same item, moved up with its new message
|
||||||
|
var newer = Filter.TimelineType is TimelineType.Direct
|
||||||
|
? page.Where(p => posts.Concat(pending).All(shown => shown.Key != p.Key || shown.CursorId != p.CursorId)).ToList()
|
||||||
|
: page.Where(p => known.Add(p.Key)).ToList();
|
||||||
if (newer.Count == 0)
|
if (newer.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
posts.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
||||||
|
pending.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
||||||
|
|
||||||
pending.InsertRange(0, newer);
|
pending.InsertRange(0, newer);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -307,11 +417,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge
|
||||||
|
/// counts what is unread.</summary>
|
||||||
|
async Task ReadNotifications(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
|
||||||
|
if (Filter.TimelineType is not TimelineType.Notifications)
|
||||||
|
{
|
||||||
|
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
|
||||||
|
if (newest is not null)
|
||||||
|
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
||||||
|
unreadNotifications = 0;
|
||||||
|
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
TimelineType.Home => new("user"),
|
||||||
|
TimelineType.Local => new("public:local"),
|
||||||
|
TimelineType.Federation => new("public:remote"),
|
||||||
|
TimelineType.Notifications => new("user:notification"),
|
||||||
|
TimelineType.Tag when filter.Tag is { Length: > 0 } tag => new("hashtag", Tag: tag),
|
||||||
|
TimelineType.Lists when filter.ListId is { Length: > 0 } list => new("list", List: list),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>What the stream tells: a new post or notification waits behind the "new" button; a deletion or an edit is
|
||||||
|
/// applied where the post is.</summary>
|
||||||
|
async Task OnStream(StreamMessage message)
|
||||||
|
{
|
||||||
|
switch (message.Event)
|
||||||
|
{
|
||||||
|
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
|
||||||
|
var post = FeedPost.Of(status);
|
||||||
|
if (posts.Concat(pending).Any(p => p.Key == post.Key))
|
||||||
|
return;
|
||||||
|
pending.Insert(0, post);
|
||||||
|
break;
|
||||||
|
case "notification" when message.Notification is { } notification && Filter.TimelineType is TimelineType.Notifications:
|
||||||
|
// a notification of a group already shown joins it, and the group waits on top as new
|
||||||
|
var key = notification.GroupKey ?? notification.Id;
|
||||||
|
var group = pending.FirstOrDefault(p => p.Key == key) ?? posts.FirstOrDefault(p => p.Key == key);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
pending.Insert(0, FeedPost.Of(notification));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
group.GroupCount++;
|
||||||
|
group.Notification = notification;
|
||||||
|
group.GroupAccounts = [.. group.GroupAccounts.Prepend(notification.Account).Where(a => a is not null).DistinctBy(a => a.Id).Take(8)];
|
||||||
|
if (posts.Remove(group))
|
||||||
|
pending.Insert(0, group);
|
||||||
|
break;
|
||||||
|
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
|
||||||
|
unreadNotifications++;
|
||||||
|
break;
|
||||||
|
case "delete" when message.DeletedId is { } id:
|
||||||
|
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
||||||
|
pending.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
||||||
|
break;
|
||||||
|
case "status.update" when message.Status is { } edited:
|
||||||
|
foreach (var shown in posts.Concat(pending).Where(p => p.Status?.Id == edited.Id))
|
||||||
|
shown.Status = edited;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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()
|
async Task ShowPending()
|
||||||
{
|
{
|
||||||
var shown = pending;
|
var shown = pending;
|
||||||
pending = [];
|
pending = [];
|
||||||
posts.InsertRange(0, shown);
|
posts.InsertRange(0, shown);
|
||||||
|
await ReadNotifications(loadCancellation?.Token ?? default);
|
||||||
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
||||||
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
||||||
await Locate(shown, loadCancellation?.Token ?? default);
|
await Locate(shown, loadCancellation?.Token ?? default);
|
||||||
@@ -385,6 +577,7 @@
|
|||||||
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
|
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
|
||||||
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
|
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
|
||||||
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
|
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
|
||||||
|
yield return NerdRow.Of(Localizer["stream"], Streaming.IsOpen ? Localizer["open"] : Localizer["closed, polling"]);
|
||||||
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
|
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
|
||||||
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
|
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
|
||||||
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
|
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
|
||||||
@@ -397,6 +590,9 @@
|
|||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
Streaming.Received -= OnStream;
|
||||||
|
await Remember();
|
||||||
|
await Streaming.Stop();
|
||||||
await pageCancellation.CancelAsync();
|
await pageCancellation.CancelAsync();
|
||||||
pageCancellation.Dispose();
|
pageCancellation.Dispose();
|
||||||
loadCancellation?.Cancel();
|
loadCancellation?.Cancel();
|
||||||
@@ -416,6 +612,24 @@
|
|||||||
await base.DisposeAsync();
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Keeps the feed for the way back (FeedMemory), when it shows something worth keeping.</summary>
|
||||||
|
async Task Remember()
|
||||||
|
{
|
||||||
|
if (IsLoading || loadError is { Length: > 0 } || posts.Count == 0 || CascadingState.PersonaId is null
|
||||||
|
|| Filter.TimelineType is TimelineType.Nearby)
|
||||||
|
return;
|
||||||
|
var scrollTop = 0d;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (feedModule is not null)
|
||||||
|
scrollTop = await feedModule.InvokeAsync<double>("scrollOffset", ScrollId);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Memory.Save(Query, posts, pending, groups, lists, minId, maxId, scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
sealed class GeoAnswer
|
sealed class GeoAnswer
|
||||||
{
|
{
|
||||||
public double? Latitude { get; set; }
|
public double? Latitude { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
@page "/lists"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Lists"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's lists (/api/v1/lists): made, renamed, set to show which replies and whether their members stay
|
||||||
|
out of the home feed, and deleted; their members added from the accounts the persona follows (PrivaPub accepts no
|
||||||
|
others, and drops a member when the follow ends) and removed. A list is read as a tab of the feed page. Lists are
|
||||||
|
the persona's own and never leave PrivaPub. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-list text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Lists"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
placeholder="@Localizer["A new list's title"]" data-list-new>
|
||||||
|
<Prefix><i class="ion-md-add"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
|
||||||
|
<span>@Localizer["Make a list"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (lists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var list in lists)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-list-id="@list.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@list.Title</b>
|
||||||
|
@if (list.Exclusive)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" title="@Localizer["Its members stay out of the home feed."]">
|
||||||
|
<i class="ion-md-eye-off"></i> @Localizer["Exclusive"]
|
||||||
|
</DBadge>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" OnClick="@(() => Read(list))" title="@Localizer["Read the list"]">
|
||||||
|
<i class="ion-md-paper"></i><span class="hidden sm:inline">@Localizer["Read the list"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == list.Id)" OnClick="@(() => Toggle(list))" title="@Localizer["Edit"]">
|
||||||
|
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == list.Id)
|
||||||
|
{
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
aria-label="@Localizer["Title"]" data-list-title />
|
||||||
|
<DSelect TValue="ListRepliesPolicy" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.RepliesPolicy">
|
||||||
|
<Prefix><i class="ion-md-undo"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in Policies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
</div>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.Exclusive" LabelText="@Localizer["Keep its members out of the home feed"]" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title))">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (isConfirmingDelete)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count</span></h2>
|
||||||
|
<div class="flex flex-col gap-2" data-list-members>
|
||||||
|
@if (members.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is in this list yet."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in members)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-3 min-w-0" data-member-id="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<a class="flex flex-col min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b class="truncate text-sm">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, false))"
|
||||||
|
title="@Localizer["Remove from the list"]">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="find" OnValidSubmit="Find" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="find.Query" Immediate="true" autocomplete="off" type="search"
|
||||||
|
placeholder="@Localizer["Find accounts you follow"]" data-list-find>
|
||||||
|
<Prefix><i class="ion-md-search"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isFinding || string.IsNullOrWhiteSpace(find.Query))">
|
||||||
|
@if (isFinding)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Search"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
@if (found is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2" data-list-found>
|
||||||
|
@if (Candidates.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Only accounts you follow can join a list, and none of them matches."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in Candidates)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-3 min-w-0" data-candidate-id="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate text-sm">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, true))">
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
static readonly ListRepliesPolicy[] Policies = [ListRepliesPolicy.List, ListRepliesPolicy.Followed, ListRepliesPolicy.None];
|
||||||
|
|
||||||
|
[Inject] ListService ListService { get; set; }
|
||||||
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
|
|
||||||
|
List<MastodonList> lists = [];
|
||||||
|
MastodonList draft = new();
|
||||||
|
MastodonList edit = new();
|
||||||
|
MastodonList open;
|
||||||
|
List<Account> members = [];
|
||||||
|
List<Account> found;
|
||||||
|
SearchForm find = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
bool isFinding;
|
||||||
|
bool isConfirmingDelete;
|
||||||
|
|
||||||
|
List<Account> Candidates => found.Where(a => members.All(m => m.Id != a.Id)).ToList();
|
||||||
|
|
||||||
|
string PolicyName(ListRepliesPolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
ListRepliesPolicy.Followed => Localizer["Replies to anyone you follow"],
|
||||||
|
ListRepliesPolicy.None => Localizer["No replies"],
|
||||||
|
_ => Localizer["Replies to the list's members"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its lists are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
found = null;
|
||||||
|
lists = await ListService.All();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await ListService.Save(new MastodonList { Title = draft.Title });
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = (MastodonList)result.Data;
|
||||||
|
lists.Add(list);
|
||||||
|
lists = [.. lists.OrderBy(l => l.Title, StringComparer.CurrentCultureIgnoreCase)];
|
||||||
|
draft = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["List {0} made.", list.Title]);
|
||||||
|
await Toggle(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Toggle(MastodonList list)
|
||||||
|
{
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
found = null;
|
||||||
|
find = new();
|
||||||
|
if (open?.Id == list.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = list;
|
||||||
|
edit = new MastodonList { Id = list.Id, Title = list.Title, RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive };
|
||||||
|
members = await ListService.Members(list.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await ListService.Save(edit);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = (MastodonList)result.Data;
|
||||||
|
var index = lists.FindIndex(l => l.Id == saved.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
lists[index] = saved;
|
||||||
|
open = saved;
|
||||||
|
Toast.AddResponseSuccess(Localizer["List {0} saved.", saved.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await ListService.Delete(open.Id);
|
||||||
|
isSaving = false;
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lists.RemoveAll(l => l.Id == open.Id);
|
||||||
|
Toast.AddResponseSuccess(Localizer["List {0} deleted.", open.Title]);
|
||||||
|
open = null;
|
||||||
|
members = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Find()
|
||||||
|
{
|
||||||
|
isFinding = true;
|
||||||
|
found = await ListService.FindFollowed(find.Query);
|
||||||
|
isFinding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SetMember(Account account, bool member)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await ListService.SetMember(open.Id, account.Id, member);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member)
|
||||||
|
members.Add(account);
|
||||||
|
else
|
||||||
|
members.RemoveAll(m => m.Id == account.Id);
|
||||||
|
Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, open.Title] : Localizer["{0} removed from {1}.", account.Name, open.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Read(MastodonList list)
|
||||||
|
{
|
||||||
|
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.TimelineType = TimelineType.Lists;
|
||||||
|
filter.ListId = list.Id;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SearchForm
|
||||||
|
{
|
||||||
|
public string Query { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
@page "/account/{accountId}"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@(account?.Name ?? Localizer["Profile"])</Title>
|
||||||
|
|
||||||
|
@* 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. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
|
||||||
|
<i class="ion-md-arrow-back"></i>
|
||||||
|
</DButton>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Profile"]</h1>
|
||||||
|
</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 (account is null)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center text-lg">
|
||||||
|
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This account doesn't exist."]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (account.Moved is { } moved)
|
||||||
|
{
|
||||||
|
<p class="flex flex-wrap items-center gap-2 p-3 rounded-xl neo-inset neo-2xs text-sm" data-moved>
|
||||||
|
<i class="ion-md-swap"></i>
|
||||||
|
<span>@Localizer["{0} has moved to", account.Name]</span>
|
||||||
|
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@* 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"
|
||||||
|
src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<div class="flex flex-col min-w-0 flex-1 pb-1">
|
||||||
|
<b class="text-lg truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span>
|
||||||
|
</div>
|
||||||
|
@if (!IsOwn)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2 pb-1">
|
||||||
|
@if (relationship?.FollowedBy == true)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" class="self-center">@Localizer["Follows you"]</DBadge>
|
||||||
|
}
|
||||||
|
@if (relationship?.Blocking != true)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="@(relationship?.Following == true || relationship?.Requested == true ? DColor.Neutral : DColor.Primary)"
|
||||||
|
Disabled="@(IsDefaultDisabled || isActing || relationship is null)" OnClick="ToggleFollow">
|
||||||
|
@FollowLabel
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@if (relationship?.Following == true)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
</Trigger>
|
||||||
|
<ChildContent>
|
||||||
|
@if (allLists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var list in allLists)
|
||||||
|
{
|
||||||
|
<label class="label text-sm" data-list-id="@list.Id">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@inLists.Contains(list.Id)" disabled="@(IsDefaultDisabled || isActing)"
|
||||||
|
@onchange="@((ChangeEventArgs e) => SetListMember(list, e.Value is true))" />
|
||||||
|
<span class="truncate">@list.Title</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<a class="btn btn-xs" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
||||||
|
</ChildContent>
|
||||||
|
</DDropdown>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
|
||||||
|
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
|
||||||
|
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
|
||||||
|
<span>@(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"])</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
|
||||||
|
OnClick="@(() => Do(relationship?.Blocking == true ? AccountAction.Unblock : AccountAction.Block))">
|
||||||
|
<i class="ion-md-remove-circle text-error"></i>
|
||||||
|
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
||||||
|
<i class="ion-md-flag text-warning"></i>
|
||||||
|
<span>@Localizer["Report"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (isReporting)
|
||||||
|
{
|
||||||
|
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 px-4 pt-2">
|
||||||
|
@if (account.Locked)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-lock"></i> @Localizer["Locked account"]</DBadge>
|
||||||
|
}
|
||||||
|
@if (account.Bot)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-cog"></i> @Localizer["Bot"]</DBadge>
|
||||||
|
}
|
||||||
|
@if (account.Group)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(account.Note))
|
||||||
|
{
|
||||||
|
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account.Fields.Count > 0)
|
||||||
|
{
|
||||||
|
<dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm">
|
||||||
|
@foreach (var field in account.Fields)
|
||||||
|
{
|
||||||
|
<dt class="font-semibold truncate">@CustomEmojis.Text(field.Name, account.Emojis)</dt>
|
||||||
|
<dd class="post-content break-words min-w-0">@((MarkupString)CustomEmojis.Html(field.Value, account.Emojis))</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
|
||||||
|
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
||||||
|
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
||||||
|
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<DTab><Header>@Localizer["Media"]</Header></DTab>
|
||||||
|
</DTabs>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@if (tab is ProfileTab.Posts && pinned.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-4" data-pinned>
|
||||||
|
<span class="badge badge-ghost gap-1 self-start"><i aria-hidden="true" class="ion-md-pin"></i>@Localizer["Pinned"]</span>
|
||||||
|
@foreach (var post in pinned)
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key)))
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
@if (posts.Count == 0 && !isLoadingMore)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
@if (maxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore">
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string AccountId { get; set; }
|
||||||
|
|
||||||
|
Account account;
|
||||||
|
Relationship relationship;
|
||||||
|
List<FeedPost> posts = [];
|
||||||
|
List<FeedPost> pinned = [];
|
||||||
|
HashSet<string> pinnedKeys = [];
|
||||||
|
ProfileTab tab = ProfileTab.Posts;
|
||||||
|
string maxId;
|
||||||
|
string loadedId;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isActing;
|
||||||
|
bool isLoadingMore;
|
||||||
|
bool isListMenuOpen;
|
||||||
|
bool isReporting;
|
||||||
|
List<MastodonList> allLists = [];
|
||||||
|
HashSet<string> inLists = [];
|
||||||
|
|
||||||
|
bool IsOwn => account is not null && account.Id == CascadingState.PersonaId;
|
||||||
|
|
||||||
|
string FullAcct => account.Acct.Contains('@') ? account.Acct : $"{account.Acct}@{account.Host}";
|
||||||
|
|
||||||
|
string HeaderStyle => string.IsNullOrEmpty(account?.Header) ? null : $"background-image:url('{account.Header}')";
|
||||||
|
|
||||||
|
string FollowLabel => relationship switch
|
||||||
|
{
|
||||||
|
{ Following: true } => Localizer["Unfollow"],
|
||||||
|
{ Requested: true } => Localizer["Cancel the request"],
|
||||||
|
_ => Localizer["Follow"]
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (AccountId == loadedId && CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedId = AccountId;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
account = await AccountService.Get(AccountId);
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
|
||||||
|
await LoadLists();
|
||||||
|
pinned = await AccountService.Pinned(account.Id);
|
||||||
|
pinnedKeys = [.. pinned.Select(p => p.Key)];
|
||||||
|
posts = [];
|
||||||
|
maxId = null;
|
||||||
|
IsLoading = false;
|
||||||
|
await LoadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's lists and which of them hold the account, for an account the persona follows.</summary>
|
||||||
|
async Task LoadLists()
|
||||||
|
{
|
||||||
|
if (relationship?.Following != true)
|
||||||
|
{
|
||||||
|
allLists = [];
|
||||||
|
inLists = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allLists = await ListService.All();
|
||||||
|
inLists = [.. (await ListService.Of(account.Id)).Select(l => l.Id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SetListMember(MastodonList list, bool member)
|
||||||
|
{
|
||||||
|
isActing = true;
|
||||||
|
var result = await ListService.SetMember(list.Id, account.Id, member);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member)
|
||||||
|
inLists.Add(list.Id);
|
||||||
|
else
|
||||||
|
inLists.Remove(list.Id);
|
||||||
|
Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, list.Title] : Localizer["{0} removed from {1}.", account.Name, list.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadPage()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
var result = await AccountService.Statuses(account.Id, tab, maxId);
|
||||||
|
isLoadingMore = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (FeedPage)result.Data;
|
||||||
|
var known = posts.Select(p => p.Key).ToHashSet();
|
||||||
|
posts.AddRange(page.Posts.Where(p => !known.Contains(p.Key)));
|
||||||
|
maxId = page.MaxId;
|
||||||
|
await InstanceService.Resolve(page.Posts.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
|
InstanceService.Locate(page.Posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMore() => await LoadPage();
|
||||||
|
|
||||||
|
async Task SelectTab(int index)
|
||||||
|
{
|
||||||
|
tab = (ProfileTab)index;
|
||||||
|
posts = [];
|
||||||
|
maxId = null;
|
||||||
|
await LoadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ToggleFollow() =>
|
||||||
|
await Do(relationship is { Following: true } or { Requested: true } ? AccountAction.Unfollow : AccountAction.Follow);
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relationship = (Relationship)result.Data ?? relationship;
|
||||||
|
if (action is AccountAction.Follow or AccountAction.Unfollow)
|
||||||
|
await LoadLists();
|
||||||
|
Toast.AddResponseSuccess(action switch
|
||||||
|
{
|
||||||
|
AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name],
|
||||||
|
AccountAction.Follow => Localizer["Following {0}.", account.Name],
|
||||||
|
AccountAction.Unfollow => Localizer["{0} unfollowed.", account.Name],
|
||||||
|
AccountAction.Block => Localizer["{0} blocked.", account.Name],
|
||||||
|
AccountAction.Unblock => Localizer["{0} unblocked.", account.Name],
|
||||||
|
AccountAction.Mute => Localizer["{0} muted.", account.Name],
|
||||||
|
_ => Localizer["{0} unmuted.", account.Name]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Remove(FeedPost post)
|
||||||
|
{
|
||||||
|
posts.RemoveAll(p => p.Key == post.Key);
|
||||||
|
pinned.RemoveAll(p => p.Key == post.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OpenTag(string tagName)
|
||||||
|
{
|
||||||
|
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.Tag = tagName;
|
||||||
|
filter.TimelineType = TimelineType.Tag;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Back() => await JS.InvokeVoidAsync("history.back");
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
@page "/scheduled"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Scheduled posts"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's posts waiting for their time (/api/v1/scheduled_statuses): moved to another time, or dropped so
|
||||||
|
they are never published. The composer schedules them. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-time text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Scheduled posts"]</h1>
|
||||||
|
</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 (scheduled.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is waiting to be published."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var item in scheduled)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-scheduled-id="@item.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<i class="ion-md-time"></i>
|
||||||
|
<b>@item.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
|
||||||
|
<span class="opacity-70">@VisibilityName(item.Params.Visibility)</span>
|
||||||
|
@if (item.MediaAttachments.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="opacity-70"><i class="ion-md-images"></i> @item.MediaAttachments.Count</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (item.Params.SpoilerText is { Length: > 0 } warning)
|
||||||
|
{
|
||||||
|
<p class="text-sm"><i class="ion-md-warning text-warning"></i> @warning</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm whitespace-pre-wrap break-words">@item.Params.Text</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="datetime-local" class="input input-sm w-auto" autocomplete="off" aria-label="@Localizer["Publish it on"]"
|
||||||
|
value="@(moves.GetValueOrDefault(item.Id) ?? item.ScheduledAt.ToLocalTime().ToString("yyyy-MM-ddTHH:mm"))"
|
||||||
|
@onchange="@((ChangeEventArgs e) => moves[item.Id] = e.Value?.ToString())" />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving || !moves.ContainsKey(item.Id))" OnClick="@(() => Move(item))">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Move"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Drop(item))">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Never publish it"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
List<ScheduledStatus> scheduled = [];
|
||||||
|
readonly Dictionary<string, string> moves = [];
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
string VisibilityName(string visibility) => visibility switch
|
||||||
|
{
|
||||||
|
"unlisted" => Localizer["Unlisted"],
|
||||||
|
"private" => Localizer["Followers only"],
|
||||||
|
"direct" => Localizer["Direct"],
|
||||||
|
_ => Localizer["Public"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its scheduled posts are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
moves.Clear();
|
||||||
|
scheduled = await StatusService.Scheduled();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Move(ScheduledStatus item)
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(moves[item.Id], System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var local))
|
||||||
|
return;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await StatusService.Reschedule(item.Id, local.ToUniversalTime());
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moved = (ScheduledStatus)result.Data;
|
||||||
|
moves.Remove(item.Id);
|
||||||
|
item.ScheduledAt = moved.ScheduledAt;
|
||||||
|
scheduled = [.. scheduled.OrderBy(s => s.ScheduledAt)];
|
||||||
|
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", moved.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduled.Remove(item);
|
||||||
|
Toast.AddResponseSuccess(Localizer["It will not be published."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@page "/search"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Search"]</Title>
|
||||||
|
|
||||||
|
@* Accounts, posts and hashtags (/api/v2/search). A handle (name@host) or a post's address is resolved by PrivaPub,
|
||||||
|
which fetches it from its server if it has not met it yet; the browser never contacts another server. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<EditForm Model="form" OnValidSubmit="Run" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Query" autocomplete="off" type="search"
|
||||||
|
placeholder="@Localizer["Search accounts, posts and tags, or paste an address"]">
|
||||||
|
<Prefix><i class="ion-md-search"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSearching)">
|
||||||
|
@if (isSearching)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Search"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (searched)
|
||||||
|
{
|
||||||
|
@if (results.Accounts.Count == 0 && results.Statuses.Count == 0 && results.Hashtags.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing found."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (results.Accounts.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-bold">@Localizer["Accounts"]</h2>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var account in results.Accounts)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (results.Hashtags.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-bold">@Localizer["Hashtags"]</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach (var tag in results.Hashtags)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" OnClick="@(() => OpenTag(tag.Name))">#@tag.Name</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (results.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-bold">@Localizer["Posts and replies"]</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@foreach (var post in statuses)
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "q")] public string Q { get; set; }
|
||||||
|
|
||||||
|
sealed class SearchForm
|
||||||
|
{
|
||||||
|
public string Query { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchForm form = new();
|
||||||
|
SearchResults results = new();
|
||||||
|
List<FeedPost> statuses = [];
|
||||||
|
bool searched;
|
||||||
|
bool isSearching;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(Q))
|
||||||
|
return;
|
||||||
|
form.Query = Q;
|
||||||
|
await Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Run()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(form.Query))
|
||||||
|
return;
|
||||||
|
isSearching = true;
|
||||||
|
// a handle or an address is something to resolve; plain words only search what PrivaPub already holds
|
||||||
|
var resolve = form.Query.Contains('@') || form.Query.StartsWith("http", StringComparison.OrdinalIgnoreCase);
|
||||||
|
results = await AccountService.Search(form.Query, resolve);
|
||||||
|
statuses = [.. results.Statuses.Select(FeedPost.Of)];
|
||||||
|
searched = true;
|
||||||
|
isSearching = false;
|
||||||
|
await InstanceService.Resolve(statuses.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
|
InstanceService.Locate(statuses);
|
||||||
|
// a handle opens its account's profile, whatever else matched
|
||||||
|
var handle = form.Query.Trim().TrimStart('@');
|
||||||
|
if (form.Query.Contains('@') && results.Accounts.FirstOrDefault(a => string.Equals(a.Acct, handle, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals($"{a.Acct}@{InstanceService.SelfHost}", handle, StringComparison.OrdinalIgnoreCase)) is { } exact)
|
||||||
|
Navigation.NavigateTo($"account/{Uri.EscapeDataString(exact.Id)}");
|
||||||
|
else if (results.Accounts.Count == 1 && results.Statuses.Count == 0 && form.Query.Contains('@'))
|
||||||
|
Navigation.NavigateTo($"account/{Uri.EscapeDataString(results.Accounts[0].Id)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OpenTag(string tagName)
|
||||||
|
{
|
||||||
|
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.Tag = tagName;
|
||||||
|
filter.TimelineType = TimelineType.Tag;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-8
@@ -21,6 +21,9 @@
|
|||||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||||
|
<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">
|
<label class="flex flex-col gap-2 max-w-md">
|
||||||
<span class="text-sm">@Localizer["Language"]</span>
|
<span class="text-sm">@Localizer["Language"]</span>
|
||||||
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
||||||
@@ -46,6 +49,60 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-funnel text-xl"></i> <span>@Localizer["Filters"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 w-full p-3 md:p-4">
|
||||||
|
<p class="flex-1 min-w-48 text-sm">@Localizer["Words, phrases and posts this persona would rather not see, and where."]</p>
|
||||||
|
<a class="btn btn-sm" href="filters"><i class="ion-md-funnel"></i><span>@Localizer["Manage filters"]</span></a>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2" data-settings-account>
|
||||||
|
<i class="ion-md-key text-xl"></i> <span>@Localizer["Login"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<AccountEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-people text-xl"></i> <span>@Localizer["Personas"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<PersonasEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
@@ -54,9 +111,14 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
</p>
|
<ProfileEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
@@ -69,9 +131,37 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<PersonaArchivePanel />
|
||||||
|
<DataTransfer />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-notifications text-xl"></i> <span>@Localizer["Notifications"]</span>
|
||||||
</p>
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<NotificationPolicyEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
@@ -84,9 +174,14 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
</p>
|
<MutesAndBlocks />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|||||||
+14
@@ -40,7 +40,19 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<RequestStats>()
|
.AddScoped<RequestStats>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<TimelineService>()
|
.AddScoped<TimelineService>()
|
||||||
|
.AddScoped<FeedMemory>()
|
||||||
.AddScoped<StatusService>()
|
.AddScoped<StatusService>()
|
||||||
|
.AddScoped<AccountService>()
|
||||||
|
.AddScoped<PortabilityService>()
|
||||||
|
.AddScoped<AdminService>()
|
||||||
|
.AddScoped<ArchiveService>()
|
||||||
|
.AddScoped<ListService>()
|
||||||
|
.AddScoped<FilterService>()
|
||||||
|
.AddScoped<DiscoveryService>()
|
||||||
|
.AddScoped<StreamingService>()
|
||||||
|
.AddScoped<PublicNoticeService>()
|
||||||
|
.AddScoped<ConfirmService>()
|
||||||
|
.AddScoped<OverlayService>()
|
||||||
.AddScoped<MediaService>()
|
.AddScoped<MediaService>()
|
||||||
.AddScoped<GroupService>()
|
.AddScoped<GroupService>()
|
||||||
.AddScoped<InstanceService>()
|
.AddScoped<InstanceService>()
|
||||||
@@ -60,6 +72,8 @@ builder.Services.AddHttpClient("default", client =>
|
|||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
||||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
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 =>
|
builder.Services.AddSingleton(serviceProvider =>
|
||||||
|
|||||||
+1579
-13
File diff suppressed because it is too large.
Load diff
+1579
-13
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,312 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>What the current persona does to and learns of other accounts.</summary>
|
||||||
|
public enum AccountAction
|
||||||
|
{
|
||||||
|
Follow,
|
||||||
|
Unfollow,
|
||||||
|
Block,
|
||||||
|
Unblock,
|
||||||
|
Mute,
|
||||||
|
Unmute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of an account's posts its profile lists.</summary>
|
||||||
|
public enum ProfileTab
|
||||||
|
{
|
||||||
|
Posts,
|
||||||
|
PostsAndReplies,
|
||||||
|
Media
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
const int PageSize = 20;
|
||||||
|
|
||||||
|
public async Task<Account> Get(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Get), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet.</summary>
|
||||||
|
public async Task<Account> Lookup(string acct, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
acct = acct?.Trim().TrimStart('@');
|
||||||
|
if (string.IsNullOrEmpty(acct))
|
||||||
|
return null;
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_AccountLookup, [$"acct={Uri.EscapeDataString(acct)}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
||||||
|
|
||||||
|
var found = await Search(acct, resolve: true, cancellationToken);
|
||||||
|
return found.Accounts.FirstOrDefault(a => string.Equals(a.Acct, acct, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? found.Accounts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> Relationship(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [$"id[]={Uri.EscapeDataString(accountId)}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return (await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []).FirstOrDefault();
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Relationship), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The relationships of many accounts at once, 40 a request.</summary>
|
||||||
|
public async Task<List<Relationship>> Relationships(IReadOnlyList<string> accountIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var relationships = new List<Relationship>();
|
||||||
|
foreach (var batch in accountIds.Chunk(40))
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [.. batch.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Relationships), cancellationToken);
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
relationships.AddRange(await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []);
|
||||||
|
}
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
|
||||||
|
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
|
||||||
|
new[] { new KeyValuePair<string, string>("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
|
||||||
|
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action.ToString().ToLowerInvariant()),
|
||||||
|
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 account's posts, newest first; Data is a FeedPage.</summary>
|
||||||
|
public async Task<WebResult> Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { $"limit={PageSize}" };
|
||||||
|
parameters.AddRange(tab switch
|
||||||
|
{
|
||||||
|
ProfileTab.Posts => ["exclude_replies=true"],
|
||||||
|
ProfileTab.Media => ["only_media=true"],
|
||||||
|
_ => Array.Empty<string>()
|
||||||
|
});
|
||||||
|
if (maxId is not null)
|
||||||
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), [.. parameters],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
|
||||||
|
var (nextMaxId, _) = response.PageCursors();
|
||||||
|
return new WebResult
|
||||||
|
{
|
||||||
|
Data = new FeedPage
|
||||||
|
{
|
||||||
|
Posts = [.. statuses.Select(FeedPost.Of)],
|
||||||
|
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The posts the account pins, in its order: what it features, for an account elsewhere.</summary>
|
||||||
|
public async Task<List<FeedPost>> Pinned(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), ["pinned=true"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return [.. (await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? []).Select(FeedPost.Of)];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Pinned), cancellationToken);
|
||||||
|
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>Accepts (authorize) or refuses (reject) a follow request.</summary>
|
||||||
|
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_FollowRequest(accountId, accept ? "authorize" : "reject"),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`.</summary>
|
||||||
|
public async Task<SearchResults> Search(string query, bool resolve, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return new SearchResults();
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Search,
|
||||||
|
[$"q={Uri.EscapeDataString(query.Trim())}", $"resolve={(resolve ? "true" : "false")}", "limit=20"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<SearchResults>(cancellationToken) ?? new SearchResults();
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Search), cancellationToken);
|
||||||
|
return new SearchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accounts or hashtags this server already knows that match what is being typed, for the composer: nothing
|
||||||
|
/// is fetched from elsewhere.</summary>
|
||||||
|
public async Task<SearchResults> Suggest(string typed, bool hashtags, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Search,
|
||||||
|
[$"q={Uri.EscapeDataString(typed)}", $"type={(hashtags ? "hashtags" : "accounts")}", "resolve=false", "limit=5"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<SearchResults>(cancellationToken) ?? new SearchResults();
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Suggest), cancellationToken);
|
||||||
|
return new SearchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
|
||||||
|
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func<HttpContent>)form.Content,
|
||||||
|
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>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 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);
|
||||||
|
|
||||||
|
/// <summary>A page of the accounts the persona blocked; Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Blocks(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
|
||||||
|
|
||||||
|
async Task<WebResult> Accounts(string route, string maxId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { "limit=40" };
|
||||||
|
if (maxId is not null)
|
||||||
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
||||||
|
using var response = await httpService.Get(route, [.. parameters], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var accounts = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
|
var (nextMaxId, _) = response.PageCursors();
|
||||||
|
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The servers the persona blocked: nothing from them reaches it, and its followers there are removed.</summary>
|
||||||
|
public async Task<List<string>> DomainBlocks(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_DomainBlocks, ["limit=200"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<string>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> BlockDomain(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair<string, string>("domain", domain.Trim()) },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
StatusIds = statusIds,
|
||||||
|
Comment = comment?.Trim(),
|
||||||
|
Category = category.ToString().ToLowerInvariant(),
|
||||||
|
Forward = forward
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AccountService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The newest 100 reports, open or resolved.</summary>
|
||||||
|
public async Task<List<ViewReport>> Reports(bool resolved, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorReports, [$"resolved={(resolved ? "true" : "false")}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewReport>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Reports), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The servers blocked for everyone here, by name.</summary>
|
||||||
|
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminDomainBlocks, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewDomainBlock>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(ServerBlocks), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blocks a server for everyone, or changes how; Data is the block.</summary>
|
||||||
|
public async Task<WebResult> BlockServer(DomainBlockForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminDomainBlock, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewDomainBlock>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> UnblockServer(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminDomainUnblock(domain), cancellationToken: cancellationToken);
|
||||||
|
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);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewStatisticsOverview>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Overview), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One page of the servers we exchanged with; sort is traffic (default), failures, latency or host.</summary>
|
||||||
|
public async Task<ViewHostsPage> Hosts(int days, string sort, int page, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsHosts,
|
||||||
|
[$"days={days}", $"sort={Uri.EscapeDataString(sort ?? "")}", $"page={page}", "limit=50"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewHostsPage>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Hosts), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ViewCrawler> Crawler(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsCrawler, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewCrawler>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Crawler), 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.Forbidden))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AdminService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
@@ -41,6 +42,20 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
|||||||
|
|
||||||
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
|
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
|
||||||
|
|
||||||
|
// whether the navigation being decided came from the Back (or Forward) button
|
||||||
|
public bool TookBack() => JsSyncRuntime.Invoke<bool>("tookBack");
|
||||||
|
|
||||||
|
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
|
||||||
|
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
|
||||||
|
|
||||||
|
// read once: a card asks it for each gifv it shows
|
||||||
|
bool? reducedMotion;
|
||||||
|
|
||||||
|
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
||||||
|
|
||||||
|
// a sideways-scrolling strip of tabs shows its selected tab
|
||||||
|
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||||
|
|
||||||
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
|
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
|
||||||
|
|
||||||
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
|
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.User;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A persona's whole archive on PrivaPub (/clientapi/persona/{id}/archive): made in a job and downloaded through a
|
||||||
|
/// link, or another archive (its own, or Mastodon's) uploaded in pieces by js/transfer.js and imported into it, the
|
||||||
|
/// parts chosen, until it is done or asked to stop. As the root. Values null on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<ViewPersonaArchive> State(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(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(ArchiveService)}.{nameof(State)}", cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks for the archive to be made; Data is the state.</summary>
|
||||||
|
public async Task<WebResult> Export(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A link to download the archive made; Data is the ViewArchiveTicket.</summary>
|
||||||
|
public async Task<WebResult> Ticket(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveTicket(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveTicket>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload for an archive's pieces; Data is the ViewArchiveUpload.</summary>
|
||||||
|
public async Task<WebResult> StartUpload(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_PersonaArchiveUpload, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveUpload>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> CancelUpload(string uploadId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_PersonaArchiveUpload(uploadId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The uploaded archive imported into the persona; Data is the state.</summary>
|
||||||
|
public async Task<WebResult> Import(string personaId, ImportPersonaArchiveForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImport(personaId), form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Stop(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImportStop(personaId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,16 @@ namespace decePubClient.Services
|
|||||||
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
|
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere.</summary>
|
||||||
|
public async Task<WebResult> CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
|
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
|
||||||
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -104,6 +114,27 @@ namespace decePubClient.Services
|
|||||||
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Changes the root's password, the current one given.</summary>
|
||||||
|
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes the root, its password given: every persona of it and everything public they had, here and, as
|
||||||
|
/// PrivaPub tells them, on the servers that hold it. The caller signs out afterwards.</summary>
|
||||||
|
public async Task<WebResult> DeleteAccount(RemoveSelfForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteAccount, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
|
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
|
||||||
public async Task Logout(CancellationToken cancellationToken = default)
|
public async Task Logout(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Asks before what can't be undone (deleting a post, blocking, leaving a group, signing out): Ask shows the question
|
||||||
|
/// in ConfirmHost (MainLayout) and returns whether the reader confirmed it. One question at a time; a second one asked
|
||||||
|
/// while the first is open answers the first no.
|
||||||
|
/// </summary>
|
||||||
|
public class ConfirmService
|
||||||
|
{
|
||||||
|
TaskCompletionSource<bool> pending;
|
||||||
|
|
||||||
|
public event Action Changed;
|
||||||
|
|
||||||
|
public bool IsAsking => pending is not null;
|
||||||
|
public string Heading { get; private set; }
|
||||||
|
public string Text { get; private set; }
|
||||||
|
public string ConfirmText { get; private set; }
|
||||||
|
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
|
||||||
|
public bool Danger { get; private set; }
|
||||||
|
|
||||||
|
public async Task<bool> Ask(string heading, string text, string confirmText, bool danger, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
pending?.TrySetResult(false);
|
||||||
|
var asking = new TaskCompletionSource<bool>();
|
||||||
|
pending = asking;
|
||||||
|
(Heading, Text, ConfirmText, Danger) = (heading, text, confirmText, danger);
|
||||||
|
Changed?.Invoke();
|
||||||
|
await using var cancel = cancellationToken.Register(() => asking.TrySetResult(false));
|
||||||
|
var confirmed = await asking.Task;
|
||||||
|
if (pending == asking)
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Answer(bool confirmed) => pending?.TrySetResult(confirmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>Which order the directory lists accounts in.</summary>
|
||||||
|
public enum DirectoryOrder
|
||||||
|
{
|
||||||
|
/// <summary>By their latest public post.</summary>
|
||||||
|
Active,
|
||||||
|
/// <summary>By the day they say they joined.</summary>
|
||||||
|
New
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What there is to find on PrivaPub: hashtags (their week, following them), what is trending there (hashtags, posts
|
||||||
|
/// and links, from public posts it already holds) and the directory of accounts that let themselves be discovered.
|
||||||
|
/// Lists come back empty and values null on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<Tag> Tag(string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetAnon(APIs.Mastodon.GET_M_Tag(name), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<Tag>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Tag), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows or stops following a hashtag; Data is the Tag.</summary>
|
||||||
|
public async Task<WebResult> FollowTag(string name, bool follow, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_TagAction(name, follow ? "follow" : "unfollow"), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Tag>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Tag>> FollowedTags(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Tag>(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<Tag>> TrendingTags(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Tag>(APIs.Mastodon.GET_TrendingTags, ["limit=20"], nameof(TrendingTags), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<Status>> TrendingStatuses(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Status>(APIs.Mastodon.GET_TrendingStatuses, ["limit=40"], nameof(TrendingStatuses), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<TrendLink>> TrendingLinks(CancellationToken cancellationToken = default) =>
|
||||||
|
List<TrendLink>(APIs.Mastodon.GET_TrendingLinks, ["limit=20"], nameof(TrendingLinks), cancellationToken);
|
||||||
|
|
||||||
|
public const int DirectoryPage = 40;
|
||||||
|
|
||||||
|
public Task<List<Account>> Directory(DirectoryOrder order, bool localOnly, int offset, CancellationToken cancellationToken = default) =>
|
||||||
|
List<Account>(APIs.Mastodon.GET_Directory,
|
||||||
|
[$"order={(order is DirectoryOrder.New ? "new" : "active")}", $"local={(localOnly ? "true" : "false")}", $"limit={DirectoryPage}", $"offset={offset}"],
|
||||||
|
nameof(Directory), cancellationToken);
|
||||||
|
|
||||||
|
async Task<List<T>> List<T>(string route, string[] parameters, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpService.GetAnon(route, parameters, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<T>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, caller, cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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(DiscoveryService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels.Group;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The feed as the reader left it: its query, posts, waiting posts, cursors and scroll. Coming back from a thread or a
|
||||||
|
/// profile shows it again at once instead of reading it anew; newer posts come behind the "new" button as usual. Kept
|
||||||
|
/// ten minutes, and only for the same feed of the same persona.
|
||||||
|
/// </summary>
|
||||||
|
public class FeedMemory
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Keep = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
public FeedQuery Query { get; private set; }
|
||||||
|
public List<FeedPost> Posts { get; private set; } = [];
|
||||||
|
public List<FeedPost> Pending { get; private set; } = [];
|
||||||
|
public List<ViewGroup> Groups { get; private set; } = [];
|
||||||
|
public List<MastodonList> Lists { get; private set; } = [];
|
||||||
|
public string MinId { get; private set; }
|
||||||
|
public string MaxId { get; private set; }
|
||||||
|
public double ScrollTop { get; private set; }
|
||||||
|
public DateTime SavedAt { get; private set; }
|
||||||
|
|
||||||
|
public void Save(FeedQuery query, List<FeedPost> posts, List<FeedPost> pending, List<ViewGroup> groups, List<MastodonList> lists,
|
||||||
|
string minId, string maxId, double scrollTop)
|
||||||
|
{
|
||||||
|
Query = query;
|
||||||
|
Posts = posts;
|
||||||
|
Pending = pending;
|
||||||
|
Groups = groups;
|
||||||
|
Lists = lists;
|
||||||
|
MinId = minId;
|
||||||
|
MaxId = maxId;
|
||||||
|
ScrollTop = scrollTop;
|
||||||
|
SavedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether what is kept is recent and belongs to this persona.</summary>
|
||||||
|
public bool Holds(string personaId) => Query is not null && Query.PersonaId == personaId && DateTime.UtcNow - SavedAt < Keep;
|
||||||
|
|
||||||
|
public void Forget()
|
||||||
|
{
|
||||||
|
Query = null;
|
||||||
|
Posts = [];
|
||||||
|
Pending = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The current persona's filters (PrivaPub's /api/v2/filters): made, changed with their keywords, given posts, and
|
||||||
|
/// deleted. What they match comes on each status (Status.Filtered); the card applies it. Lists come back empty on
|
||||||
|
/// failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class FilterService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<List<Filter>> All(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Filters, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Filter>>(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(FilterService)}.{nameof(All)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the filter when filterId is null, or changes it; Data is the filter as PrivaPub keeps it.</summary>
|
||||||
|
public async Task<WebResult> Save(string filterId, FilterForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = filterId is null
|
||||||
|
? await httpService.Post(APIs.Mastodon.POST_Filter, form, cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Put(APIs.Mastodon.PUT_M_Filter(filterId), form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Filter>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Delete(string filterId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Filter(filterId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds a post to the filter; Data is the FilterStatus.</summary>
|
||||||
|
public async Task<WebResult> AddStatus(string filterId, string statusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_FilterStatus(filterId), new[] { new KeyValuePair<string, string>("status_id", statusId) },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<FilterStatus>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> RemoveStatus(string filterStatusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_FilterStatus(filterStatusId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ using collAnon.Client.Services;
|
|||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
using PrivaPub.ClientModels.Group;
|
using PrivaPub.ClientModels.Group;
|
||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>The communities and circles a persona belongs to (/clientapi/group/list).</summary>
|
/// <summary>The persona's communities and circles (/clientapi/group): made, joined by invitation, edited and left;
|
||||||
|
/// for their owners and moderators, who is in them and who asks to join.</summary>
|
||||||
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The persona's groups, communities first; empty on failure.</summary>
|
/// <summary>The persona's groups, communities first; empty on failure.</summary>
|
||||||
@@ -31,5 +33,66 @@ namespace decePubClient.Services
|
|||||||
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
|
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
|
||||||
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
|
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes a community or a circle owned by the persona; Data is the ViewGroup.</summary>
|
||||||
|
public Task<WebResult> Insert(InsertGroupForm form, CancellationToken cancellationToken = default) =>
|
||||||
|
GroupResult(APIs.ClientApi.POST_InsertGroup, form, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Changes a group the persona runs; Data is the ViewGroup.</summary>
|
||||||
|
public Task<WebResult> Update(UpdateGroupForm form, CancellationToken cancellationToken = default) =>
|
||||||
|
GroupResult(APIs.ClientApi.POST_UpdateGroup, form, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Joins a group by its invitation (and its password, when it has one); Data is the ViewGroup.</summary>
|
||||||
|
public Task<WebResult> Join(JoinGroupForm form, CancellationToken cancellationToken = default) =>
|
||||||
|
GroupResult(APIs.ClientApi.POST_JoinGroup, form, cancellationToken);
|
||||||
|
|
||||||
|
public Task<WebResult> Leave(string personaId, string groupId, CancellationToken cancellationToken = default) =>
|
||||||
|
Membership(APIs.ClientApi.POST_LeaveGroup, personaId, groupId, null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Lets in someone who asked to join a group the persona runs.</summary>
|
||||||
|
public Task<WebResult> Approve(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
|
||||||
|
Membership(APIs.ClientApi.POST_ApproveGroupMember, personaId, groupId, actorUri, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Declines a request to join; the asker's server is told.</summary>
|
||||||
|
public Task<WebResult> Reject(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
|
||||||
|
Membership(APIs.ClientApi.POST_RejectGroupMember, personaId, groupId, actorUri, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Takes a member out of a group the persona runs.</summary>
|
||||||
|
public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
|
||||||
|
Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary>
|
||||||
|
public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_GroupMembers,
|
||||||
|
[$"avatarId={Uri.EscapeDataString(personaId)}", $"groupId={Uri.EscapeDataString(groupId)}"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(GroupService)}.{nameof(Members)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewGroupMember>>(cancellationToken) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> GroupResult(string route, object form, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewGroup>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Membership(string route, string personaId, string groupId, string actorUri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(route, new GroupMembershipForm { AvatarId = personaId, GroupId = groupId, MemberActorURI = actorUri },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,10 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
@@ -78,6 +82,12 @@ namespace decePubClient.Services
|
|||||||
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -300,10 +310,24 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sends the request and records it in RequestStats.</summary>
|
/// <summary>Sends the request and records it in RequestStats.</summary>
|
||||||
|
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
|
||||||
|
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
|
||||||
var started = Stopwatch.GetTimestamp();
|
var started = Stopwatch.GetTimestamp();
|
||||||
var response = await httpClient.SendAsync(request, cancellationToken);
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await httpClient.SendAsync(request, timeout.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
|
||||||
|
}
|
||||||
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
||||||
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
||||||
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The current persona's lists (Mastodon's /api/v1/lists): made, renamed and deleted, their members (only accounts the
|
||||||
|
/// persona follows; PrivaPub drops a member when the follow ends), and the lists an account is in. A list's posts are a
|
||||||
|
/// feed of their own (TimelineService, TimelineType.Lists). Lists come back empty on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class ListService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The most members PrivaPub keeps in one list, and so the most read back.</summary>
|
||||||
|
public const int MaxMembers = 500;
|
||||||
|
const int MembersPerPage = 80;
|
||||||
|
|
||||||
|
public async Task<List<MastodonList>> All(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Lists, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<MastodonList>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(All), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the list when it has no id yet, or saves its title, replies policy and exclusivity; Data is the list.</summary>
|
||||||
|
public async Task<WebResult> Save(MastodonList list, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var form = new MastodonList { Title = list.Title?.Trim(), RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive };
|
||||||
|
using var response = list.Id is null
|
||||||
|
? await httpService.Post(APIs.Mastodon.POST_List, form, cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Put(APIs.Mastodon.PUT_M_List(list.Id), form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<MastodonList>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Delete(string listId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_List(listId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every member of the list, page by page, up to MaxMembers.</summary>
|
||||||
|
public async Task<List<Account>> Members(string listId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var members = new List<Account>();
|
||||||
|
var maxId = default(string);
|
||||||
|
while (members.Count < MaxMembers)
|
||||||
|
{
|
||||||
|
string[] parameters = maxId is null ? [$"limit={MembersPerPage}"] : [$"limit={MembersPerPage}", $"max_id={Uri.EscapeDataString(maxId)}"];
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.M_ListAccounts(listId), parameters, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Members), cancellationToken);
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
|
members.AddRange(page);
|
||||||
|
maxId = response.PageCursors().MaxId;
|
||||||
|
if (page.Count < MembersPerPage || maxId is null)
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds (or removes) an account the persona follows to (from) the list.</summary>
|
||||||
|
public async Task<WebResult> SetMember(string listId, string accountId, bool member, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = member
|
||||||
|
? await httpService.Post(APIs.Mastodon.M_ListAccounts(listId), new ListAccountsForm { AccountIds = [accountId] },
|
||||||
|
cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Delete(APIs.Mastodon.M_ListAccounts(listId), [$"account_ids[]={Uri.EscapeDataString(accountId)}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's lists the account is in.</summary>
|
||||||
|
public async Task<List<MastodonList>> Of(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountLists(accountId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<MastodonList>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Of), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accounts the persona follows whose name, display name or server starts with the query (PrivaPub's
|
||||||
|
/// following=true; nothing is resolved).</summary>
|
||||||
|
public async Task<List<Account>> FindFollowed(string query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return [];
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_AccountSearch,
|
||||||
|
[$"q={Uri.EscapeDataString(query.Trim())}", "following=true", "resolve=false", "limit=20"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(FindFollowed), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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(ListService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
|
|||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
|
/// <summary>
|
||||||
|
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
||||||
|
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
|
||||||
|
/// </summary>
|
||||||
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
|
static readonly MediaConfiguration Fallback = new()
|
||||||
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
|
{
|
||||||
|
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
|
||||||
|
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
|
||||||
|
ImageSizeLimit = 16 * 1024 * 1024,
|
||||||
|
VideoSizeLimit = 99 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaConfiguration limits;
|
||||||
|
|
||||||
|
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
|
||||||
|
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (limits is not null)
|
||||||
|
return limits;
|
||||||
|
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode
|
||||||
|
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
|
||||||
|
return limits = instance.Configuration.MediaAttachments;
|
||||||
|
return Fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
|
||||||
|
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
||||||
{
|
{
|
||||||
var file = new ByteArrayContent(media.Blob);
|
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
|
||||||
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
|
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
|
||||||
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
||||||
if (media.AltText is { Length: > 0 } description)
|
if (media.AltText is { Length: > 0 } description)
|
||||||
content.Add(new StringContent(description), "description");
|
content.Add(new StringContent(description), "description");
|
||||||
@@ -29,6 +54,35 @@ namespace decePubClient.Services
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
|
||||||
|
/// when it can't be.</summary>
|
||||||
|
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.PartialContent)
|
||||||
|
return new WebResult();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
|
||||||
|
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
|
||||||
|
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
||||||
return attachment?.Id is null
|
return attachment?.Id is null
|
||||||
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using decePubClient.Components.Daisy;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The overlays open now, newest last (IDOverlayHost): MainLayout closes the newest when the Back button is pressed
|
||||||
|
/// while one is open, and all of them when the app goes elsewhere.
|
||||||
|
/// </summary>
|
||||||
|
public class OverlayService : IDOverlayHost
|
||||||
|
{
|
||||||
|
readonly List<Entry> open = [];
|
||||||
|
|
||||||
|
public bool HasOpen
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (open)
|
||||||
|
return open.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Open(Func<Task> close)
|
||||||
|
{
|
||||||
|
var entry = new Entry(this, close);
|
||||||
|
lock (open)
|
||||||
|
open.Add(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CloseTop(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Entry top;
|
||||||
|
lock (open)
|
||||||
|
top = open.LastOrDefault();
|
||||||
|
if (top is null)
|
||||||
|
return;
|
||||||
|
top.Dispose();
|
||||||
|
await top.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CloseAll(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Entry[] all;
|
||||||
|
lock (open)
|
||||||
|
{
|
||||||
|
all = [.. open];
|
||||||
|
open.Clear();
|
||||||
|
}
|
||||||
|
foreach (var entry in all.Reverse())
|
||||||
|
await entry.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Entry(OverlayService owner, Func<Task> close) : IDisposable
|
||||||
|
{
|
||||||
|
public Func<Task> Close { get; } = close;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (owner.open)
|
||||||
|
owner.open.Remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
using collAnon.Client.Services;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>What a persona carries from one server to another, each in the CSV file Mastodon exports and imports.</summary>
|
||||||
|
public enum PortableData
|
||||||
|
{
|
||||||
|
Follows = 1,
|
||||||
|
Lists,
|
||||||
|
Blocks,
|
||||||
|
Mutes,
|
||||||
|
ServerBlocks,
|
||||||
|
Bookmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How an import went: how many rows were done, and the rows that could not be, with why.</summary>
|
||||||
|
public sealed class ImportReport
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Done { get; set; }
|
||||||
|
public List<string> Failed { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The persona's follows, lists, blocks, mutes, blocked servers and bookmarks as Mastodon's CSV exports, written in
|
||||||
|
/// the browser from the Mastodon API, and the same files imported: each row resolved through PrivaPub (a remote
|
||||||
|
/// account or post is fetched by PrivaPub, never by the browser) and acted on one at a time. Nothing throws; an import
|
||||||
|
/// reports the rows it could not do.
|
||||||
|
/// </summary>
|
||||||
|
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
||||||
|
InstanceService instances, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The most rows read from an export or an import file.</summary>
|
||||||
|
public const int MaxRows = 5000;
|
||||||
|
|
||||||
|
const string AccountHeader = "Account address";
|
||||||
|
|
||||||
|
public static string FileName(PortableData data) => data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => "following_accounts.csv",
|
||||||
|
PortableData.Lists => "lists.csv",
|
||||||
|
PortableData.Blocks => "blocked_accounts.csv",
|
||||||
|
PortableData.Mutes => "muted_accounts.csv",
|
||||||
|
PortableData.ServerBlocks => "blocked_domains.csv",
|
||||||
|
_ => "bookmarks.csv"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
||||||
|
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
|
||||||
|
sealed class Stop
|
||||||
|
{
|
||||||
|
public WebResult Failed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
|
||||||
|
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var stop = new Stop();
|
||||||
|
var rows = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
|
||||||
|
PortableData.Lists => await Lists(cancellationToken),
|
||||||
|
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
||||||
|
PortableData.Mutes => await Mutes(stop, cancellationToken),
|
||||||
|
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
||||||
|
_ => await Bookmarks(personaId, stop, cancellationToken)
|
||||||
|
};
|
||||||
|
if (stop.Failed is { } failed)
|
||||||
|
return failed;
|
||||||
|
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
|
||||||
|
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
Csv(AccountHeader, "Show boosts", "Notify on new posts", "Languages"),
|
||||||
|
.. followed.Select(a => relationships.GetValueOrDefault(a.Id) is { } r
|
||||||
|
? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), null)
|
||||||
|
: Csv(Full(a.Acct), "true", "false", null))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
|
||||||
|
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
Csv(AccountHeader, "Hide notifications"),
|
||||||
|
.. muted.Select(a => Csv(Full(a.Acct), Flag(relationships.GetValueOrDefault(a.Id)?.MutingNotifications ?? true)))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Lists(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<string>();
|
||||||
|
foreach (var list in await lists.All(cancellationToken))
|
||||||
|
rows.AddRange((await lists.Members(list.Id, cancellationToken)).Select(member => Csv(list.Title, Full(member.Acct))));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<string>();
|
||||||
|
var maxId = default(string);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
||||||
|
if (result.IsInvalid || result.Data is not FeedPage page)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
||||||
|
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
||||||
|
}
|
||||||
|
while (maxId is not null && rows.Count < MaxRows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// every page of an account list, up to MaxRows
|
||||||
|
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var all = new List<Account>();
|
||||||
|
var maxId = default(string);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var result = await page(maxId, cancellationToken);
|
||||||
|
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
all.AddRange(accountPage.Accounts);
|
||||||
|
maxId = accountPage.MaxId;
|
||||||
|
}
|
||||||
|
while (maxId is not null && all.Count < MaxRows);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Does what each row of an exported file says, one row at a time; progress counts the rows done.</summary>
|
||||||
|
public async Task<ImportReport> Import(PortableData data, string csv, IProgress<ImportReport> progress = default,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var rows = Parse(csv.TrimStart('\uFEFF')).Where(r => r.Count > 0 && !string.IsNullOrWhiteSpace(r[0])).ToList();
|
||||||
|
if (rows.Count > 0 && rows[0][0].Equals(AccountHeader, StringComparison.OrdinalIgnoreCase))
|
||||||
|
rows.RemoveAt(0);
|
||||||
|
var report = new ImportReport { Total = Math.Min(rows.Count, MaxRows) };
|
||||||
|
var known = data == PortableData.Lists ? await lists.All(cancellationToken) : [];
|
||||||
|
foreach (var row in rows.Take(MaxRows))
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
var failure = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await FollowRow(row, cancellationToken),
|
||||||
|
PortableData.Lists => await ListRow(row, known, cancellationToken),
|
||||||
|
PortableData.Blocks => await AccountRow(row[0], AccountAction.Block, cancellationToken),
|
||||||
|
PortableData.Mutes => await AccountRow(row[0], AccountAction.Mute, cancellationToken),
|
||||||
|
PortableData.ServerBlocks => Failure(row[0], await accounts.BlockDomain(row[0], cancellationToken)),
|
||||||
|
_ => await BookmarkRow(row[0].Trim(), cancellationToken)
|
||||||
|
};
|
||||||
|
if (failure is null)
|
||||||
|
report.Done++;
|
||||||
|
else
|
||||||
|
report.Failed.Add(failure);
|
||||||
|
progress?.Report(report);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> FollowRow(List<string> row, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await accounts.Lookup(row[0], cancellationToken) is not { } account)
|
||||||
|
return Unknown(row[0]);
|
||||||
|
var showReblogs = row.Count < 2 || !row[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> AccountRow(string acct, AccountAction action, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await accounts.Lookup(acct, cancellationToken) is not { } account)
|
||||||
|
return Unknown(acct);
|
||||||
|
return Failure(acct, await accounts.Act(account.Id, action, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a list row names the list and an account; the list is made when missing, and an account is followed first, since
|
||||||
|
// a list holds followed accounts only
|
||||||
|
async Task<string> ListRow(List<string> row, List<MastodonList> known, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (row.Count < 2 || string.IsNullOrWhiteSpace(row[1]))
|
||||||
|
return string.Join(',', row);
|
||||||
|
var title = row[0].Trim();
|
||||||
|
var list = known.FirstOrDefault(l => string.Equals(l.Title, title, StringComparison.Ordinal));
|
||||||
|
if (list is null)
|
||||||
|
{
|
||||||
|
var made = await lists.Save(new MastodonList { Title = title }, cancellationToken);
|
||||||
|
if (made.IsInvalid || made.Data is not MastodonList created)
|
||||||
|
return Failure(title, made) ?? title;
|
||||||
|
known.Add(list = created);
|
||||||
|
}
|
||||||
|
if (await accounts.Lookup(row[1], cancellationToken) is not { } account)
|
||||||
|
return Unknown(row[1]);
|
||||||
|
var relationship = await accounts.Relationship(account.Id, cancellationToken);
|
||||||
|
if (relationship is not { Following: true })
|
||||||
|
{
|
||||||
|
var followed = await accounts.Follow(account.Id, true, cancellationToken);
|
||||||
|
if (followed.IsInvalid)
|
||||||
|
return Failure(row[1], followed);
|
||||||
|
}
|
||||||
|
return Failure($"{title}: {row[1]}", await lists.SetMember(list.Id, account.Id, true, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> BookmarkRow(string uri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await accounts.Search(uri, resolve: true, cancellationToken);
|
||||||
|
if ((found.Statuses.FirstOrDefault(s => s.Uri == uri || s.Url == uri) ?? found.Statuses.FirstOrDefault()) is not { } status)
|
||||||
|
return Unknown(uri);
|
||||||
|
return Failure(uri, await statuses.Act(status.Id, StatusAction.Bookmark, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Unknown(string what) => $"{what}: ?";
|
||||||
|
|
||||||
|
static string Failure(string what, WebResult result) => result.IsInvalid ? $"{what}: {result.ErrorMessage}" : null;
|
||||||
|
|
||||||
|
// a local account's acct has no host; an export names every account in full
|
||||||
|
string Full(string acct) => acct.Contains('@') ? acct : $"{acct}@{instances.SelfHost}";
|
||||||
|
|
||||||
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
static string Csv(params string[] cells) => string.Join(',', cells.Select(c => c is null ? "" :
|
||||||
|
c.IndexOfAny([',', '"', '\n', '\r']) < 0 ? c : $"\"{c.Replace("\"", "\"\"")}\""));
|
||||||
|
|
||||||
|
// RFC 4180: quoted cells may hold commas, quotes (doubled) and line breaks
|
||||||
|
static IEnumerable<List<string>> Parse(string csv)
|
||||||
|
{
|
||||||
|
var row = new List<string>();
|
||||||
|
var cell = new StringBuilder();
|
||||||
|
var quoted = false;
|
||||||
|
for (var i = 0; i < csv.Length; i++)
|
||||||
|
{
|
||||||
|
var c = csv[i];
|
||||||
|
if (quoted)
|
||||||
|
{
|
||||||
|
if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"')
|
||||||
|
{
|
||||||
|
cell.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
quoted = false;
|
||||||
|
else
|
||||||
|
cell.Append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
quoted = true;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
cell.Clear();
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
cell.Clear();
|
||||||
|
yield return row;
|
||||||
|
row = [];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
cell.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cell.Length > 0 || row.Count > 0)
|
||||||
|
{
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
yield return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Blazored.LocalStorage;
|
||||||
|
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
|
||||||
|
/// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm
|
||||||
|
/// returns true at once for a persona that already accepted it, and false when the reader declines.
|
||||||
|
/// </summary>
|
||||||
|
public class PublicNoticeService(ILocalStorageService localStorage)
|
||||||
|
{
|
||||||
|
TaskCompletionSource<bool> pending;
|
||||||
|
|
||||||
|
public event Action Changed;
|
||||||
|
|
||||||
|
public bool IsAsking => pending is not null;
|
||||||
|
|
||||||
|
/// <summary>What the persona is being told about.</summary>
|
||||||
|
public PublicAct Act { get; private set; } = PublicAct.Reaction;
|
||||||
|
|
||||||
|
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(personaId))
|
||||||
|
return false;
|
||||||
|
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
|
||||||
|
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
|
||||||
|
if (told.Contains(personaId))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
Act = act;
|
||||||
|
pending = new TaskCompletionSource<bool>();
|
||||||
|
Changed?.Invoke();
|
||||||
|
var accepted = await pending.Task;
|
||||||
|
pending = null;
|
||||||
|
Changed?.Invoke();
|
||||||
|
if (!accepted)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
told.Add(personaId);
|
||||||
|
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Answer(bool accepted) => pending?.TrySetResult(accepted);
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
-12
@@ -21,7 +21,9 @@ namespace decePubClient.Services
|
|||||||
Reblog,
|
Reblog,
|
||||||
Unreblog,
|
Unreblog,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Unbookmark
|
Unbookmark,
|
||||||
|
Pin,
|
||||||
|
Unpin
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,38 +33,147 @@ namespace decePubClient.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
|
/// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
|
||||||
|
/// it is picked); a description changed since is sent first.</summary>
|
||||||
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (form.IsLocated || form.GroupId is not null)
|
if (form.IsLocated || form.GroupId is not null)
|
||||||
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
||||||
|
|
||||||
var mediaIds = new List<string>();
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
foreach (var media in form.Media)
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
{
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
var uploaded = await mediaService.Upload(media, cancellationToken);
|
return undescribed;
|
||||||
if (uploaded.IsInvalid)
|
var mediaIds = form.Media.Select(m => m.Id).ToList();
|
||||||
return uploaded;
|
|
||||||
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
||||||
{
|
{
|
||||||
Status = form.Content ?? string.Empty,
|
Status = form.Content ?? string.Empty,
|
||||||
MediaIds = mediaIds,
|
MediaIds = mediaIds,
|
||||||
InReplyToId = form.InReplyToId,
|
InReplyToId = form.InReplyToId,
|
||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.Visibility,
|
||||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
|
||||||
|
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
|
||||||
|
QuotedStatusId = form.QuotedStatusId
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
if (form.ScheduledAtUtc is not null)
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
||||||
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
||||||
return new WebResult { Data = FeedPost.Of(status) };
|
return new WebResult { Data = FeedPost.Of(status) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
|
||||||
|
// described again through the edit's media_attributes)
|
||||||
|
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
|
||||||
|
{
|
||||||
|
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
|
||||||
|
if (described.IsInvalid)
|
||||||
|
return described;
|
||||||
|
media.SentAltText = media.AltText;
|
||||||
|
}
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
||||||
|
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusSource(statusId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<StatusSource>(cancellationToken) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A post's versions, the oldest first and the one showing last; [] when they cannot be read.</summary>
|
||||||
|
public async Task<List<StatusEdit>> History(string statusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusHistory(statusId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(History)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.</summary>
|
||||||
|
public async Task<List<Account>> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId),
|
||||||
|
["limit=80"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
|
||||||
|
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
|
||||||
|
/// changed; Data is the edited Status.</summary>
|
||||||
|
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
|
return undescribed;
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
|
||||||
|
{
|
||||||
|
Status = form.Content ?? string.Empty,
|
||||||
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
MediaIds = [.. form.Media.Select(m => m.Id)],
|
||||||
|
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Status>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
|
||||||
|
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [.. (await response.MastodonReadFromJsonAsync<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
|
||||||
|
public async Task<WebResult> Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId),
|
||||||
|
new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops a scheduled post: it is never published.</summary>
|
||||||
|
public async Task<WebResult> Unschedule(string scheduledId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
||||||
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
|
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -100,6 +211,36 @@ namespace decePubClient.Services
|
|||||||
return await StatusResult(response, cancellationToken);
|
return await StatusResult(response, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reacts with an emoji (or takes the reaction back); Data is the updated Status.</summary>
|
||||||
|
public async Task<WebResult> React(string statusId, string emoji, bool add, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var route = APIs.Mastodon.M_Reaction(statusId, emoji);
|
||||||
|
using var response = add
|
||||||
|
? await httpService.Put(route, cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Delete(route, cancellationToken: cancellationToken);
|
||||||
|
return await StatusResult(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Joins a remote event as the persona (its server is told, and shows who takes part), or leaves it; Data is
|
||||||
|
/// the updated Status, whose event says how the participation stands.</summary>
|
||||||
|
public async Task<WebResult> Participate(string statusId, bool join, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(join ? APIs.PrivaPub.POST_M_Join(statusId) : APIs.PrivaPub.POST_M_Leave(statusId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return await StatusResult(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
|
||||||
|
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_PollVotes(pollId), new PollVoteForm { Choices = [.. choices] },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Poll>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Deletes the persona's own status.</summary>
|
/// <summary>Deletes the persona's own status.</summary>
|
||||||
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -107,6 +248,17 @@ namespace decePubClient.Services
|
|||||||
return await StatusResult(response, cancellationToken);
|
return await StatusResult(response, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.</summary>
|
||||||
|
public async Task<WebResult> DeleteLocated(string postId, string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePost, new DeletePostForm { AvatarId = personaId, PostId = postId },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
|
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.Helpers;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>One stream of PrivaPub's streaming API: its name and, for a hashtag or a list, which one.</summary>
|
||||||
|
public sealed record StreamSubscription(string Stream, string Tag = default, string List = default);
|
||||||
|
|
||||||
|
/// <summary>What a stream told: the stream it came on, the event (update, notification, status.update, delete) and
|
||||||
|
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
|
||||||
|
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
|
||||||
|
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
|
||||||
|
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
|
||||||
|
/// page to poll meanwhile. Nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
|
||||||
|
ILoggingService logger) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
ClientWebSocket socket;
|
||||||
|
CancellationTokenSource running;
|
||||||
|
StreamSubscription current;
|
||||||
|
|
||||||
|
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
|
||||||
|
|
||||||
|
/// <summary>Raised for each message of the stream subscribed now.</summary>
|
||||||
|
public event Func<StreamMessage, Task> Received;
|
||||||
|
|
||||||
|
Uri Endpoint
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var root = new UriBuilder(configuration.ApiBaseAddress) { Path = "/api/v1/streaming" };
|
||||||
|
root.Scheme = root.Scheme == "https" ? "wss" : "ws";
|
||||||
|
return root.Uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
|
||||||
|
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (subscription == current && (running is not null || subscription is null))
|
||||||
|
return;
|
||||||
|
var previous = current;
|
||||||
|
current = subscription;
|
||||||
|
if (socket?.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
if (previous is not null)
|
||||||
|
await Say("unsubscribe", previous, cancellationToken);
|
||||||
|
if (subscription is not null)
|
||||||
|
await Say("subscribe", subscription, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subscription is not null && running is null)
|
||||||
|
{
|
||||||
|
running = new();
|
||||||
|
_ = Run(running.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var message = new JsonObject { ["type"] = type, ["stream"] = subscription.Stream };
|
||||||
|
if (subscription.Tag is not null)
|
||||||
|
message["tag"] = subscription.Tag;
|
||||||
|
if (subscription.List is not null)
|
||||||
|
message["list"] = subscription.List;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.SendAsync(Encoding.UTF8.GetBytes(message.ToJsonString()), WebSocketMessageType.Text, true, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// open, subscribe, read until the socket drops, and open again after a pause
|
||||||
|
async Task Run(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pause = TimeSpan.FromSeconds(2);
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Connect(cancellationToken);
|
||||||
|
pause = TimeSpan.FromSeconds(2);
|
||||||
|
await Read(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is WebSocketException or JsonException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
await logger.ProcessWarning(ex.Message, $"{nameof(StreamingService)}.{nameof(Run)}", cancellationToken);
|
||||||
|
}
|
||||||
|
socket?.Dispose();
|
||||||
|
socket = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(pause, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pause = TimeSpan.FromSeconds(Math.Min(60, pause.TotalSeconds * 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Connect(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var token = await authStateProvider.GetPersonaToken(cancellationToken);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
// any persona request exchanges the token first
|
||||||
|
using var _ = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken);
|
||||||
|
token = await authStateProvider.GetPersonaToken(cancellationToken);
|
||||||
|
}
|
||||||
|
if (token is null)
|
||||||
|
throw new InvalidOperationException("no persona token");
|
||||||
|
socket = new ClientWebSocket();
|
||||||
|
socket.Options.AddSubProtocol(token);
|
||||||
|
await socket.ConnectAsync(Endpoint, cancellationToken);
|
||||||
|
if (current is not null)
|
||||||
|
await Say("subscribe", current, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Read(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[64 * 1024];
|
||||||
|
while (socket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
WebSocketReceiveResult received;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
received = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||||
|
if (received.MessageType == WebSocketMessageType.Close)
|
||||||
|
return;
|
||||||
|
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
|
||||||
|
}
|
||||||
|
while (!received.EndOfMessage);
|
||||||
|
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|
||||||
|
|| message["payload"]?.GetValue<string>() is not { } payload)
|
||||||
|
continue;
|
||||||
|
var told = name switch
|
||||||
|
{
|
||||||
|
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
|
||||||
|
"notification" => new StreamMessage(name, Notification: JsonSerializer.Deserialize<Notification>(payload, SUtility.MastodonSerializer)),
|
||||||
|
"delete" => new StreamMessage(name, DeletedId: payload),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (told is not null && Received is { } handlers)
|
||||||
|
await handlers(told);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the socket; the page polls from then on.</summary>
|
||||||
|
public async Task Stop()
|
||||||
|
{
|
||||||
|
current = null;
|
||||||
|
if (running is not null)
|
||||||
|
{
|
||||||
|
await running.CancelAsync();
|
||||||
|
running.Dispose();
|
||||||
|
running = null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (socket?.State == WebSocketState.Open)
|
||||||
|
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (WebSocketException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
socket?.Dispose();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
-12
@@ -12,9 +12,9 @@ using PrivaPub.ClientModels.Post;
|
|||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary>
|
/// <summary>What a feed shows: its kind, and the group, hashtag, list or position some kinds need.</summary>
|
||||||
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
|
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
|
||||||
double? Latitude = default, double? Longitude = default);
|
double? Latitude = default, double? Longitude = default, string ListId = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
|
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
|
||||||
@@ -41,11 +41,13 @@ namespace decePubClient.Services
|
|||||||
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
|
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
|
||||||
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
|
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
|
||||||
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
|
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
|
||||||
|
TimelineType.Lists when query.ListId is { Length: > 0 } listId =>
|
||||||
|
await Statuses(APIs.Mastodon.GET_M_ListTimeline(listId), [], maxId, minId, cancellationToken),
|
||||||
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
|
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
|
||||||
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
||||||
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
||||||
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
|
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
|
||||||
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
|
TimelineType.Direct => await Conversations(maxId, minId, cancellationToken),
|
||||||
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
|
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
|
||||||
_ => new WebResult { Data = new FeedPage() }
|
_ => new WebResult { Data = new FeedPage() }
|
||||||
};
|
};
|
||||||
@@ -76,34 +78,144 @@ namespace decePubClient.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Notifications folded into groups (/api/v2/notifications): likes and boosts of one post, follows of one
|
||||||
|
/// hour, each an item with its count and a few of its accounts. Paged by notification ids from the Link header.</summary>
|
||||||
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
|
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
|
using var response = await httpService.Get(APIs.Mastodon.GET_GroupedNotifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? [];
|
var grouped = await response.MastodonReadFromJsonAsync<GroupedNotifications>(cancellationToken) ?? new GroupedNotifications();
|
||||||
|
var accounts = grouped.Accounts.DistinctBy(a => a.Id).ToDictionary(a => a.Id);
|
||||||
|
var statuses = grouped.Statuses.DistinctBy(s => s.Id).ToDictionary(s => s.Id);
|
||||||
var (nextMaxId, prevMinId) = response.PageCursors();
|
var (nextMaxId, prevMinId) = response.PageCursors();
|
||||||
return new WebResult
|
return new WebResult
|
||||||
{
|
{
|
||||||
Data = new FeedPage
|
Data = new FeedPage
|
||||||
{
|
{
|
||||||
Posts = [.. notifications.Select(FeedPost.Of)],
|
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
|
||||||
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
|
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
|
||||||
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
|
MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
|
/// <summary>How many groups of notifications are unread (newer than the notifications marker).</summary>
|
||||||
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
public async Task<int> UnreadNotifications(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
|
using var response = await httpService.Get(APIs.Mastodon.GET_UnreadNotifications, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<UnreadCount>(cancellationToken))?.Count ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's notification policy; null when it cannot be read.</summary>
|
||||||
|
public async Task<NotificationPolicy> NotificationPolicy(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationPolicy, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationPolicy)}", cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves the persona's notification policy; Data is the policy as PrivaPub keeps it.</summary>
|
||||||
|
public async Task<WebResult> SetNotificationPolicy(NotificationPolicy policy, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Patch(APIs.Mastodon.PATCH_NotificationPolicy, new NotificationPolicy
|
||||||
|
{
|
||||||
|
ForNotFollowing = policy.ForNotFollowing,
|
||||||
|
ForNotFollowers = policy.ForNotFollowers,
|
||||||
|
ForNewAccounts = policy.ForNewAccounts,
|
||||||
|
ForPrivateMentions = policy.ForPrivateMentions,
|
||||||
|
ForLimitedAccounts = policy.ForLimitedAccounts
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The requests gathering the filtered notifications, newest first; [] when they cannot be read.</summary>
|
||||||
|
public async Task<List<NotificationRequest>> NotificationRequests(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationRequests, ["limit=80"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationRequests)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<NotificationRequest>>(cancellationToken) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets a request's notifications in, and its account from then on, or dismisses them.</summary>
|
||||||
|
public async Task<WebResult> AnswerNotificationRequest(string requestId, bool accept, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_NotificationRequest(requestId, accept), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
|
||||||
|
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_Markers, new MarkersForm { Notifications = new MarkerForm { LastReadId = notificationId } },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Direct conversations by their newest message, newest first, paged by that message's id.</summary>
|
||||||
|
async Task<WebResult> Conversations(string maxId, string minId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { $"limit={PageSize}" };
|
||||||
|
if (maxId is not null)
|
||||||
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
||||||
|
if (minId is not null)
|
||||||
|
parameters.Add($"min_id={Uri.EscapeDataString(minId)}");
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [.. parameters], cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
||||||
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
|
var (nextMaxId, nextMinId) = response.PageCursors();
|
||||||
|
return new WebResult
|
||||||
|
{
|
||||||
|
Data = new FeedPage
|
||||||
|
{
|
||||||
|
Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)],
|
||||||
|
MaxId = conversations.Count < PageSize ? null : nextMaxId,
|
||||||
|
MinId = nextMinId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many of the newest conversations have a message the persona has not read.</summary>
|
||||||
|
public async Task<int> UnreadConversations(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, ["limit=40"], cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []).Count(c => c.Unread) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks a conversation read; Data is the conversation.</summary>
|
||||||
|
public async Task<WebResult> ReadConversation(string conversationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_ReadConversation(conversationId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Conversation>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes a conversation off the list until a newer message; its messages stay.</summary>
|
||||||
|
public async Task<WebResult> RemoveConversation(string conversationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Conversation(conversationId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
|
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
|
||||||
|
|||||||
+38
-4
@@ -1,6 +1,11 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="relative flex flex-col md:flex-row w-full h-full">
|
@* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's
|
||||||
|
Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own
|
||||||
|
navigation closes them all and goes on. *@
|
||||||
|
<CascadingValue Value="(IDOverlayHost)Overlays" IsFixed="true">
|
||||||
|
<div class="relative flex flex-col md:flex-row w-full h-full">
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
|
|
||||||
<div class="block flex-1 relative w-full h-full">
|
<div class="block flex-1 relative w-full h-full">
|
||||||
@@ -8,6 +13,35 @@
|
|||||||
@Body
|
@Body
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
<PublicNoticeHost />
|
||||||
|
<ConfirmHost />
|
||||||
|
</CascadingValue>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
[Inject] NavigationManager Navigation { get; set; }
|
||||||
|
[Inject] AppStatusService Status { get; set; }
|
||||||
|
|
||||||
|
IDisposable changing;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
|
||||||
|
|
||||||
|
async ValueTask OnLocationChanging(LocationChangingContext context)
|
||||||
|
{
|
||||||
|
var back = !context.IsNavigationIntercepted && Status.TookBack();
|
||||||
|
if (!Overlays.HasOpen)
|
||||||
|
return;
|
||||||
|
if (back)
|
||||||
|
{
|
||||||
|
context.PreventNavigation();
|
||||||
|
await Overlays.CloseTop(context.CancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Overlays.CloseAll(context.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => changing?.Dispose();
|
||||||
|
}
|
||||||
+104
-19
@@ -1,12 +1,22 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0">
|
@* On a phone the menu opens from the hamburger over the page, and closes after a choice (any navigation, a persona
|
||||||
|
switch), on a tap outside it or on Escape; from md it is the sidebar, which scrolls on its own when the window is
|
||||||
|
short (a phone held sideways). *@
|
||||||
|
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0 md:h-full md:overflow-y-auto md:overscroll-contain"
|
||||||
|
@onkeydown="OnKeyDown">
|
||||||
|
|
||||||
|
@if (isMenuOpen)
|
||||||
|
{
|
||||||
|
<div class="md:hidden fixed inset-0 z-40 bg-black/30" aria-hidden="true" @onclick="CloseMenu"></div>
|
||||||
|
}
|
||||||
<div class="relative md:w-52">
|
<div class="relative md:w-52">
|
||||||
<DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu">
|
<DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu" aria-label="@Localizer["Menu"]" title="@Localizer["Menu"]"
|
||||||
<i class="ion-md-menu text-lg"></i>
|
aria-expanded="@(isMenuOpen ? "true" : "false")" aria-controls="nav-panel" data-nav-toggle>
|
||||||
|
<i class="ion-md-menu text-lg" aria-hidden="true"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
<div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block">
|
<div id="nav-panel" data-nav-panel
|
||||||
|
class="@(isMenuOpen ? "block" : "hidden") md:block absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-[min(18rem,calc(100vw-1.5rem))] md:w-full max-h-[calc(100dvh-5rem)] md:max-h-none overflow-y-auto overscroll-contain rounded-lg neo-raised neo-sm md:shadow-none! z-50">
|
||||||
<div class="flex flex-col gap-3 p-3 md:p-4">
|
<div class="flex flex-col gap-3 p-3 md:p-4">
|
||||||
|
|
||||||
@if (CascadingState.IsSignedIn)
|
@if (CascadingState.IsSignedIn)
|
||||||
@@ -41,11 +51,37 @@
|
|||||||
<i class="ion-md-home"></i>
|
<i class="ion-md-home"></i>
|
||||||
<span>@Localizer["Home"]</span>
|
<span>@Localizer["Home"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="search">
|
||||||
|
<i class="ion-md-search"></i>
|
||||||
|
<span>@Localizer["Search"]</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="explore">
|
||||||
|
<i class="ion-md-compass"></i>
|
||||||
|
<span>@Localizer["Explore"]</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="lists">
|
||||||
|
<i class="ion-md-list"></i>
|
||||||
|
<span>@Localizer["Lists"]</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="groups">
|
||||||
|
<i class="ion-md-people"></i>
|
||||||
|
<span>@Localizer["Groups"]</span>
|
||||||
|
</NavLink>
|
||||||
|
@if (CascadingState.PersonaId is { } personaId)
|
||||||
|
{
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
|
||||||
|
<i class="ion-md-person"></i>
|
||||||
|
<span>@Localizer["My profile"]</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="settings">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="settings">
|
||||||
<i class="ion-md-settings"></i>
|
<i class="ion-md-settings"></i>
|
||||||
<span>@Localizer["Settings"]</span>
|
<span>@Localizer["Settings"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<AuthorizeView Policy="@Policies.IsAdmin">
|
<AuthorizeView Policy="@Policies.IsModerator">
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
||||||
<i class="ion-md-switch text-error"></i>
|
<i class="ion-md-switch text-error"></i>
|
||||||
<span>@Localizer["Administration"]</span>
|
<span>@Localizer["Administration"]</span>
|
||||||
@@ -53,10 +89,10 @@
|
|||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@if (CascadingState.IsSignedIn)
|
@if (CascadingState.IsSignedIn)
|
||||||
{
|
{
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout">
|
<DButton class="justify-start" OnClick="Logout" data-logout>
|
||||||
<i class="ion-md-log-out text-error"></i>
|
<i class="ion-md-log-out text-error"></i>
|
||||||
<span>@Localizer["Logout"]</span>
|
<span>@Localizer["Logout"]</span>
|
||||||
</NavLink>
|
</DButton>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -76,7 +112,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark">
|
<DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark" class="touch-target"
|
||||||
|
aria-label="@Localizer["Dark mode"]" title="@Localizer["Dark mode"]">
|
||||||
<UncheckedIcon>
|
<UncheckedIcon>
|
||||||
<i class="ion-md-sunny"></i>
|
<i class="ion-md-sunny"></i>
|
||||||
</UncheckedIcon>
|
</UncheckedIcon>
|
||||||
@@ -90,21 +127,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:hidden">
|
<a class="md:hidden grid place-items-center h-11 w-11" href="" aria-label="@Localizer["Home"]" title="@Localizer["Home"]">
|
||||||
<img alt="" class="h-[30px]" src="imgs/icon-512.png">
|
<img alt="" class="h-[30px]" src="imgs/icon-512.png">
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<div class="md:hidden">
|
<a class="md:hidden grid place-items-center h-11 w-11" href="settings" aria-label="@Localizer["Settings"]" title="@Localizer["Settings"]">
|
||||||
<a class="block rounded-full neo-raised neo-2xs" href="settings">
|
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full neo-raised neo-2xs"
|
||||||
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full"
|
|
||||||
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
|
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
string menuToggle = "hidden";
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
[Inject] OverlayService Overlays { get; set; }
|
||||||
|
|
||||||
|
bool isMenuOpen;
|
||||||
|
IDisposable overlay;
|
||||||
|
|
||||||
|
// open, the Back button closes it
|
||||||
|
void SetMenu(bool open)
|
||||||
|
{
|
||||||
|
isMenuOpen = open;
|
||||||
|
if (open && overlay is null)
|
||||||
|
overlay = Overlays.Open(() => InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetMenu(false);
|
||||||
|
StateHasChanged();
|
||||||
|
}));
|
||||||
|
else if (!open && overlay is not null)
|
||||||
|
{
|
||||||
|
overlay.Dispose();
|
||||||
|
overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
bool isPersonaMenuOpen;
|
bool isPersonaMenuOpen;
|
||||||
|
|
||||||
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
|
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
|
||||||
@@ -118,20 +175,48 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
if (!CascadingState.Status.IsMobileMedia())
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
menuToggle = default;
|
}
|
||||||
|
|
||||||
|
void OnLocationChanged(object sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!isMenuOpen)
|
||||||
|
return;
|
||||||
|
SetMenu(false);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseMenu() => SetMenu(false);
|
||||||
|
|
||||||
|
void OnKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
SetMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
overlay?.Dispose();
|
||||||
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task SwitchPersona(string personaId)
|
async Task SwitchPersona(string personaId)
|
||||||
{
|
{
|
||||||
|
SetMenu(false);
|
||||||
var result = await CascadingState.SwitchPersona(personaId);
|
var result = await CascadingState.SwitchPersona(personaId);
|
||||||
if (result.IsInvalid)
|
if (result.IsInvalid)
|
||||||
Toast.AddResponseError(result);
|
Toast.AddResponseError(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleNavMenu()
|
private void ToggleNavMenu() => SetMenu(!isMenuOpen);
|
||||||
|
|
||||||
|
async Task Logout()
|
||||||
{
|
{
|
||||||
menuToggle = menuToggle is { Length: > 0 } ? default : "hidden";
|
SetMenu(false);
|
||||||
|
if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true))
|
||||||
|
return;
|
||||||
|
Navigation.NavigateTo("logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task ResetToOriginalColour()
|
protected async Task ResetToOriginalColour()
|
||||||
|
|||||||
+3
-1
@@ -1,11 +1,12 @@
|
|||||||
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
||||||
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
||||||
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
|
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
|
||||||
@import "tailwindcss" source("../");
|
@import "tailwindcss" source("../");
|
||||||
|
|
||||||
@source not "../wwwroot/vendor";
|
@source not "../wwwroot/vendor";
|
||||||
@source not "../Docs";
|
@source not "../Docs";
|
||||||
@source not "../tools";
|
@source not "../tools";
|
||||||
|
@source not "../tests";
|
||||||
|
|
||||||
@plugin "../tools/tailwind/daisyui.mjs" {
|
@plugin "../tools/tailwind/daisyui.mjs" {
|
||||||
themes: false;
|
themes: false;
|
||||||
@@ -14,5 +15,6 @@
|
|||||||
|
|
||||||
@import "./theme.css";
|
@import "./theme.css";
|
||||||
@import "./neo.css";
|
@import "./neo.css";
|
||||||
|
@import "./touch.css";
|
||||||
@import "./base.css";
|
@import "./base.css";
|
||||||
@import "./feed.css";
|
@import "./feed.css";
|
||||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user