Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,62 @@ 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/`.
|
||||||
|
- **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 +142,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);
|
||||||
@@ -98,6 +154,57 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
- 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 and selects 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 +260,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,9 +270,9 @@ 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.
|
||||||
- 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.
|
||||||
@@ -183,8 +291,87 @@ 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`
|
||||||
|
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
|
||||||
|
link, participants and status.
|
||||||
|
- **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`: its words and
|
||||||
|
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
||||||
|
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, fields, locked, bot,
|
||||||
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||||
|
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
|
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
|
||||||
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||||
|
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||||||
|
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||||
|
- **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 +382,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,32 @@ 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))]
|
||||||
public partial class ClientJsonContext : JsonSerializerContext
|
public partial class ClientJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+262
-18
@@ -1,8 +1,12 @@
|
|||||||
|
@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
|
||||||
|
and warning change, its media, visibility and poll stay). *@
|
||||||
<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,12 +17,69 @@
|
|||||||
<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)
|
||||||
{
|
{
|
||||||
@@ -69,29 +130,42 @@
|
|||||||
|
|
||||||
<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 && Editing 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"]">
|
||||||
@@ -115,12 +189,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 +216,27 @@
|
|||||||
{
|
{
|
||||||
<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] 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,16 +244,77 @@
|
|||||||
[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
|
long MaximumFileSize => form.MediaType switch
|
||||||
@@ -170,10 +332,44 @@
|
|||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override void OnInitialized()
|
// 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.QuotedStatusId = Quoting?.Id;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
IsLoading = false;
|
||||||
if (InReplyTo is null)
|
if (InReplyTo is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -185,6 +381,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 +396,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;
|
||||||
@@ -269,12 +475,42 @@
|
|||||||
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 (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 +520,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;
|
||||||
|
}
|
||||||
+881
-205
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)
|
@if (Heading is not null || ShowClose)
|
||||||
{
|
{
|
||||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||||
|
@if (Heading is not null)
|
||||||
|
{
|
||||||
|
<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
|
||||||
|
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
|
||||||
|
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
|
||||||
|
brings the feed back. *@
|
||||||
|
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||||||
|
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
|
||||||
|
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
|
||||||
|
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
|
||||||
|
<dl id="@($"{uid}-sun")" class="sr-only">
|
||||||
|
@if (sun is not null)
|
||||||
|
{
|
||||||
|
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||||
|
{
|
||||||
|
<dt>@row.Label</dt>
|
||||||
|
<dd>@row.Value</dd>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
|
||||||
|
|
||||||
|
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||||
|
@if (sun is not null)
|
||||||
|
{
|
||||||
|
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
|
||||||
|
data-globe-moment>
|
||||||
|
<i class="@SunIcon(sun)" aria-hidden="true"></i>
|
||||||
|
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||||
|
</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
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
|
||||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
|
||||||
<span class="badge badge-ghost badge-xs gap-1">
|
|
||||||
<i class="ion-md-planet"></i>
|
|
||||||
@if (stats is { Error: null } live)
|
|
||||||
{
|
|
||||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
|
|
||||||
}
|
|
||||||
else if (stats?.Error is { Length: > 0 })
|
|
||||||
{
|
|
||||||
<span>@Localizer["The globe could not start."]</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>@Localizer["loading"]…</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</NerdTip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -34,29 +64,103 @@
|
|||||||
/// <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). *@
|
||||||
@ChildContent
|
@if (TapOpens)
|
||||||
</DTooltip>
|
{
|
||||||
|
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
|
||||||
|
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
|
||||||
|
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
|
||||||
|
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
|
||||||
|
@ChildContent
|
||||||
|
</DTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||||
|
@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,150 @@
|
|||||||
|
@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 bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
|
exporting = null;
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "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,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,210 @@
|
|||||||
|
@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?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||||
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
|
||||||
|
alt="@Localizer["Avatar"]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Avatar"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Header"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
|
</label>
|
||||||
|
</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] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
// PrivaPub keeps pictures up to 20 MB and resizes them
|
||||||
|
const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||||
|
|
||||||
|
Account account;
|
||||||
|
ProfileForm form = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
List<string> pictureErrors = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
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 (file.Size > MaximumPictureSize)
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
|
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
|
||||||
|
await fileStream.CopyToAsync(memoryStream);
|
||||||
|
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||||
|
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
|
||||||
|
if (isAvatar)
|
||||||
|
form.Avatar = picture;
|
||||||
|
else
|
||||||
|
form.Header = picture;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
|
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +17,9 @@ 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(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
[JsonSerializable(typeof(List<Notification>))]
|
[JsonSerializable(typeof(List<Notification>))]
|
||||||
@@ -31,6 +34,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,32 @@ 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 static class OAuth
|
public static class OAuth
|
||||||
@@ -36,6 +56,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 +69,66 @@ 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 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 +140,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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,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,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,107 @@ 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 words and their warning; the media stay as they are.</summary>
|
||||||
|
public class StatusEditForm
|
||||||
|
{
|
||||||
|
public string Status { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
static ByteArrayContent Picture(UploadMedia media)
|
||||||
|
{
|
||||||
|
var picture = new ByteArrayContent(media.Blob);
|
||||||
|
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
@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 and read the federation statistics. 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 +29,44 @@
|
|||||||
<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>
|
||||||
|
</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">
|
||||||
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||||
|
</PhoneSheet>
|
||||||
|
|
||||||
|
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||||
{
|
{
|
||||||
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
||||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
<i class="ion-md-notifications-off"></i>
|
||||||
</div>
|
<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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-9
@@ -46,6 +46,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 +108,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 +128,34 @@
|
|||||||
</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>
|
<DataTransfer />
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
</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 +168,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>
|
||||||
|
|||||||
+11
@@ -40,7 +40,18 @@ 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<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>()
|
||||||
|
|||||||
+1231
-13
File diff suppressed because it is too large.
Load diff
+1231
-13
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,298 @@
|
|||||||
|
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>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,104 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,15 @@ 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");
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,279 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
/// <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>
|
||||||
|
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var rows = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await Follows(personaId, cancellationToken),
|
||||||
|
PortableData.Lists => await Lists(cancellationToken),
|
||||||
|
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
||||||
|
PortableData.Mutes => await Mutes(cancellationToken),
|
||||||
|
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
||||||
|
_ => await Bookmarks(personaId, cancellationToken)
|
||||||
|
};
|
||||||
|
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), 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(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var muted = await Pages(accounts.Mutes, 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, 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)
|
||||||
|
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
|
||||||
|
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, 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)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
-3
@@ -21,7 +21,9 @@ namespace decePubClient.Services
|
|||||||
Reblog,
|
Reblog,
|
||||||
Unreblog,
|
Unreblog,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Unbookmark
|
Unbookmark,
|
||||||
|
Pin,
|
||||||
|
Unpin
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,13 +56,102 @@ namespace decePubClient.Services
|
|||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.Visibility,
|
||||||
|
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);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
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);
|
||||||
|
return new WebResult { Data = FeedPost.Of(status) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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: new words and warning, the media kept; Data is the edited Status.</summary>
|
||||||
|
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
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,
|
||||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
||||||
}, 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);
|
||||||
|
|
||||||
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Status>(cancellationToken) };
|
||||||
return new WebResult { Data = FeedPost.Of(status) };
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
@@ -100,6 +191,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 +228,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>
|
||||||
|
|||||||
+42
-8
@@ -1,13 +1,47 @@
|
|||||||
@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
|
||||||
<NavMenu />
|
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 />
|
||||||
|
|
||||||
<div class="block flex-1 relative w-full h-full">
|
<div class="block flex-1 relative w-full h-full">
|
||||||
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
|
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
|
||||||
@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();
|
||||||
|
}
|
||||||
+106
-21
@@ -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";
|
||||||
@@ -5,6 +5,12 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The page never scrolls itself (the feed and the pages scroll inside), so a pull past its top would only make a phone
|
||||||
|
reload the app. Only vertically: a sideways overscroll is the trackpad's back gesture. */
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
details > summary::-webkit-details-marker {
|
details > summary::-webkit-details-marker {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-3
@@ -1,6 +1,6 @@
|
|||||||
/* The feed: post HTML as PrivaPub's Mastodon API sends it (already sanitized; Mastodon's link markup hides the
|
/* The feed: post HTML as PrivaPub's Mastodon API sends it (already sanitized; Mastodon's link markup hides the
|
||||||
scheme in .invisible and shortens long links with .ellipsis), and the globe's point labels, which replace the
|
scheme in .invisible and shortens long links with .ellipsis), the globe's point labels, which replace the look of
|
||||||
look of globe.gl's own tooltip box. */
|
globe.gl's own tooltip box, its CDN groups and country names, and the card it is pinned to. */
|
||||||
@layer components {
|
@layer components {
|
||||||
.post-content p {
|
.post-content p {
|
||||||
margin-block: 0 0.5em;
|
margin-block: 0 0.5em;
|
||||||
@@ -25,6 +25,16 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a custom emoji (CustomEmojis): the height of the text it sits in */
|
||||||
|
.custom-emoji {
|
||||||
|
display: inline;
|
||||||
|
height: 1.3em;
|
||||||
|
width: auto;
|
||||||
|
max-width: 3em;
|
||||||
|
vertical-align: -0.25em;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.post-content .ellipsis::after {
|
.post-content .ellipsis::after {
|
||||||
content: "…";
|
content: "…";
|
||||||
}
|
}
|
||||||
@@ -52,13 +62,25 @@
|
|||||||
padding-inline-start: 1.25rem;
|
padding-inline-start: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. */
|
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI
|
||||||
|
only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen
|
||||||
|
it never shows: a tap opens the rows in a sheet instead (NerdTip). */
|
||||||
.tooltip-content:has(> .nerd-stats) {
|
.tooltip-content:has(> .nerd-stats) {
|
||||||
border-radius: var(--radius-box);
|
border-radius: var(--radius-box);
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
max-width: min(22rem, 90vw);
|
max-width: min(22rem, 90vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip:not(:hover, :focus-within, .tooltip-open) > .tooltip-content:has(> .nerd-stats) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tooltip-content:has(> .nerd-stats) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.float-tooltip-kap {
|
.float-tooltip-kap {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -88,4 +110,155 @@
|
|||||||
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A country's name on the globe, on the land by day and by night alike: light text in a dark halo. The globe sets
|
||||||
|
its size and opacity by zoom, and hides it through visibility (the label renderer owns display). */
|
||||||
|
.globe-country {
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: rgb(255 255 255 / 0.9);
|
||||||
|
text-shadow: 0 0 2px rgb(0 0 0 / 0.95), 0 0 6px rgb(0 0 0 / 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The card the globe is pinned to (a click on it): its moment and place stay on the globe. */
|
||||||
|
[data-globe-pinned] {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The globe's instruments (wwwroot/js/instruments.js): an overlay of chips beside what is drawn in the scene, the
|
||||||
|
frame-rate ring around the globe and a two-line legend. It never takes the pointer. Each chip's colour is its
|
||||||
|
instrument's: the Sun's warning, the twilights' info, the place's accent, the view's primary. */
|
||||||
|
.globe-instruments {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: layout paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag {
|
||||||
|
--tag: var(--color-base-content);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
font: 10px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
background: color-mix(in oklab, var(--color-base-100) 78%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--tag) 70%, transparent);
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-sun,
|
||||||
|
.globe-tag-sun-faint {
|
||||||
|
--tag: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-twilight {
|
||||||
|
--tag: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a place's chip: its place, phase and solar time over its Sun's height and its day */
|
||||||
|
.globe-tag-place {
|
||||||
|
--tag: var(--color-accent);
|
||||||
|
white-space: pre;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-server {
|
||||||
|
--tag: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-white {
|
||||||
|
--tag: rgb(255 255 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-orrery {
|
||||||
|
--tag: var(--color-base-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-sun-faint,
|
||||||
|
.globe-tag-orrery-faint {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-ring {
|
||||||
|
--tag: var(--color-base-content);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a place's chip says the phase of its day */
|
||||||
|
.globe-tag-place[data-phase="day"] {
|
||||||
|
--tag: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-tag-place[data-phase="night"],
|
||||||
|
.globe-tag-place[data-phase="astronomical"] {
|
||||||
|
--tag: var(--color-neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring-value {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-success);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dasharray 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring[data-pace="fair"] .globe-ring-value {
|
||||||
|
stroke: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring[data-pace="poor"] .globe-ring-value {
|
||||||
|
stroke: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-ring[data-pace="idle"] .globe-ring-track,
|
||||||
|
.globe-ring[data-pace="paused"] .globe-ring-track {
|
||||||
|
stroke-dasharray: 2 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.globe-ring-value {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
font: 9px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
opacity: 0.65;
|
||||||
|
text-shadow: 0 0 3px var(--color-base-100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+40
-4
@@ -14,6 +14,11 @@
|
|||||||
each one excludes the variants it must leave alone. :where() keeps those exclusions at specificity (0,1,0).
|
each one excludes the variants it must leave alone. :where() keeps those exclusions at specificity (0,1,0).
|
||||||
To drop a neo shadow at a call site use shadow-none! or neo-flat!.
|
To drop a neo shadow at a call site use shadow-none! or neo-flat!.
|
||||||
|
|
||||||
|
A gradient on a control is painted from its border box (background-origin: border-box). daisyUI keeps a 1px border
|
||||||
|
on its controls, made transparent here, and a background image starts at the padding box and repeats into the
|
||||||
|
border: the strip then showed the far end of the gradient, a thin seam along the edges, as if the control sat in a
|
||||||
|
second frame.
|
||||||
|
|
||||||
Never write @utility collapse: it brings back Tailwind's own collapse utility (visibility: collapse), which daisyUI
|
Never write @utility collapse: it brings back Tailwind's own collapse utility (visibility: collapse), which daisyUI
|
||||||
otherwise suppresses, and hides every collapse. DCollapse adds neo-raised in its markup instead. */
|
otherwise suppresses, and hides every collapse. DCollapse adds neo-raised in its markup instead. */
|
||||||
|
|
||||||
@@ -64,10 +69,12 @@
|
|||||||
|
|
||||||
@utility neo-gradient {
|
@utility neo-gradient {
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-light), var(--neo-grad-dark));
|
background-image: linear-gradient(145deg, var(--neo-grad-light), var(--neo-grad-dark));
|
||||||
|
background-origin: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility neo-gradient-pressed {
|
@utility neo-gradient-pressed {
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility neo-hue-track {
|
@utility neo-hue-track {
|
||||||
@@ -92,6 +99,7 @@
|
|||||||
--btn-bg: var(--btn-color, var(--color-base-100));
|
--btn-bg: var(--btn-color, var(--color-base-100));
|
||||||
--btn-border: transparent;
|
--btn-border: transparent;
|
||||||
background-image: linear-gradient(145deg, color-mix(in oklab, var(--btn-bg), white var(--neo-lift)), color-mix(in oklab, var(--btn-bg), black var(--neo-sink)));
|
background-image: linear-gradient(145deg, color-mix(in oklab, var(--btn-bg), white var(--neo-lift)), color-mix(in oklab, var(--btn-bg), black var(--neo-sink)));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
||||||
|
|
||||||
&:where(:focus-visible) {
|
&:where(:focus-visible) {
|
||||||
@@ -111,11 +119,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Text fields: a pressed well, darker while focused, with no focus ring and no border unless a colour or a failed
|
/* Text fields: a pressed well, darker while focused, with no focus ring and no border unless a colour or a failed
|
||||||
validation asks for one. */
|
validation asks for one.
|
||||||
|
|
||||||
|
Fields (input, select, file-input, textarea) take the boxes' radius (--radius-box, .75rem). daisyUI gives them
|
||||||
|
--radius-field (2rem), which stays the buttons': a pill on a one-line field, and on a textarea a curve that ate into
|
||||||
|
its first and last lines. daisyUI reads the variable, so a rounded-* class at the call site (the composer's) still
|
||||||
|
wins. */
|
||||||
@utility input {
|
@utility input {
|
||||||
|
--radius-field: var(--radius-box);
|
||||||
|
|
||||||
&:not(:where(.input-ghost)) {
|
&:not(:where(.input-ghost)) {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
|
|
||||||
&:focus, &:focus-within {
|
&:focus, &:focus-within {
|
||||||
@@ -132,6 +148,8 @@
|
|||||||
|
|
||||||
/* daisyUI draws the select's arrow with background-image, so the well here is the background colour alone. */
|
/* daisyUI draws the select's arrow with background-image, so the well here is the background colour alone. */
|
||||||
@utility select {
|
@utility select {
|
||||||
|
--radius-field: var(--radius-box);
|
||||||
|
|
||||||
&:not(:where(.select-ghost)) {
|
&:not(:where(.select-ghost)) {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
@@ -160,9 +178,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility textarea {
|
@utility textarea {
|
||||||
|
--radius-field: var(--radius-box);
|
||||||
|
|
||||||
&:not(:where(.textarea-ghost)) {
|
&:not(:where(.textarea-ghost)) {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
|
|
||||||
&:focus, &:focus-within {
|
&:focus, &:focus-within {
|
||||||
@@ -178,9 +199,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility file-input {
|
@utility file-input {
|
||||||
|
--radius-field: var(--radius-box);
|
||||||
|
|
||||||
&:not(:where(.file-input-ghost)) {
|
&:not(:where(.file-input-ghost)) {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +218,7 @@
|
|||||||
&:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) {
|
&:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +227,7 @@
|
|||||||
&:not(:where(:checked, [aria-checked="true"], :disabled)) {
|
&:not(:where(:checked, [aria-checked="true"], :disabled)) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +236,7 @@
|
|||||||
&:not(:where(:disabled)) {
|
&:not(:where(:disabled)) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -235,18 +262,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* At most the screen's height as the phone shows it (dvh: its browser bars come and go), and a bottom sheet leaves room
|
||||||
|
above it and clears the home indicator. */
|
||||||
@utility modal-box {
|
@utility modal-box {
|
||||||
box-shadow: -6px -6px 12px var(--neo-light), 6px 6px 12px var(--neo-dark);
|
box-shadow: -6px -6px 12px var(--neo-light), 6px 6px 12px var(--neo-dark);
|
||||||
|
max-height: 100dvh;
|
||||||
|
|
||||||
|
:where(.modal-bottom) > & {
|
||||||
|
max-height: calc(100dvh - 3rem);
|
||||||
|
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility dropdown-content {
|
@utility dropdown-content {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
||||||
|
|
||||||
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stays
|
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stayed
|
||||||
invisible (opacity 0) but still in the way of the pointer, over whatever it covers: hide it from the pointer too. */
|
invisible (opacity 0) but in the way of the pointer, and still took room: a closed menu past a card's edge made
|
||||||
|
the feed scroll sideways on a phone. A closed one is not displayed at all. */
|
||||||
:where(.dropdown.dropdown-close, .dropdown:not(.dropdown-open, .dropdown-hover:hover, :focus-within)) & {
|
:where(.dropdown.dropdown-close, .dropdown:not(.dropdown-open, .dropdown-hover:hover, :focus-within)) & {
|
||||||
visibility: hidden;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/* Touch screens.
|
||||||
|
|
||||||
|
A phone's layout uses the width variants (max-md:, md:) at the call site; how big a thing is to a finger uses the
|
||||||
|
pointer: the sizes here sit under @media (pointer: coarse), so a mouse matches none of them and desktop keeps
|
||||||
|
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
||||||
|
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
||||||
|
|
||||||
|
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
|
||||||
|
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
||||||
|
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
||||||
|
areas would overlap. */
|
||||||
|
/* A small text link or button (a name, "edited") keeps its size to the eye and gets a 44px hit area, a transparent box
|
||||||
|
centred on it, on touch only. Not for one that truncates its text: its overflow clips the box, so such a link takes
|
||||||
|
padding instead (pointer-coarse:py-3.5). */
|
||||||
|
@utility touch-target {
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: max(100%, 2.75rem);
|
||||||
|
height: max(100%, 2.75rem);
|
||||||
|
translate: -50% -50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A short window: a phone held sideways. The globe's banner would leave the feed no room there. */
|
||||||
|
@custom-variant short (@media (max-height: 30rem));
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* At every width: a checkbox's, radio's or toggle's label wraps (daisyUI's .label never does, and a long one ran past
|
||||||
|
a phone's edge), and the control keeps its size beside it. */
|
||||||
|
.label:has(> input[type=checkbox], > input[type=radio], > .toggle) {
|
||||||
|
white-space: normal;
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
/* Field text at 16px: iOS zooms into a focused field whose text is smaller, now that zooming is allowed. */
|
||||||
|
:where(.input, .select, .textarea) {
|
||||||
|
--font-size-min: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(.btn-xs, .btn-lg, .btn-xl) {
|
||||||
|
--size: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
--fontsize: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (the ::after is placed against the padding box, 34px inside a 1px border: 5px around it makes 44) */
|
||||||
|
.btn-xs,
|
||||||
|
:is(button, a).badge {
|
||||||
|
--size: 2.25rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
--fontsize: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(button, a).badge {
|
||||||
|
padding-inline: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
--tab-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:not(.input-lg, .input-xl),
|
||||||
|
.select:not(.select-lg, .select-xl) {
|
||||||
|
--size: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox,
|
||||||
|
.radio {
|
||||||
|
--size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
--size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
--range-thumb-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-title,
|
||||||
|
.label:has(> input[type=checkbox], > input[type=radio], > .toggle) {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A sideways strip fades at the edge where more of it waits: the right edge at first, both while scrolled, the left
|
||||||
|
at the end (a scroll-driven animation where the browser has one; without, the right edge only). */
|
||||||
|
@property --fade-start {
|
||||||
|
syntax: "<length>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --fade-end {
|
||||||
|
syntax: "<length>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes edge-fade-x {
|
||||||
|
from {
|
||||||
|
--fade-start: 0px;
|
||||||
|
--fade-end: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
8% {
|
||||||
|
--fade-start: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
92% {
|
||||||
|
--fade-end: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
--fade-start: 1.5rem;
|
||||||
|
--fade-end: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility edge-fade-x {
|
||||||
|
mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
|
||||||
|
|
||||||
|
@supports (animation-timeline: scroll()) {
|
||||||
|
animation: edge-fade-x linear both;
|
||||||
|
animation-timeline: scroll(self inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
@using decePubClient.Components
|
@using decePubClient.Components
|
||||||
@using decePubClient.Components.Feed
|
@using decePubClient.Components.Feed
|
||||||
@using decePubClient.Components.Nerd
|
@using decePubClient.Components.Nerd
|
||||||
|
@using decePubClient.Components.Settings
|
||||||
|
@using decePubClient.Components.Admin
|
||||||
@using decePubClient.Components.Daisy
|
@using decePubClient.Components.Daisy
|
||||||
@using decePubClient.Components.Daisy.Actions
|
@using decePubClient.Components.Daisy.Actions
|
||||||
@using decePubClient.Components.Daisy.DataDisplay
|
@using decePubClient.Components.Daisy.DataDisplay
|
||||||
@@ -39,5 +41,7 @@
|
|||||||
@using decePubClient.Components.Daisy.Navigation
|
@using decePubClient.Components.Daisy.Navigation
|
||||||
@using decePubClient.LayerComponents
|
@using decePubClient.LayerComponents
|
||||||
@using PrivaPub.ClientModels
|
@using PrivaPub.ClientModels
|
||||||
|
@using PrivaPub.ClientModels.Admin
|
||||||
@using PrivaPub.ClientModels.Group
|
@using PrivaPub.ClientModels.Group
|
||||||
|
@using PrivaPub.ClientModels.User
|
||||||
@using PrivaPub.ClientModels.User.Avatar
|
@using PrivaPub.ClientModels.User.Avatar
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||||
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
||||||
|
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
|
||||||
|
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**" />
|
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**;tests/**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
||||||
@@ -67,6 +69,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
||||||
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
|
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||||
|
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture);
|
||||||
|
any other publish leaves it out, and the deploy checks that it did. -->
|
||||||
|
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,24 +7,66 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "e2e", "e2e", "{0EB607AC-6329-6575-AD8B-E5558B048B88}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "decePubClient.E2E", "tests\e2e\decePubClient.E2E.csproj", "{DB238929-3C3E-4841-B782-3BF0CC53B20E}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.Build.0 = Release|Any CPU
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EBE69805-3005-49C2-81E4-A314A19F68B6}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.Build.0 = Release|Any CPU
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88}
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user