28 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 511edf3591 The composer completes mentions and hashtags
Build / Build (push) Successful in 31s
Deploy / decepub.thepra.dev (push) Successful in 1m34s
A mention or hashtag being written at the end of the words is offered, once typing pauses, the accounts or tags
PrivaPub already knows (/api/v2/search, never resolving); a click writes it out. e2e: a town persona's handle,
begun, is offered and completed. CLAUDE.md also says that FEP-5624's moderated comments (PeerTube) show as pending
on a card, as GoToSocial's policies do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 658683f816 The persona's notification policy, and what it filtered
Settings has a Notifications section, also 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), and the filtered ones, one request per account, let in (with that account from then on) or
dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the
tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 a0f07abf8c Reports, and who liked or boosted a post
Another's profile and posts can be reported from the client (/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 (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:19 +02:00
thepraandClaude Opus 5.5 bb68ff8d5e An edited post's versions open from its card
The card marks an edited post with a button that opens its versions, newest first (/statuses/:id/history), each with
its time, warning and words; PrivaPub now has a remote post's earlier versions from Pleroma's formerRepresentations
too. The composer's e2e test opens the history after its edit. The wall test retries smuser's post while Smithereen
has not yet had the persona's Accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 13:03:19 +02:00
thepraandClaude Opus 5.5 03824babbc A post on a wall says whose, and the persona takes posts off its own
PrivaPub hosts what a persona's followers write on its wall (Smithereen's, FEP-400e) and shows posts written on walls
elsewhere to their owners' followers. A card names the wall (privapub.wall, data-wall, linking the profile), and one
written on the persona's own wall is taken off from the card's menu, which PrivaPub tells as a Remove and Smithereen
applies as a deletion. WallTests checks it against Smithereen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 08:38:49 +02:00
thepraandClaude Opus 5.5 23dbc68fb4 Coming back to the feed shows it as it was left
Leaving the feed keeps it in Services/FeedMemory.cs (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, older pages included,
instead of reading it anew; newer posts come behind the "new" button and through the stream as before.
tests/e2e FeedMemoryTests goes to a thread and back without a new read of the home timeline; the whole suite passes
(123, 5 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:21:41 +02:00
thepraandClaude Opus 5.5 5a7e1804a9 Articles fold in a feed, with their cover, excerpt and a Read button
An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes
with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened
in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests
checks it with a WriteFreely blog; the render matrix still passes (70).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:00:57 +02:00
thepraandClaude Opus 5.5 2f29872745 The globe looks at the feed's posts only when the feed hands them over
GlobePanel built a string of every located post's place on each of its renders, the stats overlay's once a second
included: with two thousand posts loaded, a hundred kilobytes of formatting every second while the page sat idle. It
now looks at the posts only when the feed hands them over, and compares a hash. The feed perf test (Category=Perf)
loads 2000 posts as before (49 s, against 55.6 s for 1740 in the baseline run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:17:48 +02:00
thepraandClaude Opus 5.5 da98addf76 Pinned posts on profiles, and pinning from the card
A profile shows the posts the account pins above its posts (pinned=true: for an account elsewhere, what it features,
which PrivaPub now keeps); the persona pins and unpins its own public and unlisted posts from the card's menu, and
PrivaPub tells its followers. tests/e2e/Actions/PinTests.cs pins from the card and finds the post first on the profile;
the whole e2e suite passes against the reseeded village (122, 4 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:04:31 +02:00
thepraandClaude Opus 5.5 a5178a67b1 The feed stays light at thousands of posts
A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's
home, thousands of posts from SocialPub's load runs, to 2000 posts. Before:
100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the
main thread was busy almost all of that time, and the page held 224 000 DOM
nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did.

- A card re-renders only when its own events run or its render key changes
  (Content.RenderKey: the post, its status, notification, conversation,
  place and grouping, where it is shown, the persona and the connection).
  2000 cards then took 122 s.
- The list is a <Virtualize>: only the cards near the view are rendered.
  The page now holds about 3 000 DOM nodes and 145 MB however long the feed
  is, and each page of older posts costs the same (about 1.9 s in the test's
  scroll loop) at the 100th post as at the 1700th. The feed tells how many
  posts it holds in data-feed-count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:30:45 +02:00
thepraandClaude Opus 5.5 f9de55ffd5 A moved account's profile says where it went
PrivaPub now fills `moved` once a move is believed; the profile shows it, linking
the new account's profile. The e2e opens the account SocialPub's moves scenario
moved and finds the new one named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:39:56 +02:00
thepraandClaude Opus 5.5 228708c328 Settings: change the login's password, or delete the login
A Login section in Settings changes the root's password (the current one
given) and deletes the whole login, every persona with it, after its
password and an explicit "I understand"; the client then signs out. Both
use PrivaPub's existing /clientapi/user routes.

The e2e signs up a login of its own, changes its password and deletes it,
checking each against PrivaPub's login. Signing in now waits for either
the feed or an enabled persona chooser, so a login with one persona (which
goes on by itself) and a new persona's empty feed both sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:32:47 +02:00
thepraandClaude Opus 5.5 0bbab720a5 Groups: make, join, run and leave communities and circles
The Groups page (/groups, in the menu) is where a persona makes a community
or a circle (with its invitation code and an optional password), joins one
by invitation and leaves one. For the groups it runs it edits them, shows
the invitation, lets in or declines the requests to join, and takes members
out, through PrivaPub's new /clientapi/group/members, reject and remove.
The e2e makes a circle, joins it as another persona with the code, and has
the owner take that member out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:24:39 +02:00
thepraandClaude Opus 5.5 2024798732 Custom emoji, downvotes, blurhash and focal points on the cards
What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
  posts, names, content warnings, bios and fields: only the shortcodes the
  server listed, only http(s) pictures, never inside a tag, the shortcode
  kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
  small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
  tentative or cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:55:39 +02:00
thepraandClaude Opus 5.5 5e8fdc43af An event card joins and leaves the event
PrivaPub lets a persona join another server's event (owner decision
2026-10-05). The card of a remote event shows the persona's participation
(pending, accepted, rejected) with Join or Leave, except for events by
invitation or taken on another site. Joining is public on the event's
server, so the first join asks the persona once, as reactions and votes do,
remembered apart from them (PublicAct).

The e2e joins the event SocialPub's Mobilizon scenario keeps, checks the
participant on Mobilizon and PrivaPub's answer, and leaves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 15:19:07 +02:00
thepraandClaude Opus 5.5 6f8472bc9f A reply or boost awaiting its author's approval says so
GoToSocial lets an author approve replies, likes and boosts first. PrivaPub now asks that author and marks the
persona's reply or boost privapub.approval: pending until the answer, or rejected; the card shows which, with an
hourglass or a cross. One the policy shuts out is refused (422) and shown as the error it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:44:09 +02:00
thepraandClaude Opus 5.5 61631bb0a7 The composer says which language a post is in
A post went out in the reader's own interface language whatever it was
written in. The composer now has a language picker (each language named in
itself), starting at the persona's default from Settings, else the
interface's; a reply starts in its thread's language. The e2e poll test
picks Italian and finds it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:06:12 +02:00
thepraandClaude Opus 5.5 d7d836ba39 Settings, Administration and conversations; threads that fill in
Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
  name, biography and fields as written, locked, bot, discoverable,
  indexable, and how its posts start (visibility, language, sensitive
  media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
  there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
  and imported as the CSV files Mastodon writes (PortabilityService): an
  import resolves each row through PrivaPub, one at a time, and can stop.

Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).

The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.

A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.

The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 03f4f2ad53 E2E: a known gap skips its test, and reactions start clean
A test whose failure an open gap of the town's gaps.json names (a peer's
own bug, such as Misskey keeping its renote count after our Undo, G-0005)
is now skipped with the gap's id instead of failing the run; the report
still counts the cell an expected failure. The reaction test starts by
withdrawing a reaction a run cut short left behind, so one failure no
longer makes the next run fail too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 5c11727f58 Polls, quotes and edits from the composer
The composer can hold a poll instead of media (two to four choices, a
length, one choice or several), quote a post (offered on a card when the
server says the persona may quote it), and edit the persona's own post: its
words and warning come from /source, and saving keeps its media,
visibility and poll. A card's menu offers Edit and Quote, and now opens
sideways: upwards, the first card's menu was cut off by the top of the
feed. The e2e tests publish a poll, edit a post and quote it, each checked
on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:42:42 +02:00
thepraandClaude Opus 5.5 a4d9831691 Notifications in groups, and what is unread
The Notifications tab reads grouped notifications (/api/v2/notifications):
likes and boosts of one post, or follows of one hour, are one card naming
both accounts of a pair, or the newest and how many others, with their
avatars. A notification the stream tells joins its group and waits on top.
The tab's badge counts what is unread; opening the tab moves the
notifications marker to the newest shown, which reads it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:35:23 +02:00
thepraandClaude Opus 5.5 963565b316 The feed hears new posts as they come
decePub opens PrivaPub's streaming API (one WebSocket for the persona, its
token given as the socket's protocol, never in a URL) and subscribes to the
stream of the feed in view: home, a list, local, federated, a hashtag or
notifications. New posts wait behind the "new" button as polled ones did;
a deletion or an edit applies at once. Polling stays for the feeds PrivaPub
does not stream and for any feed while the socket is down, which opens again
after a pause growing to a minute. The e2e test sees a post made elsewhere
in seconds and its deletion take the card away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:15:54 +02:00
thepraandClaude Opus 5.5 6d5cf17723 Posts for later, hashtags followed, and Explore
The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:53:51 +02:00
thepraandClaude Opus 5.5 9a752d9288 Filters: made on /filters, applied by each card where it is
The persona's filters (PrivaPub's /api/v2/filters): made, given words and
whole-word rules, contexts, an action and an expiry on /filters (linked from
Settings), and deleted; posts filtered one by one can be taken out. Every
status says which filters it matches; each page tells its cards where they
are (home and lists, notifications, public feeds, the thread around the
opened post, profiles), and a card leaves its post out (hide), folds it
behind the filter's title with a way to show it anyway (warn), or blurs its
media (blur). The e2e test folds and then hides a post of the persona's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:10:27 +02:00
thepraandClaude Opus 5.5 e2491de16e Lists: made on /lists, read as a tab of the feed
A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a
replies policy and made exclusive on /lists, filled from the accounts it
follows (accounts/search?following=true), and read as the feed's Lists tab.
A followed account's profile has a Lists menu to put it in or take it out.
ListService does the calls; the e2e tests check each step against
PrivaPub's own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:49:51 +02:00
thepraandClaude Opus 5.5 011bafb6af End-to-end tests against the pasture; profiles, follows, search, votes, reactions
tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already
cached, never downloaded, never in CI) runs the published app against
SocialPub's pasture and its town: run.sh publishes it with the Pasture
environment (wwwroot/appsettings.Pasture.json, published only then; the
deploy fails if it ever ships) and serves it as https://decepub.test. The
tests sign in through the real login page and persona chooser, check that
every request goes only to decepub.test and privapub.test with the right
credential on the right API, render one post of each kind from each
platform as its author wrote it, and check every action on the other
side of the wire (town.sh stored): favourites, boosts and replies with
their undoing, follows from a profile, votes, reactions. Each test is also
a cell of the town's report (out/client.jsonl). They found two PrivaPub
bugs fixed there (a blocked author's status answered null; sensitive
remote media invented a content warning) and a reply addressed to nobody.

New in the app, all checked by those tests:
- profiles at /account/{id}: header, bio, fields, counts, the account's
  posts as posts, posts and replies, or media, and follow, unfollow,
  cancel a request, mute and block (AccountService); post authors and
  notifications open them in the app;
- search at /search: accounts, posts and hashtags, a handle or an address
  resolved by PrivaPub;
- poll voting on the card, and emoji reactions shown and given with one
  tap; before a persona's first reaction or vote it is told once that
  both are public (PublicNoticeService, the owner's decision);
- follow requests answered from their notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:23:18 +02:00
thepraandClaude Opus 5.5 0f68e74e38 The globe under the Sun of now or of a post, with borders and country names
Build / Build (push) Successful in 46s
Deploy / decepub.thepra.dev (push) Successful in 1m52s
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar
coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side,
and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered,
focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The
panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its
hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt,
distance, irradiance, day length and the next equinox or solstice.

Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer:
largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural
Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 20:23:42 +02:00
thepraandClaude Opus 5.5 69d0a641fe The globe draws the poles as they are, and nothing while idle
The globe is a UV sphere: towards the poles its triangles narrow to
slivers, and texture coordinates interpolated across them pinched the
map into a star around each pole. Each pixel now computes its own
coordinates from its exact point on the sphere, with seam-safe mipmap
gradients; the map wraps horizontally and gets 8x anisotropic
filtering.

That showed a flaw in the day texture: NASA's mosaic has a lighter
strip at its east edge near the poles, a line from each pole once drawn
exactly. The day texture is now built from NASA's original Blue Marble
(December 2004) by tools/globe/prepare-day-texture.py, which blends
that strip into the map where both sides of the seam agree; same
4096x2048 size, provenance and hashes in SOURCES.md.

globe.gl redrew 60 times a second even when nothing moved; it now draws
only during camera flights, hover pulses, auto-rotation and the
reader's drags, so an idle globe costs no GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 17:46:44 +02:00
125 changed files with 13750 additions and 323 deletions

No files matched your search

+3
View File
@@ -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
+4
View File
@@ -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/
+149 -16
View File
@@ -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
+19
View File
@@ -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
{
}
+138
View File
@@ -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."]);
}
}
+122
View File
@@ -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);
}
}
+209
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+88 -3
View File
@@ -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);
}
+178 -12
View File
@@ -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; }
}
}
+59
View File
@@ -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();
}
+39
View File
@@ -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;
}
+62
View File
@@ -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();
}
+76
View File
@@ -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");
}
}
+150
View File
@@ -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();
}
}
+229
View File
@@ -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);
}
}
+78
View File
@@ -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."]);
}
}
+210
View File
@@ -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."]);
}
}
+1
View File
@@ -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
};
+47
View File
@@ -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);
}
}
}
+18
View File
@@ -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", "中文")
];
}
}
+23
View File
@@ -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)
{
+27
View File
@@ -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
{
}
+76
View File
@@ -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";
}
}
+20
View File
@@ -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)]
+2
View File
@@ -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
View File
@@ -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,
+44
View File
@@ -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; } = [];
}
+33
View File
@@ -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);
}
+80
View File
@@ -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; }
}
+29
View File
@@ -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; } = [];
}
+39
View File
@@ -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>
+103
View File
@@ -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; }
}
+11
View File
@@ -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
+32
View File
@@ -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; }
}
+101
View File
@@ -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;
}
}
}
+19
View File
@@ -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
}
+18
View File
@@ -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;
}
+10
View File
@@ -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
}
}
+10
View File
@@ -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
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ namespace decePubClient.Models.Types
Notifications,
Favourites,
Bookmarks,
Tag
Tag,
Lists
}
}
+35 -5
View File
@@ -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">
+58 -3
View File
@@ -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();
}
}
+271
View File
@@ -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("");
}
}
+20
View File
@@ -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;
}
+399
View File
@@ -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; }
}
}
+369
View File
@@ -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
View File
@@ -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; }
+334
View File
@@ -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; }
}
}
+371
View File
@@ -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");
}
+144
View File
@@ -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."]);
}
}
+132
View File
@@ -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
View File
@@ -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>
+9
View File
@@ -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>()
File diff suppressed because it is too large. Load diff
+1138 -1
View File
File diff suppressed because it is too large. Load diff
+298
View File
@@ -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);
}
}
}
+104
View File
@@ -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);
}
}
}
+31
View File
@@ -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)
{
+85
View File
@@ -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);
}
}
}
+51
View File
@@ -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 = [];
}
}
}
+73
View File
@@ -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();
}
}
}
+64 -1
View File
@@ -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();
}
}
}
+10
View File
@@ -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);
+127
View File
@@ -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);
}
}
}
+279
View File
@@ -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;
}
}
}
}
+48
View File
@@ -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
View File
@@ -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)
{
+191
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -11,3 +11,4 @@
</div>
<ToastHost />
<PublicNoticeHost />
+27 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+4
View File
@@ -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 -1
View File
@@ -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>
+42
View File
@@ -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
+67
View File
@@ -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);
}
}
}
+160
View File
@@ -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();
});
}
}
}
+73
View File
@@ -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);
}
}
}
+63
View File
@@ -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);
}
}
}
+140
View File
@@ -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);
}
}
}
}
+58
View File
@@ -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);
}
}
}
}
+110
View File
@@ -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);
}
}
}
+70
View File
@@ -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();
}
}
}
+47
View File
@@ -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);
}
}
}
+72
View File
@@ -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);
}
}
}
}
+58
View File
@@ -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);
}
}
}
+115
View File
@@ -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);
}
}
}
}
+52
View File
@@ -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);
}
}
}
}
+55
View File
@@ -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);
}
}
}
}
+118
View File
@@ -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);
}
}
}
+84
View File
@@ -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);
}
}
}
}
+208
View File
@@ -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);
}
}
}
+52
View File
@@ -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);
}
}
}
}
+61
View File
@@ -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 });
}
}
}
}
+123
View File
@@ -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