Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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; }
|
||||
done
|
||||
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)"
|
||||
|
||||
- name: Snapshot the live directory
|
||||
|
||||
@@ -367,3 +367,7 @@ FodyWeavers.xsd
|
||||
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
|
||||
.tools/
|
||||
wwwroot/css/app.css
|
||||
|
||||
# the e2e tests: the published app they serve and the browser state they keep
|
||||
tests/e2e/.publish/
|
||||
tests/e2e/.state/
|
||||
@@ -43,7 +43,36 @@ dotnet run # dev server: https://loc
|
||||
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`.
|
||||
- **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
|
||||
|
||||
@@ -87,6 +116,7 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
||||
- picking a hue leaves grey.
|
||||
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
||||
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
||||
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
|
||||
- `Styles/neo.css` holds the neumorphic layer:
|
||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||
old `neomorph is-nxsmall` classes);
|
||||
@@ -162,9 +192,9 @@ supplies:
|
||||
- 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
|
||||
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` /
|
||||
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub
|
||||
redirects it).
|
||||
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||
PrivaPub redirects it).
|
||||
- `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
|
||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||
@@ -183,8 +213,87 @@ supplies:
|
||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||
threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads),
|
||||
`GroupService`, `InstanceService` and `ProvenanceService`.
|
||||
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||||
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||||
`/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||
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
|
||||
`PrivaPub.ClientModels`.
|
||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||
@@ -195,19 +304,43 @@ supplies:
|
||||
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.
|
||||
- **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
|
||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
||||
`GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll,
|
||||
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
|
||||
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. 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`.
|
||||
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||
time; `wake()` before anything that animates).
|
||||
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||
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, the phase and the season
|
||||
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||||
- **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
|
||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera,
|
||||
points and frame rate.
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||
request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||||
the place's elevation and day length, the next equinox or solstice).
|
||||
|
||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using decePubClient.Models;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.User;
|
||||
@@ -22,6 +23,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(PageSettings))]
|
||||
[JsonSerializable(typeof(AuthData))]
|
||||
[JsonSerializable(typeof(FeedFilter))]
|
||||
[JsonSerializable(typeof(PublicActionNotices))]
|
||||
[JsonSerializable(typeof(ServerInfo))]
|
||||
[JsonSerializable(typeof(List<ServerInfo>))]
|
||||
[JsonSerializable(typeof(UploadMedia))]
|
||||
@@ -30,14 +32,31 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ValidationErrors))]
|
||||
[JsonSerializable(typeof(WebResult))]
|
||||
[JsonSerializable(typeof(LoginForm))]
|
||||
[JsonSerializable(typeof(UserPasswordForm))]
|
||||
[JsonSerializable(typeof(RemoveSelfForm))]
|
||||
[JsonSerializable(typeof(JwtUser))]
|
||||
[JsonSerializable(typeof(ViewAvatar))]
|
||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||
[JsonSerializable(typeof(InsertAvatarForm))]
|
||||
[JsonSerializable(typeof(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(List<ViewPost>))]
|
||||
[JsonSerializable(typeof(InsertPostForm))]
|
||||
[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
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
+239
-11
@@ -1,8 +1,12 @@
|
||||
@using System.Text.RegularExpressions
|
||||
@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
|
||||
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">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@@ -14,11 +18,67 @@
|
||||
</DInput>
|
||||
}
|
||||
<div class="relative">
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||
placeholder="@(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>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@* 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" 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)
|
||||
{
|
||||
@@ -69,7 +129,7 @@
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between 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")"
|
||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
@@ -87,11 +147,22 @@
|
||||
</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"]">
|
||||
<i class="ion-md-warning text-base"></i>
|
||||
</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. *@
|
||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||
@@ -115,12 +186,26 @@
|
||||
</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"]">
|
||||
<i class="ion-md-locate text-base"></i>
|
||||
</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>
|
||||
|
||||
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
||||
@@ -128,14 +213,27 @@
|
||||
{
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
||||
<i class="ion-md-send"></i>
|
||||
<span>@SubmitLabel</span>
|
||||
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
|
||||
</DButton>
|
||||
</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>
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
|
||||
@@ -143,16 +241,76 @@
|
||||
[Parameter] public Status InReplyTo { get; set; }
|
||||
/// <summary>The community or circle the post is for.</summary>
|
||||
[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>
|
||||
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
|
||||
|
||||
ComposeForm form = new();
|
||||
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;
|
||||
bool isPublishing;
|
||||
|
||||
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 + " ";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
long MaximumFileSize => form.MediaType switch
|
||||
@@ -170,10 +328,32 @@
|
||||
_ => 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
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IsLoading = false;
|
||||
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)
|
||||
return;
|
||||
|
||||
@@ -185,6 +365,8 @@
|
||||
"unlisted" => MessageType.Unlisted,
|
||||
_ => 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.SpoilerText = InReplyTo.SpoilerText;
|
||||
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
|
||||
@@ -198,6 +380,14 @@
|
||||
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()
|
||||
{
|
||||
form.IsLocated = !form.IsLocated;
|
||||
@@ -269,12 +459,42 @@
|
||||
error = Localizer["Missing content, either message or media"];
|
||||
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)
|
||||
{
|
||||
error = Localizer["Choose a persona first."];
|
||||
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;
|
||||
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||||
isPublishing = false;
|
||||
@@ -284,9 +504,17 @@
|
||||
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."]);
|
||||
var published = result.Data as FeedPost;
|
||||
form = new() { GroupId = GroupId };
|
||||
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
||||
await OnPublished.InvokeAsync(published);
|
||||
}
|
||||
|
||||
|
||||
+780
-198
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,8 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* 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. *@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
@@ -13,6 +14,14 @@
|
||||
<Header>
|
||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||
<span class="@(type == Filter.TimelineType ? null : "hidden 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>
|
||||
</DTab>
|
||||
}
|
||||
@@ -49,15 +58,52 @@
|
||||
</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)
|
||||
{
|
||||
<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>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public FeedFilter Filter { 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 bool IsComposing { get; set; }
|
||||
[Parameter] public EventCallback OnCompose { get; set; }
|
||||
@@ -67,7 +113,7 @@
|
||||
static readonly TimelineType[] Always =
|
||||
[
|
||||
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];
|
||||
@@ -76,8 +122,39 @@
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
{
|
||||
TimelineType.Federation => Localizer["Federated"],
|
||||
@@ -90,6 +167,14 @@
|
||||
Filter.TimelineType = Types[index];
|
||||
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
@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
|
||||
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
|
||||
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
|
||||
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
|
||||
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
|
||||
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
|
||||
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
|
||||
<div class="relative w-full h-full">
|
||||
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||||
|
||||
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
||||
@if (stats?.Sun is { } sun)
|
||||
{
|
||||
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
|
||||
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
|
||||
<span class="flex items-center gap-1 font-semibold">
|
||||
<i class="@SunIcon(sun)"></i>
|
||||
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||
</span>
|
||||
@if (sun.Phase is { } phase)
|
||||
{
|
||||
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
|
||||
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
|
||||
}
|
||||
</div>
|
||||
</NerdTip>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
||||
<span class="badge badge-ghost badge-xs gap-1">
|
||||
<i class="ion-md-planet"></i>
|
||||
@if (stats is { Error: null } live)
|
||||
{
|
||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
|
||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
|
||||
}
|
||||
else if (stats?.Error is { Length: > 0 })
|
||||
{
|
||||
@@ -37,13 +64,18 @@
|
||||
|
||||
ElementReference element;
|
||||
IJSObjectReference module;
|
||||
DotNetObjectReference<GlobePanel> reference;
|
||||
PeriodicTimer statsTimer;
|
||||
readonly CancellationTokenSource cancellation = new();
|
||||
string sentFingerprint;
|
||||
int? sentFingerprint;
|
||||
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
|
||||
bool postsHandedOver = true;
|
||||
GlobeStats stats;
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
protected override void OnParametersSet() => postsHandedOver = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
@@ -52,11 +84,17 @@
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId);
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||||
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
||||
_ = ReadStats(cancellation.Token);
|
||||
}
|
||||
await SendPosts();
|
||||
if (postsHandedOver)
|
||||
{
|
||||
postsHandedOver = false;
|
||||
await SendPosts();
|
||||
}
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
@@ -75,7 +113,17 @@
|
||||
|
||||
var located = Posts.Where(p => p.Location is not null).ToList();
|
||||
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)
|
||||
return;
|
||||
sentFingerprint = fingerprint;
|
||||
@@ -100,6 +148,26 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||||
[JSInvokable]
|
||||
public async Task SunChanged()
|
||||
{
|
||||
if (module is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
async Task ReadStats(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -129,20 +197,85 @@
|
||||
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("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
|
||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
|
||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
|
||||
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
|
||||
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
|
||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
await cancellation.CancelAsync();
|
||||
cancellation.Dispose();
|
||||
statsTimer?.Dispose();
|
||||
reference?.Dispose();
|
||||
try
|
||||
{
|
||||
if (module is not null)
|
||||
@@ -179,11 +312,44 @@
|
||||
public int Fps { get; set; }
|
||||
public string Renderer { get; set; }
|
||||
public string Texture { get; set; }
|
||||
public int Anisotropy { get; set; }
|
||||
public bool AutoRotate { get; set; }
|
||||
public bool Paused { get; set; }
|
||||
public bool Idle { get; set; }
|
||||
public string Hovered { get; set; }
|
||||
public int Arcs { get; set; }
|
||||
public int Rings { get; set; }
|
||||
public int Countries { get; set; }
|
||||
public int CountryNames { get; set; }
|
||||
public int Borders { get; set; }
|
||||
public SunStats Sun { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||
sealed class SunStats
|
||||
{
|
||||
public string At { get; set; }
|
||||
public bool Now { get; set; }
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
public double Distance { get; set; }
|
||||
public double Irradiance { get; set; }
|
||||
public double EclipticLongitude { get; set; }
|
||||
public double Obliquity { get; set; }
|
||||
public string Place { get; set; }
|
||||
public double? PlaceLatitude { get; set; }
|
||||
public double? PlaceLongitude { get; set; }
|
||||
public double? Elevation { get; set; }
|
||||
public string Phase { get; set; }
|
||||
public bool? Morning { get; set; }
|
||||
public string SolarTime { get; set; }
|
||||
public double? DayLength { get; set; }
|
||||
public string Season { get; set; }
|
||||
public string Hemisphere { get; set; }
|
||||
public string SeasonNorth { get; set; }
|
||||
public string SeasonSouth { 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 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,39 @@
|
||||
@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 IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
|
||||
<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)">@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,62 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
|
||||
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
|
||||
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||
<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>
|
||||
}
|
||||
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||
</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."]);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ public static class GenericExtensions
|
||||
TimelineType.Favourites => "ion-md-heart",
|
||||
TimelineType.Bookmarks => "ion-md-bookmark",
|
||||
TimelineType.Tag => "ion-md-pricetag",
|
||||
TimelineType.Lists => "ion-md-list",
|
||||
_ => 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", "中文")
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,29 @@
|
||||
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>
|
||||
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,9 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(Account))]
|
||||
[JsonSerializable(typeof(List<Account>))]
|
||||
[JsonSerializable(typeof(Relationship))]
|
||||
[JsonSerializable(typeof(List<Relationship>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(SearchResults))]
|
||||
[JsonSerializable(typeof(MediaAttachment))]
|
||||
[JsonSerializable(typeof(Notification))]
|
||||
[JsonSerializable(typeof(List<Notification>))]
|
||||
@@ -31,6 +34,30 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(TokenResponse))]
|
||||
[JsonSerializable(typeof(MastodonError))]
|
||||
[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
|
||||
{
|
||||
}
|
||||
@@ -16,12 +16,31 @@ public static class APIs
|
||||
public const string POST_Login = "clientapi/user/login";
|
||||
public const string GET_Logout = "clientapi/user/logout";
|
||||
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
||||
public const string POST_UpdatePassword = "clientapi/user/update/password";
|
||||
public const string POST_DeleteAccount = "clientapi/user/delete";
|
||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
||||
public const string GET_Personas = "clientapi/avatar/private/list";
|
||||
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||
public const string GET_Groups = "clientapi/group/list";
|
||||
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_Nearby = "clientapi/post/nearby";
|
||||
public const string POST_InsertPost = "clientapi/post/insert";
|
||||
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
|
||||
@@ -36,6 +55,12 @@ public static class APIs
|
||||
public static class Mastodon
|
||||
{
|
||||
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_PublicTimeline = "api/v1/timelines/public";
|
||||
public const string GET_Statuses = "api/v1/statuses";
|
||||
@@ -43,17 +68,66 @@ public static class APIs
|
||||
public const string GET_Favourites = "api/v1/favourites";
|
||||
public const string GET_Bookmarks = "api/v1/bookmarks";
|
||||
public const string GET_Conversations = "api/v1/conversations";
|
||||
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
|
||||
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||
public const string GET_Notifications = "api/v1/notifications";
|
||||
public const string POST_Media = "api/v2/media";
|
||||
public 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_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
||||
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
||||
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
||||
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
||||
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||
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 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
|
||||
@@ -65,5 +139,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_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? 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)]
|
||||
public MediaType MediaType { get; set; } = MediaType.Images;
|
||||
[JsonIgnore, Bindable(false)]
|
||||
|
||||
@@ -8,6 +8,8 @@ public class FeedFilter
|
||||
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
||||
/// <summary>The community or circle the Groups tab shows.</summary>
|
||||
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>
|
||||
public string Tag { get; set; }
|
||||
/// <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>
|
||||
public Status Status { get; set; }
|
||||
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; }
|
||||
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
||||
public bool Located { get; init; }
|
||||
@@ -30,6 +34,11 @@ public class FeedPost
|
||||
|
||||
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 static FeedPost Of(Status status) => status.Reblog is { } original
|
||||
@@ -38,12 +47,38 @@ public class FeedPost
|
||||
|
||||
public static FeedPost Of(Notification notification) => new()
|
||||
{
|
||||
Key = notification.Id,
|
||||
Key = notification.GroupKey ?? notification.Id,
|
||||
CursorId = notification.Id,
|
||||
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()
|
||||
{
|
||||
Key = conversation.Id,
|
||||
|
||||
@@ -25,6 +25,15 @@ public class Account
|
||||
public string LastStatusAt { get; set; }
|
||||
public List<CustomEmoji> Emojis { 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>
|
||||
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 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 string Name { get; set; }
|
||||
@@ -53,6 +83,20 @@ public class Relationship
|
||||
public bool Following { get; set; }
|
||||
public bool FollowedBy { get; set; }
|
||||
public bool Blocking { get; set; }
|
||||
public bool BlockedBy { 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 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 Status Status { 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>
|
||||
|
||||
@@ -176,4 +176,107 @@ public class PostStatusForm
|
||||
public string SpoilerText { get; set; }
|
||||
public string Visibility { 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>
|
||||
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 Excerpt { 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>
|
||||
public PrivaPubPlace Place { 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
|
||||
@@ -68,9 +75,13 @@ public class PrivaPubEvent
|
||||
public string TimeZone { get; set; }
|
||||
public List<PrivaPubPlace> Places { 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? Capacity { 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
|
||||
|
||||
@@ -40,7 +40,13 @@ public class Status
|
||||
public string Text { get; set; }
|
||||
public PrivaPubStatus Privapub { 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; }
|
||||
/// <summary>Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona.</summary>
|
||||
public QuoteApproval QuoteApproval { get; set; }
|
||||
}
|
||||
|
||||
public class StatusMention
|
||||
@@ -85,9 +91,28 @@ public class Poll
|
||||
public int VotesCount { get; set; }
|
||||
public int? VotersCount { 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 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 string Title { get; set; }
|
||||
@@ -107,3 +132,10 @@ public class PreviewCard
|
||||
public int Height { 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,
|
||||
Favourites,
|
||||
Bookmarks,
|
||||
Tag
|
||||
Tag,
|
||||
Lists
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
@page "/administration"
|
||||
@attribute [Authorize(Policy = Policies.IsModerator)]
|
||||
@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">
|
||||
|
||||
@@ -25,18 +29,44 @@
|
||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||
<Header>
|
||||
<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>
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
<p class="w-full text-center text-lg">
|
||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
||||
</p>
|
||||
<ReportsPanel />
|
||||
</div>
|
||||
</ChildContent>
|
||||
</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">
|
||||
<Header>
|
||||
<p class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<Title>@Localizer["Thread"]</Title>
|
||||
|
||||
@* 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">
|
||||
<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)
|
||||
{
|
||||
<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 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
|
||||
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
|
||||
@foreach (var post in descendants)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -53,10 +55,14 @@
|
||||
|
||||
[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;
|
||||
List<FeedPost> ancestors = [];
|
||||
List<FeedPost> descendants = [];
|
||||
string loadedId;
|
||||
CancellationTokenSource rereading;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -77,6 +83,9 @@
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
rereading?.Cancel();
|
||||
rereading?.Dispose();
|
||||
rereading = null;
|
||||
IsLoading = true;
|
||||
var status = await StatusService.Get(StatusId);
|
||||
if (status is null)
|
||||
@@ -95,6 +104,42 @@
|
||||
var all = ancestors.Append(current).Concat(descendants).ToList();
|
||||
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||
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)
|
||||
@@ -127,4 +172,14 @@
|
||||
}
|
||||
|
||||
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,271 @@
|
||||
@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 class="overflow-x-auto">
|
||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||
<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 min-w-0 flex-1 text-left" @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 {
|
||||
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,369 @@
|
||||
@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; }
|
||||
|
||||
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)
|
||||
{
|
||||
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]);
|
||||
}
|
||||
}
|
||||
+189
-12
@@ -7,8 +7,10 @@
|
||||
|
||||
@* 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
|
||||
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js); newer ones are polled every
|
||||
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
|
||||
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
|
||||
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
|
||||
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
|
||||
the stream tells is applied at once. *@
|
||||
<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">
|
||||
@@ -16,12 +18,12 @@
|
||||
</aside>
|
||||
|
||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
||||
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||
OnChanged="ChangeFeed" />
|
||||
|
||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
|
||||
|
||||
@if (isComposing)
|
||||
{
|
||||
@@ -30,6 +32,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||
{
|
||||
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
||||
<i class="ion-md-notifications-off"></i>
|
||||
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (pending.Count > 0)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
|
||||
@@ -67,15 +77,16 @@
|
||||
}
|
||||
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"]
|
||||
</p>
|
||||
}
|
||||
|
||||
@foreach (var post in posts)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
||||
(tests/e2e/Perf/FeedPerfTests) *@
|
||||
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||
<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">
|
||||
@if (isLoadingMore)
|
||||
@@ -103,13 +114,21 @@
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
[Inject] TimelineService TimelineService { get; set; }
|
||||
[Inject] GroupService GroupService { get; set; }
|
||||
[Inject] ListService ListService { get; set; }
|
||||
[Inject] StreamingService Streaming { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] RequestStats RequestStats { get; set; }
|
||||
[Inject] FeedMemory Memory { get; set; }
|
||||
|
||||
FeedFilter Filter { get; set; } = new();
|
||||
List<FeedPost> posts = [];
|
||||
List<FeedPost> pending = [];
|
||||
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 minId;
|
||||
string loadError;
|
||||
@@ -121,6 +140,8 @@
|
||||
FeedQuery lastQuery;
|
||||
DateTime? lastPollAt;
|
||||
TimeSpan? lastLoadTook;
|
||||
// the scroll a recalled feed goes back to, once rendered
|
||||
double? recalledScroll;
|
||||
|
||||
GlobePanel globe;
|
||||
IJSObjectReference feedModule;
|
||||
@@ -131,6 +152,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Streaming.Received += OnStream;
|
||||
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
||||
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
||||
Filter.TimelineType = TimelineType.Home;
|
||||
@@ -149,9 +171,36 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (await Recall())
|
||||
return;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
/// <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 changed (NavMenu's switcher): everything on the page belongs to the old one.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -168,13 +217,25 @@
|
||||
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
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);
|
||||
pollTimer = new(PollInterval);
|
||||
_ = 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,
|
||||
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>
|
||||
async Task Reload()
|
||||
@@ -193,11 +254,16 @@
|
||||
|
||||
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);
|
||||
lists = await ListService.All(cancellationToken);
|
||||
loadedPersonaId = CascadingState.PersonaId;
|
||||
}
|
||||
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
||||
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;
|
||||
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
||||
{
|
||||
@@ -223,6 +289,8 @@
|
||||
maxId = page.MaxId;
|
||||
minId = page.MinId;
|
||||
lastPollAt = DateTime.UtcNow;
|
||||
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
||||
await ReadNotifications(cancellationToken);
|
||||
StateHasChanged();
|
||||
await Locate(posts, cancellationToken);
|
||||
if (feedModule is not null)
|
||||
@@ -275,7 +343,14 @@
|
||||
{
|
||||
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
|
||||
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
||||
continue;
|
||||
@@ -287,9 +362,15 @@
|
||||
continue;
|
||||
|
||||
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)
|
||||
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);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
@@ -307,11 +388,85 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
async Task ShowPending()
|
||||
{
|
||||
var shown = pending;
|
||||
pending = [];
|
||||
posts.InsertRange(0, shown);
|
||||
await ReadNotifications(loadCancellation?.Token ?? default);
|
||||
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
||||
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
||||
await Locate(shown, loadCancellation?.Token ?? default);
|
||||
@@ -385,6 +540,7 @@
|
||||
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["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["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
|
||||
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
|
||||
@@ -397,6 +553,9 @@
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
Streaming.Received -= OnStream;
|
||||
await Remember();
|
||||
await Streaming.Stop();
|
||||
await pageCancellation.CancelAsync();
|
||||
pageCancellation.Dispose();
|
||||
loadCancellation?.Cancel();
|
||||
@@ -416,6 +575,24 @@
|
||||
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
|
||||
{
|
||||
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,371 @@
|
||||
@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>
|
||||
}
|
||||
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
|
||||
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" style="@HeaderStyle"></div>
|
||||
<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"
|
||||
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] 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)
|
||||
{
|
||||
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,144 @@
|
||||
@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; }
|
||||
|
||||
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)
|
||||
{
|
||||
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>
|
||||
</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">
|
||||
<Header>
|
||||
<p class="inline-flex items-center gap-2">
|
||||
@@ -54,9 +108,14 @@
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
<p class="w-full text-center text-lg">
|
||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
||||
</p>
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<ProfileEditor />
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||
}
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
@@ -69,9 +128,34 @@
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
<p class="w-full text-center text-lg">
|
||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
||||
</p>
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<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>
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
@@ -84,9 +168,14 @@
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
<p class="w-full text-center text-lg">
|
||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
||||
</p>
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<MutesAndBlocks />
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||
}
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
|
||||
@@ -40,7 +40,16 @@ builder.Services.AddOptions()
|
||||
.AddScoped<RequestStats>()
|
||||
.AddScoped<AuthService>()
|
||||
.AddScoped<TimelineService>()
|
||||
.AddScoped<FeedMemory>()
|
||||
.AddScoped<StatusService>()
|
||||
.AddScoped<AccountService>()
|
||||
.AddScoped<PortabilityService>()
|
||||
.AddScoped<AdminService>()
|
||||
.AddScoped<ListService>()
|
||||
.AddScoped<FilterService>()
|
||||
.AddScoped<DiscoveryService>()
|
||||
.AddScoped<StreamingService>()
|
||||
.AddScoped<PublicNoticeService>()
|
||||
.AddScoped<MediaService>()
|
||||
.AddScoped<GroupService>()
|
||||
.AddScoped<InstanceService>()
|
||||
|
||||
+1138
-1
File diff suppressed because it is too large.
Load diff
+1138
-1
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,16 @@ namespace decePubClient.Services
|
||||
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>
|
||||
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -104,6 +114,27 @@ namespace decePubClient.Services
|
||||
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>
|
||||
public async Task Logout(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -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.Models;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
|
||||
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)
|
||||
{
|
||||
/// <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) ?? [];
|
||||
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> 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,
|
||||
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) =>
|
||||
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) =>
|
||||
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
||||
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,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);
|
||||
}
|
||||
}
|
||||
+124
-3
@@ -21,7 +21,9 @@ namespace decePubClient.Services
|
||||
Reblog,
|
||||
Unreblog,
|
||||
Bookmark,
|
||||
Unbookmark
|
||||
Unbookmark,
|
||||
Pin,
|
||||
Unpin
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,13 +56,102 @@ namespace decePubClient.Services
|
||||
Sensitive = form.HasContentWarning,
|
||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||
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
|
||||
}, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
||||
return new WebResult { Data = FeedPost.Of(status) };
|
||||
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Status>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
|
||||
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken);
|
||||
return [];
|
||||
}
|
||||
return [.. (await response.MastodonReadFromJsonAsync<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
|
||||
}
|
||||
|
||||
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
|
||||
public async Task<WebResult> Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId),
|
||||
new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Drops a scheduled post: it is never published.</summary>
|
||||
public async Task<WebResult> Unschedule(string scheduledId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
||||
@@ -100,6 +191,36 @@ namespace decePubClient.Services
|
||||
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>
|
||||
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <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,
|
||||
double? Latitude = default, double? Longitude = default);
|
||||
double? Latitude = default, double? Longitude = default, string ListId = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.Tag when query.Tag is { Length: > 0 } tag =>
|
||||
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 =>
|
||||
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.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),
|
||||
_ => 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)
|
||||
{
|
||||
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)
|
||||
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();
|
||||
return new WebResult
|
||||
{
|
||||
Data = new FeedPage
|
||||
{
|
||||
Posts = [.. notifications.Select(FeedPost.Of)],
|
||||
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
|
||||
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
|
||||
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
|
||||
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
|
||||
MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
|
||||
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
||||
/// <summary>How many groups of notifications are unread (newer than the notifications marker).</summary>
|
||||
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)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, 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>
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
<PublicNoticeHost />
|
||||
+27
-1
@@ -41,11 +41,37 @@
|
||||
<i class="ion-md-home"></i>
|
||||
<span>@Localizer["Home"]</span>
|
||||
</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">
|
||||
<i class="ion-md-settings"></i>
|
||||
<span>@Localizer["Settings"]</span>
|
||||
</NavLink>
|
||||
<AuthorizeView Policy="@Policies.IsAdmin">
|
||||
<AuthorizeView Policy="@Policies.IsModerator">
|
||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
||||
<i class="ion-md-switch text-error"></i>
|
||||
<span>@Localizer["Administration"]</span>
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
||||
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
||||
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
|
||||
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
|
||||
@import "tailwindcss" source("../");
|
||||
|
||||
@source not "../wwwroot/vendor";
|
||||
@source not "../Docs";
|
||||
@source not "../tools";
|
||||
@source not "../tests";
|
||||
|
||||
@plugin "../tools/tailwind/daisyui.mjs" {
|
||||
themes: false;
|
||||
|
||||
+32
-2
@@ -1,6 +1,6 @@
|
||||
/* 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
|
||||
look of globe.gl's own tooltip box. */
|
||||
scheme in .invisible and shortens long links with .ellipsis), the globe's point labels, which replace the look of
|
||||
globe.gl's own tooltip box, its CDN groups and country names, and the card it is pinned to. */
|
||||
@layer components {
|
||||
.post-content p {
|
||||
margin-block: 0 0.5em;
|
||||
@@ -25,6 +25,16 @@
|
||||
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 {
|
||||
content: "…";
|
||||
}
|
||||
@@ -88,4 +98,24 @@
|
||||
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@
|
||||
@using decePubClient.Components
|
||||
@using decePubClient.Components.Feed
|
||||
@using decePubClient.Components.Nerd
|
||||
@using decePubClient.Components.Settings
|
||||
@using decePubClient.Components.Admin
|
||||
@using decePubClient.Components.Daisy
|
||||
@using decePubClient.Components.Daisy.Actions
|
||||
@using decePubClient.Components.Daisy.DataDisplay
|
||||
@@ -39,5 +41,7 @@
|
||||
@using decePubClient.Components.Daisy.Navigation
|
||||
@using decePubClient.LayerComponents
|
||||
@using PrivaPub.ClientModels
|
||||
@using PrivaPub.ClientModels.Admin
|
||||
@using PrivaPub.ClientModels.Group
|
||||
@using PrivaPub.ClientModels.User
|
||||
@using PrivaPub.ClientModels.User.Avatar
|
||||
@@ -6,6 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
||||
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -38,7 +40,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
||||
@@ -67,6 +69,9 @@
|
||||
<ItemGroup>
|
||||
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
||||
<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>
|
||||
|
||||
@@ -7,24 +7,66 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubCli
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{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|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.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.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.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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
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
|
||||
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
||||
EndGlobalSection
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A login of its own, made for the test: its password changed in Settings, then the whole of it deleted there, each
|
||||
// checked against PrivaPub's own login
|
||||
public sealed class AccountTests(Browser browser)
|
||||
{
|
||||
const string FirstPassword = "Pasture-Pass-1!";
|
||||
const string SecondPassword = "Pasture-Pass-2!";
|
||||
|
||||
static async Task<HttpStatusCode> Login(string root, string password)
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
using var response = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_logins_password_is_changed_and_the_login_deleted_from_settings()
|
||||
{
|
||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
||||
var root = $"e2eroot{stamp}";
|
||||
using (var http = Pasture.Http())
|
||||
{
|
||||
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = root, password = FirstPassword });
|
||||
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
|
||||
var jwt = JsonNode.Parse(await signup.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
|
||||
using var insert = new HttpRequestMessage(HttpMethod.Post, Pasture.Api + "/clientapi/avatar/private/insert")
|
||||
{
|
||||
Content = JsonContent.Create(new { userName = $"e2eacct{stamp}", name = "an e2e persona", biography = "for the account test" })
|
||||
};
|
||||
insert.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
using var inserted = await http.SendAsync(insert);
|
||||
Assert.True(inserted.IsSuccessStatusCode, $"the persona could not be made ({(int)inserted.StatusCode})");
|
||||
}
|
||||
var persona = new Pasture.Persona($"e2eacct{stamp}", "an e2e persona", root, FirstPassword, null, null, null);
|
||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.account", "privapub", root, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
await page.Locator("[data-settings-account]").ClickAsync();
|
||||
await page.Locator("input[data-account-current]").FillAsync(FirstPassword);
|
||||
await page.Locator("input[data-account-new]").FillAsync(SecondPassword);
|
||||
await page.Locator("input[data-account-repeat]").FillAsync(SecondPassword);
|
||||
await page.Locator("[data-account-password-save]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) == HttpStatusCode.OK, 15), "the new password never worked");
|
||||
Assert.NotEqual(HttpStatusCode.OK, await Login(root, FirstPassword));
|
||||
|
||||
await page.Locator("input[data-account-delete-password]").FillAsync(SecondPassword);
|
||||
await page.Locator("input[data-account-delete-understood]").CheckAsync();
|
||||
await page.Locator("[data-account-delete-confirm]").ClickAsync();
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) != HttpStatusCode.OK, 15), "the deleted login still signs in");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite
|
||||
// and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh
|
||||
// stored reads the origin's database, so nothing here trusts decePub's own view of what it did.
|
||||
public sealed class ActionMatrixTests(Browser browser)
|
||||
{
|
||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
||||
|
||||
public static TheoryData<string, string> Targets()
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
||||
foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" })
|
||||
{
|
||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin
|
||||
&& i["visibility"]!.GetValue<string>() is "public" or "community"
|
||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
||||
if (item is not null)
|
||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
|
||||
{
|
||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
||||
var personas = Pasture.Personas();
|
||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
||||
.First(p => p is not null);
|
||||
return (item, reader);
|
||||
}
|
||||
|
||||
static async Task<int> Count(string origin, string uri, string field)
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, uri))[uri]!;
|
||||
return row[field]?.GetValue<int?>() ?? 0;
|
||||
}
|
||||
|
||||
static async Task<bool> IsActive(ILocator button) =>
|
||||
((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active");
|
||||
|
||||
async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader)
|
||||
{
|
||||
var session = await SignIn.As(browser, reader);
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
return (session, card);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference)
|
||||
{
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.favourite", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
var favourite = card.Locator("footer button[title='Favourite']").First;
|
||||
// the town may have done it already as this persona: start from the undone state
|
||||
if (await IsActive(favourite))
|
||||
{
|
||||
var done = await Count(origin, uri, "likes");
|
||||
await favourite.ClickAsync();
|
||||
await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60);
|
||||
await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
||||
}
|
||||
var before = await Count(origin, uri, "likes");
|
||||
await favourite.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before),
|
||||
$"the favourite never reached {origin}");
|
||||
await favourite.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60),
|
||||
$"undoing the favourite never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference)
|
||||
{
|
||||
Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread");
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.boost", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
var boost = card.Locator("footer button[title='Boost']").First;
|
||||
// the town may have done it already as this persona: start from the undone state
|
||||
if (await IsActive(boost))
|
||||
{
|
||||
var done = await Count(origin, uri, "boosts");
|
||||
await boost.ClickAsync();
|
||||
await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60);
|
||||
await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
||||
}
|
||||
var before = await Count(origin, uri, "boosts");
|
||||
await boost.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before),
|
||||
$"the boost never reached {origin}");
|
||||
await boost.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60),
|
||||
$"undoing the boost never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference)
|
||||
{
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.reply", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
await card.Locator("footer button[title='Reply']").First.ClickAsync();
|
||||
var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})";
|
||||
// after the @names the composer prefills, as a reader types
|
||||
var box = card.Locator("textarea").Last;
|
||||
await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim());
|
||||
var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST");
|
||||
await card.Locator("button[type=submit]").Last.ClickAsync();
|
||||
var response = await published;
|
||||
Assert.True(response.Ok, $"publishing the reply answered {response.Status}");
|
||||
var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(replyUri));
|
||||
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
||||
return row["exists"]!.GetValue<bool>() && row["parent_uri"]?.GetValue<string>() == uri;
|
||||
}, 45), $"the reply never landed under the post on {origin}");
|
||||
|
||||
var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First;
|
||||
await reply.WaitForAsync(new() { Timeout = 15000 });
|
||||
await reply.Locator("footer [title='Other']").First.ClickAsync();
|
||||
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
||||
return !row["exists"]!.GetValue<bool>() || row["deleted"]!.GetValue<bool>();
|
||||
}, 45), $"the deleted reply is still on {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Administration as the pasture's administrator (pastureroot, whom the scenarios make): a server blocked for everyone
|
||||
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
||||
public sealed class AdministrationTests(Browser browser)
|
||||
{
|
||||
const string Root = PastureAdmin.Root;
|
||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
||||
|
||||
static Task<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
|
||||
|
||||
static async Task<ILocator> Open(IPage page, string section)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/administration");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||
await header.ClickAsync();
|
||||
return header.Locator("xpath=..");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_is_blocked_for_everyone_and_unblocked()
|
||||
{
|
||||
const string server = "instance-block-by-e2e.example";
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.admin.server-block", "privapub", Root, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Blocked servers");
|
||||
var blocks = panel.Locator("[data-server-blocks]");
|
||||
await blocks.WaitForAsync(new() { Timeout = 30000 });
|
||||
await blocks.Locator("[data-server-block-domain]").FillAsync(server);
|
||||
await blocks.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||
var row = blocks.Locator($"[data-server-block='{server}']");
|
||||
await Assertions.Expect(row).ToContainTextAsync("Suspended", new() { Timeout = 15000 });
|
||||
Assert.Contains((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||
|
||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.DoesNotContain((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_federation_statistics_and_the_reports_are_shown()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.admin.statistics", "privapub", Root, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Statistics");
|
||||
await Assertions.Expect(panel.Locator("[data-statistics-overview]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
var expected = (await AsAdmin("/clientapi/admin/statistics/hosts?days=30&sort=traffic&page=1&limit=50"))!["hosts"]!.AsArray()
|
||||
.Select(h => h!["host"]!.GetValue<string>()).Take(5).ToList();
|
||||
Assert.NotEmpty(expected);
|
||||
foreach (var host in expected)
|
||||
await Assertions.Expect(panel.Locator($"[data-statistics-host='{host}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||
|
||||
var reports = await Open(page, "Reports");
|
||||
await Assertions.Expect(reports.Locator("[data-reports]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
var open = (await AsAdmin("/clientapi/moderator/reports?resolved=false"))!.AsArray().Count;
|
||||
await Assertions.Expect(reports.Locator("[data-report-id]")).ToHaveCountAsync(open, new() { Timeout = 15000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// An article in a feed shows its title, its excerpt and a Read button, and its whole text once asked: a WriteFreely
|
||||
// blog the persona follows publishes one (SocialPub's peers/writefreely.sh keeps the blog's token).
|
||||
public sealed class ArticleTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task An_article_is_folded_in_the_feed_and_read_on_demand()
|
||||
{
|
||||
var tokenFile = Path.Combine(Pasture.Root, ".state", "writefreely", "wfuser.token");
|
||||
Assert.SkipUnless(File.Exists(tokenFile), "no WriteFreely blog: run tools/pasture/run.sh add writefreely");
|
||||
var persona = Pasture.Personas().First();
|
||||
var blog = (await PersonaApi.Call(persona, "/api/v2/search?q=wfuser%40writefreely.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(blog is null, "PrivaPub cannot resolve the WriteFreely blog");
|
||||
var blogId = blog!["id"]!.GetValue<string>();
|
||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/follow", HttpMethod.Post);
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={blogId}"))![0]!["following"]!.GetValue<bool>(), 45), "the blog never accepted");
|
||||
|
||||
var title = $"An article to fold {Guid.NewGuid():N}"[..30];
|
||||
var ending = $"the last words {Guid.NewGuid():N}"[..24];
|
||||
var body = string.Join(" ", Enumerable.Repeat("A long paragraph that a feed should not show whole.", 12)) + " " + ending;
|
||||
using var http = Pasture.Http();
|
||||
using var publish = new HttpRequestMessage(HttpMethod.Post, "https://writefreely.test/api/collections/wfuser/posts")
|
||||
{
|
||||
Content = new StringContent(new JsonObject { ["title"] = title, ["body"] = body }.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
publish.Headers.Authorization = new AuthenticationHeaderValue("Token", File.ReadAllText(tokenFile).Trim());
|
||||
(await http.SendAsync(publish)).EnsureSuccessStatusCode();
|
||||
JsonNode article = null;
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(article = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
||||
.FirstOrDefault(s => s!["privapub"]?["title"]?.GetValue<string>() == title)) is not null, 60), "the article never reached the persona");
|
||||
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.article", "writefreely", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var card = page.Locator($"article[data-uri='{article!["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await Assertions.Expect(card).ToContainTextAsync(title);
|
||||
await Assertions.Expect(card.Locator("[data-article]")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(card).Not.ToContainTextAsync(ending);
|
||||
await card.Locator("[data-article-open]").ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync(ending, new() { Timeout = 5000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/unfollow", HttpMethod.Post);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
|
||||
// quote, each checked on PrivaPub
|
||||
public sealed class ComposerTests(Browser browser)
|
||||
{
|
||||
static async Task OpenHome(IPage page)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
}
|
||||
|
||||
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public async Task A_poll_is_published_from_the_composer()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"which one {Guid.NewGuid():N}"[..18];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.poll", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await page.Locator("button[data-poll-toggle]").ClickAsync();
|
||||
var choices = page.Locator("[data-poll-editor] input[type=text]");
|
||||
await choices.Nth(0).FillAsync("tea");
|
||||
await choices.Nth(1).FillAsync("coffee");
|
||||
// and in the language picked
|
||||
await page.Locator("[data-compose-language]").First.SelectOptionAsync("it");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).Last.ClickAsync();
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
||||
"the poll was never published");
|
||||
made = latest!["id"]!.GetValue<string>();
|
||||
Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue<string>()));
|
||||
Assert.Equal("it", latest["language"]?.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
// a mention being written is offered the accounts PrivaPub knows, and a click writes the whole handle out
|
||||
[Fact]
|
||||
public async Task A_mention_being_written_is_completed_from_the_accounts_the_server_knows()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
||||
var (author, other) = (personas[0], personas[1]);
|
||||
var acct = (await PersonaApi.Call(other, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.mention-suggestion", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
var box = page.Locator("textarea[data-compose]").First;
|
||||
await box.FillAsync("hello @" + acct[..Math.Min(4, acct.Length)]);
|
||||
await page.Locator($"[data-suggestions] [data-suggestion='@{acct}']").ClickAsync(new() { Timeout = 15000 });
|
||||
await Assertions.Expect(box).ToHaveValueAsync($"hello @{acct} ");
|
||||
await Assertions.Expect(page.Locator("[data-suggestions]")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_own_post_is_edited_and_quoted_from_its_card()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"first words {Guid.NewGuid():N}"[..20];
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
string quote = null;
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.edit-quote", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-edit]").ClickAsync();
|
||||
var box = card.Locator("textarea[data-compose]");
|
||||
await Assertions.Expect(box).ToHaveValueAsync(text, new() { Timeout = 10000 });
|
||||
await box.FillAsync(text + " (edited)");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
|
||||
Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue<string>());
|
||||
await card.Locator("button[data-history]").ClickAsync();
|
||||
var versions = card.Locator("[data-history-list] [data-version]");
|
||||
await Assertions.Expect(versions).ToHaveCountAsync(2, new() { Timeout = 10000 });
|
||||
await Assertions.Expect(versions.First).ToContainTextAsync(text + " (edited)");
|
||||
await Assertions.Expect(versions.Last).ToContainTextAsync(text);
|
||||
await Assertions.Expect(versions.Last).Not.ToContainTextAsync("(edited)");
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await Assertions.Expect(card.Locator("[data-history-list]")).ToHaveCountAsync(0);
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-quote]").ClickAsync();
|
||||
await card.Locator("textarea[data-compose]").FillAsync("quoting myself");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Quote" }).Last.ClickAsync();
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains("quoting myself") == true, 15),
|
||||
"the quote was never published");
|
||||
quote = latest!["id"]!.GetValue<string>();
|
||||
Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (quote is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{quote}", HttpMethod.Delete);
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Direct conversations: a message from another persona badges the Direct tab and its card says unread; marking it
|
||||
// read and taking it off the list are checked on PrivaPub
|
||||
public sealed class ConversationTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_new_message_is_unread_until_marked_and_leaves_the_list_when_removed()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
||||
var (reader, writer) = (personas[0], personas[1]);
|
||||
var message = (await PersonaApi.Call(writer, "/api/v1/statuses", HttpMethod.Post, new JsonObject
|
||||
{
|
||||
["status"] = $"@{reader.UserName} just between us {Guid.NewGuid():N}"[..40], ["visibility"] = "direct"
|
||||
}))!;
|
||||
var uri = message["uri"]!.GetValue<string>();
|
||||
async Task<JsonNode> Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
|
||||
.FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue<string>() == uri);
|
||||
await using var session = await SignIn.As(browser, reader);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.conversation", "privapub", reader.UserName, async () =>
|
||||
{
|
||||
Assert.True(await Pasture.Eventually(async () => (await Conversation())?["unread"]?.GetValue<bool>() == true, 15),
|
||||
"the message never made an unread conversation");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("[data-unread-conversations]").WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-mail") }).ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{uri}']");
|
||||
await card.Locator("[data-conversation-unread]").WaitForAsync(new() { Timeout = 30000 });
|
||||
await card.Locator("[data-conversation-read]").ClickAsync();
|
||||
await Assertions.Expect(card.Locator("[data-conversation-unread]")).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
Assert.False((await Conversation())!["unread"]!.GetValue<bool>());
|
||||
|
||||
await card.Locator("[data-conversation-remove]").ClickAsync();
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
Assert.Null(await Conversation());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(writer, $"/api/v1/statuses/{message["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Posts written for later, hashtags followed and what Explore shows, each checked against PrivaPub's own answer
|
||||
public sealed class DiscoveryTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_post_scheduled_in_the_composer_waits_is_moved_and_is_dropped()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"later {Guid.NewGuid():N}"[..20];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.schedule", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
await page.Locator("textarea").First.FillAsync(text);
|
||||
await page.Locator("button[data-schedule]").ClickAsync();
|
||||
await page.Locator("input[data-schedule-at]").FillAsync(DateTime.Now.AddHours(2).ToString("yyyy-MM-ddTHH:mm"));
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Schedule" }).ClickAsync();
|
||||
JsonNode waiting = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (waiting = (await PersonaApi.Call(persona, "/api/v1/scheduled_statuses"))!.AsArray()
|
||||
.FirstOrDefault(s => s!["params"]!["text"]?.GetValue<string>() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
|
||||
var id = waiting!["id"]!.GetValue<string>();
|
||||
Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
|
||||
s => s!["content"]!.GetValue<string>().Contains(text));
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/scheduled");
|
||||
var item = page.Locator($"article[data-scheduled-id='{id}']");
|
||||
await Assertions.Expect(item).ToContainTextAsync(text, new() { Timeout = 15000 });
|
||||
var later = DateTime.Now.AddHours(5);
|
||||
await item.Locator("input[type=datetime-local]").FillAsync(later.ToString("yyyy-MM-ddTHH:mm"));
|
||||
await item.GetByRole(AriaRole.Button, new() { Name = "Move" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue<DateTime>().ToUniversalTime()
|
||||
is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
|
||||
await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
|
||||
await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hashtag_is_followed_from_its_feed_tab_and_listed_in_explore()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var tag = $"e2etag{Guid.NewGuid():N}"[..14];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.follow-tag", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
// a reader finds the hashtag through search, which opens it as a tab of the feed
|
||||
await page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("#" + tag)}");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "#" + tag }).ClickAsync(new() { Timeout = 30000 });
|
||||
var follow = page.Locator("button[data-tag-follow]");
|
||||
await Assertions.Expect(follow).ToContainTextAsync($"Follow #{tag}", new() { Timeout = 30000 });
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToContainTextAsync($"Stop following #{tag}", new() { Timeout = 15000 });
|
||||
Assert.True((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
||||
await page.GetByRole(AriaRole.Tab, new() { Name = "Followed hashtags" }).ClickAsync();
|
||||
var row = page.Locator($"[data-tag='{tag}']");
|
||||
await row.WaitForAsync(new() { Timeout = 15000 });
|
||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unfollow" }).ClickAsync();
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.False((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/tags/{tag}/unfollow", HttpMethod.Post);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Explore_shows_what_privapub_says_is_trending_and_its_directory()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var trending = (await PersonaApi.Call(persona, "/api/v1/trends/tags?limit=20"))!.AsArray().Select(t => t!["name"]!.GetValue<string>()).ToList();
|
||||
var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()).ToList();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.explore", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
||||
await page.Locator("[data-tag], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var shownTags = await page.Locator("[data-tag]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.tag)");
|
||||
Assert.Equal(trending, shownTags);
|
||||
|
||||
await page.GetByRole(AriaRole.Tab, new() { Name = "People" }).ClickAsync();
|
||||
await page.Locator("[data-account], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var shownPeople = await page.Locator("[data-account]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.account)");
|
||||
Assert.Equal(directory, shownPeople);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Joining another server's event from its card (an owner decision): the persona is asked once that taking part is
|
||||
// public, Mobilizon makes it a participant and accepts, and leaving takes it off there. The event is the one
|
||||
// SocialPub's tools/pasture/scenarios/mobilizon.sh keeps for these tests.
|
||||
public sealed class EventTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_persona_joins_a_mobilizon_event_from_its_card_and_leaves_it()
|
||||
{
|
||||
var kept = Path.Combine(Pasture.Root, ".state", "mobilizon-kept-event.json");
|
||||
Assert.SkipUnless(File.Exists(kept), "no Mobilizon event kept for the client: run tools/pasture/interop.sh mobilizon");
|
||||
var url = JsonNode.Parse(File.ReadAllText(kept))!["url"]!.GetValue<string>();
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count == 0, "the town has no personas");
|
||||
var reader = personas[0];
|
||||
var status = (await PersonaApi.Call(reader, $"/api/v2/search?q={Uri.EscapeDataString(url)}&resolve=true&type=statuses"))?["statuses"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(status is null, "PrivaPub cannot resolve the kept Mobilizon event");
|
||||
var id = status!["id"]!.GetValue<string>();
|
||||
async Task<string> Participation() =>
|
||||
(await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue<string>();
|
||||
// a run cut short may have left the persona in: start from outside
|
||||
if (await Participation() is not null)
|
||||
await PersonaApi.Call(reader, $"/api/privapub/v1/statuses/{id}/leave", HttpMethod.Post);
|
||||
await using var session = await SignIn.As(browser, reader);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.join", "mobilizon", "kept-event", async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{id}");
|
||||
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
|
||||
await card.Locator("[data-event-join]").ClickAsync(new() { Timeout = 30000 });
|
||||
await VoteAndReactTests.AcceptNoticeIfAsked(page);
|
||||
await card.Locator("[data-event-leave]").WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await Participation() == "accepted", 45), "Mobilizon never accepted the Join");
|
||||
Assert.Equal("participant", await MobilizonRole(reader.ActorUri, url));
|
||||
|
||||
await page.ReloadAsync();
|
||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "accepted", new() { Timeout = 30000 });
|
||||
await card.Locator("[data-event-leave]").ClickAsync();
|
||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "none", new() { Timeout = 15000 });
|
||||
Assert.Null(await Participation());
|
||||
Assert.True(await Pasture.Eventually(async () => await MobilizonRole(reader.ActorUri, url) == "", 45),
|
||||
"the persona is still a participant on Mobilizon");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
// the persona's role in the event, from Mobilizon's database; empty when it takes no part
|
||||
static async Task<string> MobilizonRole(string actorUri, string eventUrl)
|
||||
{
|
||||
static string Quoted(string value) => "'" + value.Replace("'", "''") + "'";
|
||||
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "exec", "pasture-postgis", "psql", "-U", "pasture", "-d", "mobilizon", "-tAc",
|
||||
"select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id " +
|
||||
$"where a.url = {Quoted(actorUri)} and e.url = {Quoted(eventUrl)}" })
|
||||
start.ArgumentList.Add(arg);
|
||||
using var process = Process.Start(start)!;
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return output.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Coming back to the feed from a thread shows it as it was left, with its older pages, without reading it again
|
||||
// (Services/FeedMemory.cs).
|
||||
public sealed class FeedMemoryTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task The_feed_comes_back_from_a_thread_as_it_was_left()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.feed-memory", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var feed = page.Locator("[data-feed-count]");
|
||||
await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
// an older page too when there is one, so there is more to keep than the first
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = root.scrollHeight");
|
||||
await Pasture.Eventually(async () => int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") > 20, 10);
|
||||
var count = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
|
||||
|
||||
var homeReads = 0;
|
||||
page.Request += (_, request) =>
|
||||
{
|
||||
if (request.Url.Contains("/api/v1/timelines/home") && !request.Url.Contains("min_id"))
|
||||
homeReads++;
|
||||
};
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = 0");
|
||||
await page.Locator("article[data-uri] footer a[href^='expand/']").First.ClickAsync();
|
||||
await page.WaitForURLAsync("**/expand/**");
|
||||
await page.GoBackAsync();
|
||||
await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 10000 });
|
||||
|
||||
Assert.True(int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") >= count, "the feed came back shorter than it was left");
|
||||
Assert.Equal(0, homeReads);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
|
||||
// filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
|
||||
public sealed class FilterTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var word = $"zq{Guid.NewGuid():N}"[..10];
|
||||
var title = $"e2e {word}";
|
||||
foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
||||
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
|
||||
var uri = post["uri"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||
await page.Locator("input[data-filter-new]").FillAsync(title);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
|
||||
var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
|
||||
await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
var filterId = await article.GetAttributeAsync("data-filter-id");
|
||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
|
||||
.Any(k => k!["keyword"]!.GetValue<string>() == word) == true, 15), "PrivaPub never got the filter's word");
|
||||
|
||||
// warn: the card is folded behind the filter's title, and opens on request
|
||||
// the feed opens on its default tab, Home, when no tab is remembered
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var card = page.Locator($"article[data-uri='{uri}']");
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
|
||||
await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
|
||||
|
||||
// hide: the card is left out
|
||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||
article = page.Locator($"article[data-filter-id='{filterId}']");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||
await article.Locator("select").First.SelectOptionAsync("Hide");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
|
||||
.GetValue<string>() == "hide", 15), "PrivaPub never made the filter hide");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
|
||||
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A circle made in decePub, joined by another persona with its invitation code, and that member taken out by the
|
||||
// owner, who alone sees who is in it
|
||||
public sealed class GroupTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_circle_is_made_joined_by_invitation_and_its_member_taken_out()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
||||
var (owner, joiner) = (personas[0], personas[1]);
|
||||
var userName = $"e2ecircle{DateTime.UtcNow:MMddHHmmss}";
|
||||
await using var ownerSession = await SignIn.As(browser, owner);
|
||||
await using var joinerSession = await SignIn.As(browser, joiner);
|
||||
var page = ownerSession.Page;
|
||||
await Results.Cell("client.action.group", "privapub", owner.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/groups");
|
||||
var form = page.Locator("[data-group-new]");
|
||||
await form.Locator("input[data-group-username]").FillAsync(userName);
|
||||
await form.Locator("input[data-group-name]").FillAsync(userName);
|
||||
await form.Locator("[data-group-create]").ClickAsync();
|
||||
var invitation = page.Locator("[data-group-invitation]");
|
||||
await invitation.WaitForAsync(new() { Timeout = 30000 });
|
||||
var code = await invitation.InputValueAsync();
|
||||
Assert.Equal(64, code.Length);
|
||||
|
||||
var other = joinerSession.Page;
|
||||
await other.GotoAsync($"{Pasture.Site}/groups");
|
||||
var join = other.Locator("[data-group-join]");
|
||||
await join.Locator("input[data-group-code]").FillAsync(code);
|
||||
await join.Locator("[data-group-join-submit]").ClickAsync();
|
||||
await other.Locator("article[data-group-id] [data-group-leave]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
// closed and opened again, so its members are read after the join
|
||||
var card = page.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName });
|
||||
await card.Locator("[data-group-manage]").ClickAsync();
|
||||
await card.Locator("[data-group-manage]").ClickAsync();
|
||||
var member = card.Locator($"[data-member='{joiner.ActorUri}']");
|
||||
await member.WaitForAsync(new() { Timeout = 30000 });
|
||||
Assert.Equal("false", await member.GetAttributeAsync("data-pending"));
|
||||
await member.Locator("[data-member-remove]").ClickAsync();
|
||||
await Assertions.Expect(member).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
|
||||
await other.ReloadAsync();
|
||||
await other.Locator("[data-group-new]").WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(other.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName })).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
await ownerSession.AssertGuards();
|
||||
await joinerSession.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts
|
||||
// it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own
|
||||
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
|
||||
public sealed class ListTests(Browser browser)
|
||||
{
|
||||
static Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
|
||||
PersonaApi.Call(persona, path, method, body);
|
||||
|
||||
// an account the persona follows that has written something
|
||||
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
|
||||
(await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
|
||||
.FirstOrDefault(a => a!["statuses_count"]!.GetValue<int>() > 0 && !a["acct"]!.GetValue<string>().StartsWith("st_"));
|
||||
|
||||
[Fact]
|
||||
public async Task An_account_joins_and_leaves_a_list_from_its_profile()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var member = await Followed(persona);
|
||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
||||
var memberId = member!["id"]!.GetValue<string>();
|
||||
var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
async Task<bool> InList() =>
|
||||
(await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue<string>() == memberId);
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{memberId}");
|
||||
var profile = page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync();
|
||||
var box = profile.Locator($"[data-list-id='{listId}'] input");
|
||||
await box.CheckAsync();
|
||||
Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list");
|
||||
await box.UncheckAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_list_is_made_filled_read_as_a_feed_and_deleted()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var member = await Followed(persona);
|
||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
||||
var memberId = member!["id"]!.GetValue<string>();
|
||||
var title = $"e2e {Guid.NewGuid():N}"[..12];
|
||||
// lists an earlier failed run left behind
|
||||
foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
||||
await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.list", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
||||
await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync();
|
||||
var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title });
|
||||
await article.WaitForAsync(new() { Timeout = 15000 });
|
||||
var listId = await article.GetAttributeAsync("data-list-id");
|
||||
Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue<string>() == listId);
|
||||
|
||||
// the list opens once made: find the member among the accounts followed, and add it
|
||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue<string>());
|
||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter");
|
||||
var candidate = article.Locator($"[data-candidate-id='{memberId}']");
|
||||
await candidate.WaitForAsync(new() { Timeout = 15000 });
|
||||
await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync();
|
||||
await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()));
|
||||
|
||||
// read as a feed: the cards are the list timeline's first page, as PrivaPub answers it
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync();
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray()
|
||||
.Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue<string>()).ToHashSet();
|
||||
var shown = await page.Locator("article[data-uri]").EvaluateAllAsync<string[]>("cards => cards.map(c => c.dataset.uri)");
|
||||
Assert.NotEmpty(expected);
|
||||
Assert.Subset(expected, shown.ToHashSet());
|
||||
|
||||
// exclusive, then deleted
|
||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
||||
article = page.Locator($"article[data-list-id='{listId}']");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||
await article.GetByLabel("Keep its members out of the home feed").CheckAsync();
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue<bool>() == true, 15),
|
||||
"PrivaPub never made the list exclusive");
|
||||
// the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync();
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync();
|
||||
await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Null(await Api(persona, $"/api/v1/lists/{listId}"));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The persona's notification policy from Settings: people it does not follow are filtered, a like from one waits in a
|
||||
// request the Notifications tab leads to, and letting the request in brings the notification back; the policy is put back to accepting everything, and
|
||||
// the stranger's permission is taken away again
|
||||
public sealed class NotificationPolicyTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_strangers_like_waits_in_a_request_the_persona_lets_in()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
var author = personas[0];
|
||||
// (a persona of the town the author does not follow)
|
||||
var stranger = default(Pasture.Persona);
|
||||
foreach (var candidate in personas.Skip(1))
|
||||
if ((await PersonaApi.Call(author, $"/api/v1/accounts/relationships?id[]={candidate.LocalId}"))![0]!["following"]!.GetValue<bool>() == false)
|
||||
{
|
||||
stranger = candidate;
|
||||
break;
|
||||
}
|
||||
Assert.SkipWhen(stranger is null, "the town's first persona follows every other");
|
||||
var strangerAcct = (await PersonaApi.Call(stranger!, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like me, stranger {Guid.NewGuid():N}"[..30], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.notification-policy", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Notifications" }).First;
|
||||
await header.ClickAsync();
|
||||
var panel = page.Locator("[data-notification-policy]");
|
||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
||||
await panel.Locator("[data-policy='for_not_following']").SelectOptionAsync("filter");
|
||||
await panel.Locator("[data-policy-save]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(author, "/api/v2/notifications/policy"))!["for_not_following"]!.GetValue<string>() == "filter", 15), "the policy was never saved");
|
||||
|
||||
await PersonaApi.Call(stranger!, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
Assert.DoesNotContain((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
|
||||
// the Notifications tab says requests are waiting, and leads to them
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
||||
await page.Locator("[data-filtered-requests]").ClickAsync(new() { Timeout = 30000 });
|
||||
var request = page.Locator($"[data-notification-requests] [data-request='{strangerAcct}']");
|
||||
await request.WaitForAsync(new() { Timeout = 30000 });
|
||||
await request.Locator("[data-request-accept]").ClickAsync();
|
||||
await Assertions.Expect(request).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Contains((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, "/api/v2/notifications/policy", HttpMethod.Patch, new JsonObject { ["for_not_following"] = "accept" });
|
||||
// (letting a request in lets its account in for good, which no API takes back)
|
||||
await Pasture.Mongo($"db.NotificationPermission.deleteMany({{AvatarId: '{author.LocalId}', FromActorURI: '{stranger!.ActorUri}'}})");
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Notifications as groups: two likes of one post are one card naming both, the tab's badge counts what is unread, and
|
||||
// reading the tab moves the marker so PrivaPub counts nothing unread
|
||||
public sealed class NotificationTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Two_likes_of_one_post_are_one_card_and_reading_them_clears_the_badge()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 3, "the town has fewer than three personas");
|
||||
var (author, first, second) = (personas[0], personas[1], personas[2]);
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like me {Guid.NewGuid():N}"[..16], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await PersonaApi.Call(first, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await PersonaApi.Call(second, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await Results.Cell("client.render.notification-group", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("[data-unread]").WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").Filter(new() { Has = page.Locator("[data-notification='favourite']") });
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(card.Locator("[data-notification='favourite']")).ToHaveAttributeAsync("data-count", "2");
|
||||
await Assertions.Expect(card).ToContainTextAsync($"{second.Name} and {first.Name} favourited your post");
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(author, "/api/v2/notifications/unread_count"))!["count"]!.GetValue<int>() == 0, 15), "reading the tab left notifications unread");
|
||||
await Assertions.Expect(page.Locator("[data-unread]")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A persona pins its own post from the card, finds it above its posts on its profile, and unpins it. PrivaPub tells
|
||||
// the persona's followers with Add and Remove; SocialPub's tools/pasture/scenarios/pins.sh checks that side.
|
||||
public sealed class PinTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_persona_pins_its_post_from_the_card_and_its_profile_shows_it_first()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"pin me {Guid.NewGuid():N}"[..20];
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue<string>();
|
||||
async Task<bool> Pinned() =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue<string>() == id);
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.pin", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{me}");
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-pin]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(Pinned, 15), "the pin never reached PrivaPub");
|
||||
|
||||
await page.ReloadAsync();
|
||||
var pinnedCard = page.Locator($"[data-pinned] article[data-uri='{post["uri"]!.GetValue<string>()}']");
|
||||
await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
|
||||
Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").CountAsync());
|
||||
|
||||
await pinnedCard.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await Assertions.Expect(pinnedCard.Locator("button[data-pin]")).ToContainTextAsync("Unpin", new() { Timeout = 5000 });
|
||||
await pinnedCard.Locator("button[data-pin]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => !await Pinned(), 15), "the unpin never reached PrivaPub");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Accounts in decePub: found by searching their handle (PrivaPub resolves it), opened as a profile, followed and
|
||||
// unfollowed there; the follow is checked on the account's own server, where the persona must appear as a follower.
|
||||
public sealed class ProfileTests(Browser browser)
|
||||
{
|
||||
static readonly string[] Platforms = ["gts", "mastodon", "akkoma", "misskey", "sharkey"];
|
||||
|
||||
public static TheoryData<string> Origins() => new(Platforms);
|
||||
|
||||
// an account of the platform that the persona does not follow yet, from the town's own sessions
|
||||
static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona)
|
||||
{
|
||||
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject();
|
||||
var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_"))
|
||||
.Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue<string>(), kv.Value!["token"]!.GetValue<string>()))
|
||||
.ToList();
|
||||
return candidates.Last();
|
||||
}
|
||||
|
||||
// the newest GoToSocial account that SocialPub's moves scenario moved (scenarios/moves.sh), as PrivaPub knows it
|
||||
static async Task<(string Old, string New)> Moved()
|
||||
{
|
||||
var start = new System.Diagnostics.ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval",
|
||||
"const a = db.ForeignAvatar.find({ActorURI: {$regex: 'gts.test.users.mvold'}, MovedToURL: {$ne: null}}).sort({_id: -1}).limit(1).toArray()[0];" +
|
||||
"print(a ? a.ActorURI + ' ' + a.MovedToURL : '')" })
|
||||
start.ArgumentList.Add(arg);
|
||||
using var process = System.Diagnostics.Process.Start(start)!;
|
||||
var output = (await process.StandardOutput.ReadToEndAsync()).Trim();
|
||||
await process.WaitForExitAsync();
|
||||
var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length == 2 ? (parts[0], parts[1]) : default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_moved_accounts_profile_says_where_it_went()
|
||||
{
|
||||
var (old, moved) = await Moved();
|
||||
Assert.SkipWhen(old is null, "no moved account: run SocialPub's tools/pasture/interop.sh moves");
|
||||
var persona = Pasture.Personas().First();
|
||||
var handle = old.Split("/users/")[1] + "@gts.test";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.render.moved", "gts", handle, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
||||
var banner = session.Page.Locator("[data-moved]");
|
||||
await banner.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(banner).ToContainTextAsync("@" + moved.Split("/users/")[1] + "@gts.test");
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Origins))]
|
||||
public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform)
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var (_, userName, _) = Stranger(platform, persona);
|
||||
var handle = $"{userName}@{platform}.test";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.action.follow", platform, handle, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(profile).ToContainTextAsync("@" + handle);
|
||||
|
||||
var follow = profile.GetByRole(AriaRole.Button).Filter(new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^(Follow|Unfollow|Cancel the request)$") }).First;
|
||||
if ((await follow.InnerTextAsync()).Trim() != "Follow")
|
||||
{
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
}
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).Not.ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await FollowedOn(platform, userName, persona.UserName), 45),
|
||||
$"{handle} never learnt that {persona.UserName} follows it");
|
||||
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => !await FollowedOn(platform, userName, persona.UserName), 45),
|
||||
$"{handle} still has {persona.UserName} as a follower after the unfollow");
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
|
||||
// whether the peer itself lists the persona as following (or asking to follow) its account
|
||||
static async Task<bool> FollowedOn(string platform, string userName, string persona)
|
||||
{
|
||||
var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}");
|
||||
return result?["followed_by"]?.GetValue<bool>() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
|
||||
.AsArray().Any(a => a?.GetValue<string>()?.StartsWith(persona + "@") == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_own_profile_lists_its_posts()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.render.profile", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}");
|
||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName);
|
||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
|
||||
// PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
|
||||
// from its card
|
||||
public sealed class ReportTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_remote_account_is_reported_from_its_profile()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
|
||||
var targetId = target!["id"]!.GetValue<string>();
|
||||
var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
|
||||
// (the profile's own button: each of its posts' cards has one too)
|
||||
await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
|
||||
var dialog = page.Locator("[data-report-dialog]");
|
||||
await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
|
||||
await dialog.Locator("[data-report-comment]").FillAsync(words);
|
||||
await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
|
||||
await dialog.Locator("[data-report-send]").ClickAsync();
|
||||
await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
|
||||
var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
|
||||
.FirstOrDefault(r => r!["comment"]?.GetValue<string>() == words);
|
||||
Assert.NotNull(report);
|
||||
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
|
||||
report["forwarded"]!.GetValue<bool>()));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
||||
var (author, fan) = (personas[0], personas[1]);
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
|
||||
{
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator($"button[{button}]").ClickAsync();
|
||||
var dialog = card.Locator($"[data-interacted-by='{list}']");
|
||||
await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await Assertions.Expect(dialog).ToHaveCountAsync(0);
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Settings as a persona: its profile saved (update_credentials) and read back from PrivaPub, a server blocked and
|
||||
// unblocked, the follows exported and servers imported in Mastodon's files, and a new persona made; what a test changes
|
||||
// is put back through the API afterwards, but for the persona (PrivaPub keeps every persona, so the test makes it under
|
||||
// the scenarios' own root, never a town one)
|
||||
public sealed class SettingsTests(Browser browser)
|
||||
{
|
||||
static async Task<ILocator> Open(IPage page, string section)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||
await header.ClickAsync();
|
||||
return header.Locator("xpath=..");
|
||||
}
|
||||
|
||||
static JsonObject Restore(JsonNode account)
|
||||
{
|
||||
var fields = new JsonObject();
|
||||
var source = account["source"]!;
|
||||
var kept = source["fields"]!.AsArray();
|
||||
for (var i = 0; i < 4; i++)
|
||||
fields[i.ToString()] = new JsonObject
|
||||
{
|
||||
["name"] = i < kept.Count ? kept[i]!["name"]!.GetValue<string>() : "",
|
||||
["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue<string>() : ""
|
||||
};
|
||||
return new JsonObject
|
||||
{
|
||||
["display_name"] = account["display_name"]!.GetValue<string>(),
|
||||
["note"] = source["note"]!.GetValue<string>(),
|
||||
["locked"] = account["locked"]!.GetValue<bool>(),
|
||||
["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue<string>() },
|
||||
["fields_attributes"] = fields
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_profile_is_saved_from_settings()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
var original = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||
var mark = Guid.NewGuid().ToString("N")[..6];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.profile-edit", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Profile");
|
||||
var editor = panel.Locator("[data-profile-editor]");
|
||||
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(editor.Locator("[data-profile-name]")).ToHaveValueAsync(original["display_name"]!.GetValue<string>(),
|
||||
new() { Timeout = 10000 });
|
||||
|
||||
await editor.Locator("[data-profile-name]").FillAsync($"Edited {mark}");
|
||||
await editor.Locator("[data-profile-note]").FillAsync($"A biography written in the e2e tests {mark}");
|
||||
var row = editor.Locator("[data-profile-field='3'] input");
|
||||
await row.Nth(0).FillAsync("e2e");
|
||||
await row.Nth(1).FillAsync(mark);
|
||||
await editor.Locator("[data-profile-privacy]").SelectOptionAsync("Unlisted");
|
||||
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
|
||||
var saved = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||
Assert.Equal($"Edited {mark}", saved["display_name"]!.GetValue<string>());
|
||||
Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue<string>());
|
||||
Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue<string>());
|
||||
Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue<string>() == "e2e" && f["value"]!.GetValue<string>() == mark);
|
||||
// the persona chooser shows the new name at once
|
||||
await Assertions.Expect(page.GetByText($"Edited {mark}", new() { Exact = true }).First).ToBeVisibleAsync(new() { Timeout = 5000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, Restore(original));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_is_blocked_and_unblocked_from_settings()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
const string server = "blocked-by-e2e.example";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.domain-block", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Mutes/Blocks");
|
||||
var servers = panel.Locator("[data-domain-blocks]");
|
||||
await servers.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await servers.Locator("[data-domain-new]").FillAsync($"https://{server.ToUpperInvariant()}/about");
|
||||
await servers.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
Assert.Contains(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||
|
||||
await servers.Locator($"[data-domain='{server}']").GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.DoesNotContain(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
// the follows export is Mastodon's following_accounts.csv, naming every account in full
|
||||
[Fact]
|
||||
public async Task The_follows_are_exported_in_mastodons_format()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var following = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray();
|
||||
Assert.SkipWhen(following.Count == 0, $"{persona.UserName} follows nobody in this town");
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.export", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Data import/export");
|
||||
var button = panel.Locator("[data-export-kind='Follows']");
|
||||
await button.WaitForAsync(new() { Timeout = 30000 });
|
||||
var download = await page.RunAndWaitForDownloadAsync(() => button.ClickAsync(), new() { Timeout = 60000 });
|
||||
Assert.Equal("following_accounts.csv", download.SuggestedFilename);
|
||||
var lines = (await File.ReadAllLinesAsync((await download.PathAsync())!)).ToList();
|
||||
Assert.Equal("Account address,Show boosts,Notify on new posts,Languages", lines[0]);
|
||||
foreach (var account in following)
|
||||
{
|
||||
var acct = account!["acct"]!.GetValue<string>();
|
||||
var full = acct.Contains('@') ? acct : $"{acct}@privapub.test";
|
||||
Assert.Contains(lines, l => l.StartsWith(full + ",", StringComparison.Ordinal));
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blocked_servers_are_imported_from_a_mastodon_file()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
string[] servers = ["imported-one.example", "imported-two.example"];
|
||||
var csv = Path.Combine(Path.GetTempPath(), $"blocked_domains-{Guid.NewGuid():N}.csv");
|
||||
await File.WriteAllTextAsync(csv, string.Join("\n", servers) + "\n");
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.import", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Data import/export");
|
||||
var import = panel.Locator("[data-import]");
|
||||
await import.WaitForAsync(new() { Timeout = 30000 });
|
||||
await import.Locator("[data-import-kind]").SelectOptionAsync("ServerBlocks");
|
||||
await import.Locator("[data-import-file]").SetInputFilesAsync(csv);
|
||||
await import.Locator("[data-import-start]").ClickAsync();
|
||||
await Assertions.Expect(import.Locator("[data-import-report]")).ToContainTextAsync("2 of 2 done, 0 could not be.", new() { Timeout = 30000 });
|
||||
var blocked = (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()).ToList();
|
||||
Assert.All(servers, s => Assert.Contains(s, blocked));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var server in servers)
|
||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||
File.Delete(csv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_persona_is_made_and_used()
|
||||
{
|
||||
var name = $"e2e_{Guid.NewGuid():N}"[..12];
|
||||
await using var session = await SignIn.As(browser, new Pasture.Persona("alice", "alice", "pastureroot", "Pasture-Pass-1!", null, null, null));
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.persona-create", "privapub", "pastureroot", async () =>
|
||||
{
|
||||
var panel = await Open(page, "Personas");
|
||||
var form = panel.Locator("[data-persona-new]");
|
||||
await form.WaitForAsync(new() { Timeout = 30000 });
|
||||
await form.Locator("[data-persona-username]").FillAsync(name);
|
||||
await form.Locator("[data-persona-name]").FillAsync($"Made by e2e {name}");
|
||||
await form.Locator("[data-persona-biography]").FillAsync("A persona the end-to-end tests made.");
|
||||
await form.Locator("[data-persona-create]").ClickAsync();
|
||||
var made = panel.Locator($"[data-persona='{name}']");
|
||||
await made.WaitForAsync(new() { Timeout = 15000 });
|
||||
|
||||
await made.GetByRole(AriaRole.Button, new() { Name = "Use" }).ClickAsync();
|
||||
await Assertions.Expect(made).ToContainTextAsync("in use", new() { Timeout = 15000 });
|
||||
await Assertions.Expect(page.GetByText($"Made by e2e {name}", new() { Exact = true }).First).ToBeVisibleAsync();
|
||||
using var http = Pasture.Http();
|
||||
var webfinger = await http.GetAsync($"{Pasture.Api}/.well-known/webfinger?resource=acct:{name}@privapub.test");
|
||||
Assert.True(webfinger.IsSuccessStatusCode, $"{name} is not found by WebFinger");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The home feed hears of a new post and of its deletion through PrivaPub's streaming API, in seconds, where polling
|
||||
// would take up to 45
|
||||
public sealed class StreamingTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_post_made_elsewhere_waits_behind_the_new_button_and_its_deletion_takes_it_away()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"streamed {Guid.NewGuid():N}"[..21];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
JsonNode post = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.render.stream", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
// the stream opens once the first page is shown
|
||||
await Task.Delay(1500);
|
||||
|
||||
post = await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "unlisted" });
|
||||
var fresh = page.GetByRole(AriaRole.Button, new() { Name = "1 new" });
|
||||
await fresh.WaitForAsync(new() { Timeout = 10000 });
|
||||
await fresh.ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{post!["uri"]!.GetValue<string>()}']");
|
||||
await Assertions.Expect(card).ToContainTextAsync(text, new() { Timeout = 5000 });
|
||||
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
post = null;
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (post is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A remote thread opened in decePub fills with the replies PrivaPub never received: opening it has PrivaPub read the
|
||||
// thread from its server (Mastodon's FEP-7888 context), and the page reads the thread again a few seconds later
|
||||
public sealed class ThreadTests(Browser browser)
|
||||
{
|
||||
const string Platform = "mastodon";
|
||||
const string Author = "elif_ms06";
|
||||
// followed by no persona, so Mastodon never delivers her reply to PrivaPub
|
||||
const string Outsider = "gigi_ms28";
|
||||
|
||||
static async Task<JsonNode> Drive(string user, string verb, JsonObject parameters) =>
|
||||
await Pasture.Town("drive", Platform, user, verb, parameters.ToJsonString());
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_thread_brings_the_replies_that_never_reached_privapub()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var mark = Guid.NewGuid().ToString("N")[..8];
|
||||
var root = await Drive(Author, "post", new JsonObject { ["text"] = $"a thread to open {mark}", ["visibility"] = "public" });
|
||||
var rootUri = root["uri"]!.GetValue<string>();
|
||||
string replyUri = null;
|
||||
try
|
||||
{
|
||||
var reply = await Drive(Outsider, "post", new JsonObject
|
||||
{
|
||||
["text"] = $"said where nobody listens {mark}", ["visibility"] = "public", ["reply_to_uri"] = rootUri
|
||||
});
|
||||
replyUri = reply["uri"]!.GetValue<string>();
|
||||
var found = await PersonaApi.Call(persona, $"/api/v2/search?q={Uri.EscapeDataString(rootUri)}&resolve=true&type=statuses");
|
||||
var rootId = found!["statuses"]!.AsArray().Single()!["id"]!.GetValue<string>();
|
||||
await Task.Delay(3000);
|
||||
Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue<bool>() == true,
|
||||
$"{Outsider}'s reply reached PrivaPub by delivery, so the thread proves nothing");
|
||||
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.thread-backfill", Platform, persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{rootId}");
|
||||
await page.Locator($"article[data-uri='{rootUri}']").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var answer = page.Locator($"article[data-uri='{replyUri}']");
|
||||
await Assertions.Expect(answer).ToContainTextAsync($"said where nobody listens {mark}", new() { Timeout = 25000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (replyUri is not null)
|
||||
await Drive(Outsider, "delete", new JsonObject { ["uri"] = replyUri });
|
||||
await Drive(Author, "delete", new JsonObject { ["uri"] = rootUri });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A vote in another server's poll and an emoji reaction to another server's post, done in decePub and counted on that
|
||||
// server. The first of either asks the persona, once, to accept that both are public (an owner decision).
|
||||
public sealed class VoteAndReactTests(Browser browser)
|
||||
{
|
||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
||||
|
||||
static TheoryData<string, string> Pick(Func<JsonNode, bool> wanted, params string[] origins)
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
||||
foreach (var origin in origins)
|
||||
{
|
||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin && wanted(i)
|
||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
||||
if (item is not null)
|
||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string, string> Polls() =>
|
||||
Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
|
||||
|
||||
public static TheoryData<string, string> Reactable() =>
|
||||
Pick(i => i["visibility"]!.GetValue<string>() is "public" or "unlisted", "misskey", "sharkey", "akkoma");
|
||||
|
||||
|
||||
async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference)
|
||||
{
|
||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
||||
var personas = Pasture.Personas();
|
||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
||||
.Last(p => p is not null);
|
||||
var session = await SignIn.As(browser, reader);
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
return (item, session, card);
|
||||
}
|
||||
|
||||
// the one-time notice, when this persona has not seen it yet
|
||||
internal static async Task AcceptNoticeIfAsked(IPage page)
|
||||
{
|
||||
var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" });
|
||||
try
|
||||
{
|
||||
await proceed.WaitForAsync(new() { Timeout = 3000 });
|
||||
await proceed.ClickAsync();
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<JsonObject> Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Polls))]
|
||||
public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference)
|
||||
{
|
||||
var (item, session, card) = await Open(reference);
|
||||
await using var _ = session;
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
var choices = card.Locator("[data-poll] input");
|
||||
Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed");
|
||||
await Results.Cell("client.action.vote", origin, reference, async () =>
|
||||
{
|
||||
var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0;
|
||||
await choices.First.CheckAsync();
|
||||
await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync();
|
||||
await AcceptNoticeIfAsked(session.Page);
|
||||
await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0) > before, 45),
|
||||
$"the vote never counted on {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Reactable))]
|
||||
public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference)
|
||||
{
|
||||
var (item, session, card) = await Open(reference);
|
||||
await using var _ = session;
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
const string emoji = "🔥";
|
||||
await Results.Cell("client.action.react", origin, reference, async () =>
|
||||
{
|
||||
static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue<int>() ?? 0;
|
||||
var chip = card.Locator("[data-reactions] button").Filter(new() { HasText = emoji }).First;
|
||||
// a run cut short may have left the persona's reaction: start from the withdrawn state
|
||||
if (await chip.CountAsync() > 0 && ((await chip.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"))
|
||||
{
|
||||
var left = Count(await Row(origin, uri));
|
||||
await chip.ClickAsync();
|
||||
await Pasture.Eventually(async () => Count(await Row(origin, uri)) < left, 60);
|
||||
}
|
||||
var before = Count(await Row(origin, uri));
|
||||
await card.Locator("footer [title='React']").First.ClickAsync();
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = emoji, Exact = true }).First.ClickAsync();
|
||||
await AcceptNoticeIfAsked(session.Page);
|
||||
await Assertions.Expect(chip).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"), new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) > before, 45),
|
||||
$"the reaction never reached {origin}");
|
||||
|
||||
await chip.ScrollIntoViewIfNeededAsync();
|
||||
await chip.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) == before, 60),
|
||||
$"withdrawing the reaction never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user