R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s
Build / Build (push) Successful in 1m8s
Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice. A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with "describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting. MediaType.Documents goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
b731d8c8c0
commit
b8d7858acb
25 files changed
+1538
-56
No files matched your search
@@ -159,16 +159,16 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
navigation, which closes every overlay and goes on.
|
navigation, which closes every overlay and goes on.
|
||||||
- No history entries are pushed.
|
- No history entries are pushed.
|
||||||
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
||||||
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and
|
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
|
||||||
signing out.
|
group, dropping a scheduled post and signing out.
|
||||||
- **On a touch screen:**
|
- **On a touch screen:**
|
||||||
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
||||||
pinned to the globe with a mouse only;
|
pinned to the globe with a mouse only;
|
||||||
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||||
- pictures open in `Components/MediaViewer.razor`.
|
- pictures open in `Components/MediaViewer.razor`.
|
||||||
- **The feed on a phone:**
|
- **The feed on a phone:**
|
||||||
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
|
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
|
||||||
stats' Details are in More, and the author's picture is in the header;
|
the nerd stats' Details are in More, and the author's picture is in the header;
|
||||||
- a new post starts from a floating button;
|
- a new post starts from a floating button;
|
||||||
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
||||||
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||||
@@ -288,24 +288,31 @@ Never redeclare the base's members in a component.
|
|||||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||||
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
threads, mute, block, poll votes, downvotes, who may quote the persona's posts and revoking a quote of one, emoji
|
||||||
|
reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||||||
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||||||
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
||||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
block, mute, follow requests, the persona's followers and following and removing a follower, `/api/v2/search`, whose
|
||||||
|
handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
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
|
`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`
|
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
|
||||||
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and
|
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads, and deleting those no post holds),
|
||||||
|
`GroupService`, `InstanceService` and
|
||||||
`ProvenanceService`.
|
`ProvenanceService`.
|
||||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
||||||
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||||
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
|
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
|
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,
|
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`).
|
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:`
|
- **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
|
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`
|
post's downvotes next to its favourites with the persona's own as a toggle (`data-downvote`, in More on a phone as
|
||||||
|
`data-downvote-phone`; `StatusAction.Downvote`/`Undownvote`, PrivaPub's `/statuses/:id/downvote|undownvote`, answered
|
||||||
|
with the post, its `privapub.votes.downvoted` set and its favourite dropped). PrivaPub sends votes on every post, so
|
||||||
|
the card offers the toggle where they mean something (`Content.CanDownvote`: a threadiverse server's post, a Page, or
|
||||||
|
one with downvotes; never the persona's own). It also shows blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
||||||
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
||||||
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
|
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
|
||||||
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
|
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
|
||||||
@@ -319,6 +326,12 @@ Never redeclare the base's members in a component.
|
|||||||
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
||||||
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
|
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
|
||||||
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
|
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
|
||||||
|
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
|
||||||
|
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
|
||||||
|
lists them to the persona alone (`hide_collections`), so another profile keeps plain counts. A follower is removed
|
||||||
|
there (`remove_from_followers`, `data-follower-remove`, asked first). The follow requests waiting for the persona show
|
||||||
|
above its posts (`Components/FollowRequestsPanel.razor`, `data-follow-request`, accepted or refused; hidden while none
|
||||||
|
waits), and the Notifications tab opens the same panel in a sheet while some wait (`data-follow-requests-open`).
|
||||||
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
|
- **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
|
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.
|
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
||||||
@@ -334,7 +347,11 @@ Never redeclare the base's members in a component.
|
|||||||
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
|
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,
|
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
|
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
|
||||||
its media (blur).
|
its media (blur). Another's post goes into a filter from its card's menu ("Filter this post", `data-filter-post`,
|
||||||
|
`Components/FilterPostDialog.razor`: `POST /api/v2/filters/:id/statuses`, a filter that holds it already says so), and
|
||||||
|
the card reads the post again so the filter applies at once. On /filters the posts a filter holds are read by id when
|
||||||
|
it opens (`TimelineService.Read`, `/api/v1/statuses?id[]=`) and show their author and words, linking their thread
|
||||||
|
(`data-filter-status`), or say "Post no longer available" (`data-filter-status-missing`).
|
||||||
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
|
- **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
|
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.
|
post opened in its thread shows whole.
|
||||||
@@ -347,7 +364,9 @@ Never redeclare the base's members in a component.
|
|||||||
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||||
- the ids are kept, so a post that fails never uploads its media again;
|
- the ids are kept, so a post that fails never uploads its media again;
|
||||||
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||||
- a description changed after the upload is PUT before the post.
|
- a description changed after the upload is PUT before the post;
|
||||||
|
- a file taken out before posting is deleted from PrivaPub (`MediaService.Discard`, `DELETE /api/v1/media/:id`,
|
||||||
|
`data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
|
||||||
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
||||||
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
||||||
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||||
@@ -364,6 +383,12 @@ Never redeclare the base's members in a component.
|
|||||||
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
|
- **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
|
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.
|
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
|
||||||
|
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
|
||||||
|
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
|
||||||
|
`data-quote-policy-choice`: anyone, its followers or nobody, `PUT /api/v1/statuses/:id/interaction_policy` with
|
||||||
|
`quote_approval_policy`; the policy is read back from `quote_approval.automatic`, `StatusService.QuotePolicyOf`). A
|
||||||
|
post by someone else quoting one of them has "Revoke quote" in its menu (`data-revoke-quote`, asked first; `POST
|
||||||
|
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
|
||||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
|
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
|
||||||
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
|
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
|
||||||
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
|
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
|
||||||
@@ -390,6 +415,17 @@ Never redeclare the base's members in a component.
|
|||||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||||
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
||||||
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||||
|
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
|
||||||
|
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
|
||||||
|
ledger's events and the server's own days. Counted per server, never per account.
|
||||||
|
- A server's name opens `HostDetailDialog` (`data-statistics-host-open`, `/hosts/{host}?days=`): what it says of itself
|
||||||
|
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
|
||||||
|
ledger's `channel:…:…:outcome` keys like the servers table; `live` for a day not rolled up yet), its last events, and
|
||||||
|
"Describe again" (`data-host-describe`, `POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
|
||||||
|
- Events (`data-statistics-events`, `EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
|
||||||
|
time, and "Load more" asks for those `before` the last one's time.
|
||||||
|
- The server's days (`data-statistics-server`, `/server?days=`): its client API's requests by status class, the
|
||||||
|
documents it served, and its ledger written, dropped and failed.
|
||||||
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
|
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
|
||||||
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
|
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
|
||||||
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
||||||
@@ -402,7 +438,9 @@ Never redeclare the base's members in a component.
|
|||||||
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
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
|
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
|
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
|
||||||
links there while requests wait.
|
links there while requests wait. A notification is dismissed from its line (`data-notification-dismiss`,
|
||||||
|
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
|
||||||
|
one alone), and the page takes that item out (`Content.OnDismissed`).
|
||||||
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
- **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.
|
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
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
[JsonSerializable(typeof(ViewHostsPage))]
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
[JsonSerializable(typeof(ViewCrawler))]
|
[JsonSerializable(typeof(ViewCrawler))]
|
||||||
|
[JsonSerializable(typeof(ViewHostDetail))]
|
||||||
|
[JsonSerializable(typeof(ViewHostInstance))]
|
||||||
|
[JsonSerializable(typeof(ViewHostDay))]
|
||||||
|
[JsonSerializable(typeof(ViewInteractionEvent))]
|
||||||
|
[JsonSerializable(typeof(List<ViewInteractionEvent>))]
|
||||||
|
[JsonSerializable(typeof(ViewServerDay))]
|
||||||
|
[JsonSerializable(typeof(List<ViewServerDay>))]
|
||||||
[JsonSerializable(typeof(ViewBackups))]
|
[JsonSerializable(typeof(ViewBackups))]
|
||||||
[JsonSerializable(typeof(ViewBackup))]
|
[JsonSerializable(typeof(ViewBackup))]
|
||||||
[JsonSerializable(typeof(BackupPasswordForm))]
|
[JsonSerializable(typeof(BackupPasswordForm))]
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's own followers or the accounts it follows (/api/v1/accounts/:id/followers, /following), 40 a page by the
|
||||||
|
Link header, each opening its profile. PrivaPub lists them to the persona alone (hide_collections), so only the
|
||||||
|
persona's own profile opens this. A follower can be removed (remove_from_followers), asked first. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||||
|
data-account-list="@(Followers ? "followers" : "following")">
|
||||||
|
<Heading><span class="capitalize">@(Followers ? Localizer["followers"] : Localizer["following"])</span></Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (accounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3" data-account-list-empty>@Localizer["Nobody yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-account="@account.Acct">
|
||||||
|
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if (Followers)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => RemoveFollower(account)"
|
||||||
|
data-follower-remove>
|
||||||
|
<i class="ion-md-person text-error" aria-hidden="true"></i><span>@Localizer["Remove from followers"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (maxId is not null)
|
||||||
|
{
|
||||||
|
<div class="flex justify-center mt-3">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-account-list-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The persona's own account id.</summary>
|
||||||
|
[Parameter, EditorRequired] public string AccountId { get; set; }
|
||||||
|
/// <summary>Its followers, rather than the accounts it follows.</summary>
|
||||||
|
[Parameter] public bool Followers { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>A follower was removed.</summary>
|
||||||
|
[Parameter] public EventCallback<Account> OnRemoved { get; set; }
|
||||||
|
|
||||||
|
List<Account> accounts = [];
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string maxId;
|
||||||
|
bool isLoadingMore;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
accounts = [];
|
||||||
|
maxId = null;
|
||||||
|
await LoadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadPage()
|
||||||
|
{
|
||||||
|
var result = Followers ? await AccountService.Followers(AccountId, maxId) : await AccountService.Following(AccountId, maxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
var known = accounts.Select(a => a.Id).ToHashSet();
|
||||||
|
accounts.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||||
|
maxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMore()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
await LoadPage();
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveFollower(Account account)
|
||||||
|
{
|
||||||
|
var name = account.Name ?? account.Acct;
|
||||||
|
if (!await Confirm.Ask(Localizer["Remove {0} from your followers?", name], Localizer["They no longer see your followers-only posts, and may ask to follow you again."],
|
||||||
|
Localizer["Remove from followers"], true))
|
||||||
|
return;
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.RemoveFromFollowers(account.Id);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.Remove(account);
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} removed from your followers.", name]);
|
||||||
|
await OnRemoved.InvokeAsync(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The federation ledger's events, newest first (ViewInteractionEvent): what was received, delivered or fetched and how
|
||||||
|
it ended, never who. Channels and outcomes show as the ledger keys them, like the statistics' counters. In the
|
||||||
|
statistics' Events section and in a server's detail. *@
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-events-table data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["When"]</th>
|
||||||
|
@if (ShowHost)
|
||||||
|
{
|
||||||
|
<th>@Localizer["server"]</th>
|
||||||
|
}
|
||||||
|
<th>@Localizer["Channel"]</th>
|
||||||
|
<th>@Localizer["Activity"]</th>
|
||||||
|
<th>@Localizer["Outcome"]</th>
|
||||||
|
<th>@Localizer["Reason"]</th>
|
||||||
|
<th class="text-right">HTTP</th>
|
||||||
|
<th class="text-right">ms</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var happened in Events)
|
||||||
|
{
|
||||||
|
<tr data-statistics-event="@happened.Channel" data-outcome="@happened.Outcome">
|
||||||
|
<td class="whitespace-nowrap" title="@NerdRow.Time(happened.At)">@happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</td>
|
||||||
|
@if (ShowHost)
|
||||||
|
{
|
||||||
|
<td class="max-w-40 truncate" title="@happened.Host">@happened.Host</td>
|
||||||
|
}
|
||||||
|
<td>@happened.Channel</td>
|
||||||
|
<td class="max-w-48 truncate" title="@happened.Object">@happened.Activity</td>
|
||||||
|
<td>
|
||||||
|
<DBadge Size="DSize.Xs" Color="@OutcomeColor(happened.Outcome)">@happened.Outcome</DBadge>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-48 truncate" title="@happened.Reason">@happened.Reason</td>
|
||||||
|
<td class="text-right tabular-nums">@happened.Status</td>
|
||||||
|
<td class="text-right tabular-nums">@happened.LatencyMs</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public List<ViewInteractionEvent> Events { get; set; } = [];
|
||||||
|
/// <summary>A column for the server, where the events are of many.</summary>
|
||||||
|
[Parameter] public bool ShowHost { get; set; } = true;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
static DColor OutcomeColor(string outcome) => outcome switch
|
||||||
|
{
|
||||||
|
"ok" or "accepted" => DColor.Success,
|
||||||
|
"queued" or "deferred" or "retry" => DColor.Warning,
|
||||||
|
"failed" or "dead" or "refused" or "rejected" or "dropped" => DColor.Error,
|
||||||
|
_ => DColor.None
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* One server of the federation statistics (/clientapi/admin/statistics/hosts/{host}), opened from its row: what it says
|
||||||
|
of itself and what PrivaPub knows of its deliveries (as nerd rows, by their field names), its days with the main
|
||||||
|
counters (newest first; a live day is today's, or one not rolled up yet), and its latest events. "Describe again"
|
||||||
|
asks PrivaPub to read its NodeInfo and instance description anew. Counted per server, never per account. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" BoxClass="md:w-11/12 md:max-w-4xl"
|
||||||
|
data-host-detail="@HostName">
|
||||||
|
<Heading><span class="break-all">@HostName</span></Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-4 mt-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isDescribing)" OnClick="Describe" data-host-describe>
|
||||||
|
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Describe again"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (detail is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (detail.Instance is { } instance)
|
||||||
|
{
|
||||||
|
<NerdRows Rows="InstanceRows(instance)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-72 rounded-xl neo-inset neo-2xs" data-host-days data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["Day"]</th>
|
||||||
|
<th class="text-right">@Localizer["Received"]</th>
|
||||||
|
<th class="text-right">@Localizer["In"]</th>
|
||||||
|
<th class="text-right">@Localizer["Out"]</th>
|
||||||
|
<th class="text-right">@Localizer["Failures"]</th>
|
||||||
|
<th class="text-right">@Localizer["Accounts"]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var day in detail.Days.OrderByDescending(d => d.Day))
|
||||||
|
{
|
||||||
|
<tr data-host-day="@NerdRow.Time(day.Day)">
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
@day.Day.ToString(SUtility.dateTimeFormat)
|
||||||
|
@if (day.Live)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Info">@Localizer["live"]</DBadge>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "recv")</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "in")</td>
|
||||||
|
<td class="text-right tabular-nums">@Channel(day.Counters, "out")</td>
|
||||||
|
<td class="text-right tabular-nums">@Failures(day.Counters)</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Accounts</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
|
||||||
|
@if (detail.Events.Count > 0)
|
||||||
|
{
|
||||||
|
<b class="text-sm">@Localizer["Last events"]</b>
|
||||||
|
<EventsTable Events="detail.Events" ShowHost="false" />
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@detail.Attribution</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string HostName { get; set; }
|
||||||
|
/// <summary>How many days back its days go.</summary>
|
||||||
|
[Parameter] public int Days { get; set; } = 30;
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
ViewHostDetail detail;
|
||||||
|
bool isDescribing;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => detail = await AdminService.Host(HostName, Days);
|
||||||
|
|
||||||
|
async Task Describe()
|
||||||
|
{
|
||||||
|
isDescribing = true;
|
||||||
|
var result = await AdminService.Describe(HostName);
|
||||||
|
isDescribing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["PrivaPub will read what {0} says of itself again.", HostName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent).</summary>
|
||||||
|
static long Channel(Dictionary<string, long> counters, string channel) =>
|
||||||
|
counters.Where(c => c.Key.StartsWith(channel + ":", StringComparison.Ordinal)).Sum(c => c.Value);
|
||||||
|
|
||||||
|
// the deliveries out that failed, are retried or were given up, as the servers table counts them
|
||||||
|
static long Failures(Dictionary<string, long> counters) =>
|
||||||
|
counters.Where(c => c.Key.Split(':') is { Length: >= 4 } parts && parts[0] == "out" && parts[3] is "retry" or "dead" or "failed").Sum(c => c.Value);
|
||||||
|
|
||||||
|
static IEnumerable<NerdRow> InstanceRows(ViewHostInstance instance)
|
||||||
|
{
|
||||||
|
yield return NerdRow.Of("software", $"{instance.Software} {instance.SoftwareVersion}".Trim());
|
||||||
|
yield return NerdRow.Of("nodeName", instance.NodeName);
|
||||||
|
yield return NerdRow.Of("title", instance.Title);
|
||||||
|
yield return NerdRow.Of("nodeDescription", instance.NodeDescription);
|
||||||
|
yield return NerdRow.Of("openRegistrations", instance.OpenRegistrations);
|
||||||
|
yield return NerdRow.Of("registrationMode", instance.RegistrationMode);
|
||||||
|
yield return NerdRow.Of("usersTotal", instance.UsersTotal);
|
||||||
|
yield return NerdRow.Of("usersActiveMonth", instance.UsersActiveMonth);
|
||||||
|
yield return NerdRow.Of("localPosts", instance.LocalPosts);
|
||||||
|
yield return NerdRow.Of("languages", instance.Languages.Count > 0 ? string.Join(", ", instance.Languages) : null);
|
||||||
|
yield return NerdRow.Of("seen", instance.Seen);
|
||||||
|
yield return NerdRow.Of("firstSeenAt", instance.FirstSeenAt);
|
||||||
|
yield return NerdRow.Of("lastSeenAt", instance.LastSeenAt);
|
||||||
|
yield return NerdRow.Of("describedAt", instance.DescribedAt);
|
||||||
|
yield return NerdRow.Of("descriptionError", instance.DescriptionError);
|
||||||
|
yield return NerdRow.Of("lastSuccessAt", instance.LastSuccessAt);
|
||||||
|
yield return NerdRow.Of("lastFailureAt", instance.LastFailureAt);
|
||||||
|
yield return NerdRow.Of("lastError", instance.LastError);
|
||||||
|
yield return NerdRow.Of("consecutiveFailures", instance.ConsecutiveFailures > 0 ? instance.ConsecutiveFailures : null);
|
||||||
|
yield return NerdRow.Of("unavailableUntil", instance.UnavailableUntil);
|
||||||
|
yield return NerdRow.Of("place", string.Join(", ", new[] { instance.City, instance.Country }.Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } place ? place : null);
|
||||||
|
yield return NerdRow.Of("asn", instance.Asn is { } asn ? $"AS{asn} {instance.AsnOrg}".Trim() : null);
|
||||||
|
yield return NerdRow.Of("cdn", instance.Cdn);
|
||||||
|
yield return NerdRow.Of("instanceApi", instance.InstanceApi);
|
||||||
|
yield return NerdRow.Of("sourceUrl", instance.SourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
|
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
|
||||||
(by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@
|
(by traffic, failures, latency or name, 50 a page; a server's name opens its detail, HostDetailDialog), how far the
|
||||||
|
crawler got, the ledger's events (by server, channel and outcome, 100 at a time, older ones from the last one's time)
|
||||||
|
and the server's own days (its client API, the documents it served, its ledger). Counted per server, never per
|
||||||
|
account. *@
|
||||||
<div class="flex flex-col gap-4 w-full" data-statistics>
|
<div class="flex flex-col gap-4 w-full" data-statistics>
|
||||||
<div class="flex flex-wrap gap-2 items-end">
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
@@ -87,7 +90,10 @@
|
|||||||
@foreach (var host in hosts.Hosts)
|
@foreach (var host in hosts.Hosts)
|
||||||
{
|
{
|
||||||
<tr data-statistics-host="@host.Host">
|
<tr data-statistics-host="@host.Host">
|
||||||
<td class="max-w-48 truncate">@host.Host</td>
|
<td class="max-w-48">
|
||||||
|
<button type="button" class="link link-hover block max-w-full truncate text-left pointer-coarse:py-3.5" title="@host.Host"
|
||||||
|
@onclick="() => openHost = host.Host" data-statistics-host-open>@host.Host</button>
|
||||||
|
</td>
|
||||||
<td>@host.Software @host.Version</td>
|
<td>@host.Software @host.Version</td>
|
||||||
<td class="text-right tabular-nums">@host.In</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.Out</td>
|
||||||
@@ -116,6 +122,11 @@
|
|||||||
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (openHost is { } detailHost)
|
||||||
|
{
|
||||||
|
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
|
||||||
|
}
|
||||||
|
|
||||||
@if (crawler is not null)
|
@if (crawler is not null)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
||||||
@@ -141,6 +152,99 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2" data-statistics-events>
|
||||||
|
<b class="text-sm">@Localizer["Events"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["server"]</span>
|
||||||
|
<DInput TValue="string" Size="DSize.Sm" @bind-Value="eventsHost" @bind-Value:after="ReloadEvents" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-statistics-events-host />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Channel"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsChannel" ValueChanged="ChangeChannel" ValueExpression="() => eventsChannel"
|
||||||
|
data-statistics-events-channel>
|
||||||
|
<option value="">@Localizer["All"]</option>
|
||||||
|
@foreach (var channel in EventChannels)
|
||||||
|
{
|
||||||
|
<option value="@channel">@channel</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Outcome"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsOutcome" ValueChanged="ChangeOutcome" ValueExpression="() => eventsOutcome"
|
||||||
|
data-statistics-events-outcome>
|
||||||
|
<option value="">@Localizer["All"]</option>
|
||||||
|
@foreach (var outcome in EventOutcomes)
|
||||||
|
{
|
||||||
|
<option value="@outcome">@outcome</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (events.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70" data-statistics-events-empty>@Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EventsTable Events="events" />
|
||||||
|
}
|
||||||
|
@if (hasMoreEvents)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="MoreEvents" data-statistics-events-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2" data-statistics-server>
|
||||||
|
<b class="text-sm">@Localizer["Server days"]</b>
|
||||||
|
@if (serverDays.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-scroll-x>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["Day"]</th>
|
||||||
|
<th class="text-right">@Localizer["Client API"]</th>
|
||||||
|
<th class="text-right">4xx</th>
|
||||||
|
<th class="text-right">5xx</th>
|
||||||
|
<th class="text-right">@Localizer["Served"]</th>
|
||||||
|
<th class="text-right" title="@Localizer["written / dropped / failed"]">@Localizer["Ledger"]</th>
|
||||||
|
<th class="text-right">@Localizer["Accounts"]</th>
|
||||||
|
<th class="text-right"><span class="capitalize">@Localizer["servers"]</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var day in Enumerable.Reverse(serverDays))
|
||||||
|
{
|
||||||
|
<tr data-statistics-server-day="@NerdRow.Time(day.Day)">
|
||||||
|
<td class="whitespace-nowrap">@day.Day.ToString(SUtility.dateTimeFormat)</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, null)</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, ":4xx")</td>
|
||||||
|
<td class="text-right tabular-nums">@ClientRequests(day, ":5xx")</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Served.Values.Sum()</td>
|
||||||
|
<td class="text-right tabular-nums whitespace-nowrap" title="@Localizer["written / dropped / failed"]">
|
||||||
|
@day.LedgerWritten / @day.LedgerDropped / @day.LedgerFailed
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Accounts</td>
|
||||||
|
<td class="text-right tabular-nums">@day.Hosts</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,11 +252,24 @@
|
|||||||
[Inject] AdminService AdminService { get; set; }
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
const int PageSize = 50;
|
const int PageSize = 50;
|
||||||
|
const int EventsPageSize = 100;
|
||||||
|
// the ledger's channels and outcomes, as it keys them
|
||||||
|
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
|
||||||
|
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
|
||||||
static readonly int[] Periods = [1, 7, 30, 90];
|
static readonly int[] Periods = [1, 7, 30, 90];
|
||||||
|
|
||||||
ViewStatisticsOverview overview;
|
ViewStatisticsOverview overview;
|
||||||
ViewHostsPage hosts;
|
ViewHostsPage hosts;
|
||||||
ViewCrawler crawler;
|
ViewCrawler crawler;
|
||||||
|
List<ViewInteractionEvent> events = [];
|
||||||
|
List<ViewServerDay> serverDays = [];
|
||||||
|
// the server whose detail is open
|
||||||
|
string openHost;
|
||||||
|
string eventsHost;
|
||||||
|
string eventsChannel;
|
||||||
|
string eventsOutcome;
|
||||||
|
bool hasMoreEvents;
|
||||||
|
bool isLoadingMore;
|
||||||
int days = 30;
|
int days = 30;
|
||||||
string sort = "traffic";
|
string sort = "traffic";
|
||||||
int page = 1;
|
int page = 1;
|
||||||
@@ -179,10 +296,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Task LoadData()
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
await LoadDays();
|
||||||
|
crawler = await AdminService.Crawler();
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// what the period chosen shows
|
||||||
|
async Task LoadDays()
|
||||||
{
|
{
|
||||||
overview = await AdminService.Overview(days);
|
overview = await AdminService.Overview(days);
|
||||||
hosts = await AdminService.Hosts(days, sort, page);
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
crawler = await AdminService.Crawler();
|
serverDays = await AdminService.ServerDays(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the client API's requests of a day, those of one status class (":4xx") or all
|
||||||
|
static long ClientRequests(ViewServerDay day, string statusClass) =>
|
||||||
|
day.Client.Where(c => c.Key.EndsWith("xx", StringComparison.Ordinal) && (statusClass is null || c.Key.EndsWith(statusClass, StringComparison.Ordinal)))
|
||||||
|
.Sum(c => c.Value);
|
||||||
|
|
||||||
|
async Task ReloadEvents()
|
||||||
|
{
|
||||||
|
events = [];
|
||||||
|
hasMoreEvents = false;
|
||||||
|
await LoadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadEvents()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
var page = await AdminService.Events(eventsHost?.Trim(), eventsChannel, eventsOutcome, null, events.LastOrDefault()?.At, EventsPageSize);
|
||||||
|
isLoadingMore = false;
|
||||||
|
events.AddRange(page);
|
||||||
|
hasMoreEvents = page.Count == EventsPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreEvents() => await LoadEvents();
|
||||||
|
|
||||||
|
async Task ChangeChannel(string value)
|
||||||
|
{
|
||||||
|
eventsChannel = value;
|
||||||
|
await ReloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeOutcome(string value)
|
||||||
|
{
|
||||||
|
eventsOutcome = value;
|
||||||
|
await ReloadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
static string Ms(int? value) => value?.ToString() ?? "—";
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
||||||
@@ -191,7 +351,7 @@
|
|||||||
{
|
{
|
||||||
days = value;
|
days = value;
|
||||||
page = 1;
|
page = 1;
|
||||||
await LoadData();
|
await LoadDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task ChangeSort(string value)
|
async Task ChangeSort(string value)
|
||||||
|
|||||||
@@ -124,7 +124,8 @@
|
|||||||
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
||||||
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||||
</div>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]"
|
||||||
|
data-upload-remove>
|
||||||
<i class="ion-md-trash text-base text-error"></i>
|
<i class="ion-md-trash text-base text-error"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +216,7 @@
|
|||||||
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
|
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
|
||||||
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
|
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
|
@foreach (var mediaType in Enum.GetValues<MediaType>())
|
||||||
{
|
{
|
||||||
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
|
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
|
||||||
}
|
}
|
||||||
@@ -599,10 +600,15 @@
|
|||||||
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
||||||
void Retry(UploadMedia media) => Start(media);
|
void Retry(UploadMedia media) => Start(media);
|
||||||
|
|
||||||
void Remove(UploadMedia media)
|
// a file taken out before posting goes from PrivaPub too (DELETE /api/v1/media/:id), unless a post holds it already: an
|
||||||
|
// edit's own media stay its post's. A failure is only logged; the upload stays unattached on PrivaPub.
|
||||||
|
async Task Remove(UploadMedia media)
|
||||||
{
|
{
|
||||||
media.Cancel?.Cancel();
|
media.Cancel?.Cancel();
|
||||||
form.Media.Remove(media);
|
form.Media.Remove(media);
|
||||||
|
if (media.Posted || media.Id is not { } mediaId)
|
||||||
|
return;
|
||||||
|
await MediaService.Discard(mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// the reader chose to publish its pictures without descriptions
|
// the reader chose to publish its pictures without descriptions
|
||||||
|
|||||||
+136
-5
@@ -7,8 +7,11 @@
|
|||||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
|
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
|
||||||
The persona's filters apply where the page says the card is (FilterIn): a
|
The persona's filters apply where the page says the card is (FilterIn): a
|
||||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
|
||||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
|
||||||
|
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
|
||||||
|
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats
|
||||||
|
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||||
@if (!IsFilteredOut)
|
@if (!IsFilteredOut)
|
||||||
{
|
{
|
||||||
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
||||||
@@ -49,6 +52,13 @@
|
|||||||
}
|
}
|
||||||
<span>@NotificationText(notification)</span>
|
<span>@NotificationText(notification)</span>
|
||||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
@if (!Compact)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="ml-auto" Disabled="@(IsDefaultDisabled || isActing)" OnClick="DismissNotification" title="@Localizer["Dismiss"]"
|
||||||
|
aria-label="@Localizer["Dismiss"]" data-notification-dismiss>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
else if (Post.BoostedBy is { } booster)
|
else if (Post.BoostedBy is { } booster)
|
||||||
@@ -283,11 +293,24 @@
|
|||||||
<span class="hidden md:inline">@status.FavouritesCount</span>
|
<span class="hidden md:inline">@status.FavouritesCount</span>
|
||||||
}
|
}
|
||||||
</DButton>
|
</DButton>
|
||||||
@* a threadiverse post's downvotes: its upvotes are its favourites *@
|
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
|
||||||
@if (status.Privapub?.Votes is { Down: > 0 } votes)
|
favourite (on a phone, in More) *@
|
||||||
|
@if (CanDownvote(status) && status.Privapub.Votes is { } votes)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="votes.Downvoted == true" Disabled="IsDefaultDisabled || isActing"
|
||||||
|
OnClick="() => Downvote(status)" title="@(votes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])"
|
||||||
|
aria-pressed="@(votes.Downvoted == true ? "true" : "false")" data-downvote="@(votes.Downvoted == true ? "on" : "off")">
|
||||||
|
<i aria-hidden="true" class="ion-md-arrow-down text-lg @(votes.Downvoted == true ? "text-warning" : null)"></i>
|
||||||
|
@if (votes.Down > 0)
|
||||||
|
{
|
||||||
|
<span class="tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>@votes.Down</span>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (status.Privapub?.Votes is { Down: > 0 } ownVotes)
|
||||||
{
|
{
|
||||||
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
||||||
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
|
<i aria-hidden="true" class="ion-md-arrow-down"></i>@ownVotes.Down
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||||
@@ -335,6 +358,18 @@
|
|||||||
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
|
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||||
<span>@Localizer["Bookmark"]</span>
|
<span>@Localizer["Bookmark"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
@if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="phoneVotes.Downvoted == true"
|
||||||
|
Disabled="IsDefaultDisabled || isActing" OnClick="() => Downvote(status)" data-downvote-phone="@(phoneVotes.Downvoted == true ? "on" : "off")">
|
||||||
|
<i aria-hidden="true" class="ion-md-arrow-down text-base @(phoneVotes.Downvoted == true ? "text-warning" : null)"></i>
|
||||||
|
<span>@(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])</span>
|
||||||
|
@if (phoneVotes.Down > 0)
|
||||||
|
{
|
||||||
|
<span class="opacity-70 tabular-nums">@phoneVotes.Down</span>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
|
||||||
<i aria-hidden="true" class="ion-md-stats text-base"></i>
|
<i aria-hidden="true" class="ion-md-stats text-base"></i>
|
||||||
<span>@Localizer["Details"]</span>
|
<span>@Localizer["Details"]</span>
|
||||||
@@ -376,6 +411,17 @@
|
|||||||
}
|
}
|
||||||
@if (!IsOwn)
|
@if (!IsOwn)
|
||||||
{
|
{
|
||||||
|
@if (QuotesOwnPost(status))
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="RevokeQuote" data-revoke-quote>
|
||||||
|
<i aria-hidden="true" class="ion-md-quote text-base text-error"></i>
|
||||||
|
<span>@Localizer["Revoke quote"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isFilteringPost = true)" data-filter-post>
|
||||||
|
<i aria-hidden="true" class="ion-md-funnel text-base"></i>
|
||||||
|
<span>@Localizer["Filter this post"]</span>
|
||||||
|
</DButton>
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
||||||
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
|
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
|
||||||
<span>@Localizer["Report"]</span>
|
<span>@Localizer["Report"]</span>
|
||||||
@@ -398,6 +444,11 @@
|
|||||||
<i aria-hidden="true" class="ion-md-pin text-base"></i>
|
<i aria-hidden="true" class="ion-md-pin text-base"></i>
|
||||||
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
|
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
|
||||||
|
OnClick="@(() => isChoosingQuotePolicy = true)" data-quote-policy="@StatusService.QuotePolicyValue(StatusService.QuotePolicyOf(status))">
|
||||||
|
<i aria-hidden="true" class="ion-md-quote text-base"></i>
|
||||||
|
<span>@Localizer["Who may quote it"]</span>
|
||||||
|
</DButton>
|
||||||
}
|
}
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
|
||||||
<i aria-hidden="true" class="ion-md-create text-base"></i>
|
<i aria-hidden="true" class="ion-md-create text-base"></i>
|
||||||
@@ -461,6 +512,14 @@
|
|||||||
{
|
{
|
||||||
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
|
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
|
||||||
}
|
}
|
||||||
|
@if (isFilteringPost)
|
||||||
|
{
|
||||||
|
<FilterPostDialog StatusId="@status.Id" OnClosed="@(() => isFilteringPost = false)" OnFiltered="Filtered" />
|
||||||
|
}
|
||||||
|
@if (isChoosingQuotePolicy)
|
||||||
|
{
|
||||||
|
<QuotePolicyDialog Status="status" OnClosed="@(() => isChoosingQuotePolicy = false)" OnChanged="QuotePolicyChanged" />
|
||||||
|
}
|
||||||
@if (isHistoryOpen)
|
@if (isHistoryOpen)
|
||||||
{
|
{
|
||||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||||
@@ -521,6 +580,8 @@
|
|||||||
|
|
||||||
[Parameter, EditorRequired] public FeedPost Post { get; set; }
|
[Parameter, EditorRequired] public FeedPost Post { get; set; }
|
||||||
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
|
||||||
|
/// <summary>Its notification was dismissed: the page takes the item out.</summary>
|
||||||
|
[Parameter] public EventCallback<FeedPost> OnDismissed { get; set; }
|
||||||
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
|
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
|
||||||
[Parameter] public EventCallback<string> OnTag { get; set; }
|
[Parameter] public EventCallback<string> OnTag { get; set; }
|
||||||
[Parameter] public string CssContainer { get; set; }
|
[Parameter] public string CssContainer { get; set; }
|
||||||
@@ -547,6 +608,8 @@
|
|||||||
bool isReadingServer;
|
bool isReadingServer;
|
||||||
bool isHistoryOpen;
|
bool isHistoryOpen;
|
||||||
bool isReporting;
|
bool isReporting;
|
||||||
|
bool isFilteringPost;
|
||||||
|
bool isChoosingQuotePolicy;
|
||||||
// who liked it (false) or boosted it (true), when that list is open
|
// who liked it (false) or boosted it (true), when that list is open
|
||||||
bool? showInteractedBy;
|
bool? showInteractedBy;
|
||||||
List<StatusEdit> history = [];
|
List<StatusEdit> history = [];
|
||||||
@@ -824,6 +887,74 @@
|
|||||||
Post.Status = updated.Reblog ?? updated;
|
Post.Status = updated.Reblog ?? updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card
|
||||||
|
// offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it
|
||||||
|
// has downvotes already. Not on the persona's own post.
|
||||||
|
bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes
|
||||||
|
&& (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software));
|
||||||
|
|
||||||
|
static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb";
|
||||||
|
|
||||||
|
// a vote is public, like a reaction: the persona is told so once, before its first
|
||||||
|
async Task Downvote(Status status)
|
||||||
|
{
|
||||||
|
var on = status.Privapub?.Votes?.Downvoted != true;
|
||||||
|
if (on && !await Notices.Confirm(CascadingState.PersonaId))
|
||||||
|
return;
|
||||||
|
await Act(on ? StatusAction.Downvote : StatusAction.Undownvote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss)
|
||||||
|
async Task DismissNotification()
|
||||||
|
{
|
||||||
|
var notification = Post.Notification;
|
||||||
|
isActing = true;
|
||||||
|
var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}");
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnDismissed.InvokeAsync(Post);
|
||||||
|
}
|
||||||
|
|
||||||
|
// in a filter now: the post is read again, so the filter applies here at once
|
||||||
|
async Task Filtered()
|
||||||
|
{
|
||||||
|
isFilteringPost = false;
|
||||||
|
if (await StatusService.Get(Status.Id) is { } filtered)
|
||||||
|
Post.Status = filtered.Reblog ?? filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
void QuotePolicyChanged(Status updated)
|
||||||
|
{
|
||||||
|
isChoosingQuotePolicy = false;
|
||||||
|
Post.Status = updated.Reblog ?? updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// someone else's post quoting the persona's own: the persona may take its permission back
|
||||||
|
bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId;
|
||||||
|
|
||||||
|
async Task RevokeQuote()
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true))
|
||||||
|
return;
|
||||||
|
isActing = true;
|
||||||
|
var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id);
|
||||||
|
isActing = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (Status)result.Data;
|
||||||
|
Post.Status = updated.Reblog ?? updated;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Quote revoked."]);
|
||||||
|
}
|
||||||
|
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] PublicNoticeService Notices { get; set; }
|
[Inject] PublicNoticeService Notices { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* "Filter this post", from a card's menu: the persona's filters (/api/v2/filters), one of which takes the post (POST
|
||||||
|
/api/v2/filters/:id/statuses); a filter that holds it already says so. Filters are made and changed on /filters. The
|
||||||
|
card reads the post again afterwards, so the filter applies to it at once. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-filter-post-dialog>
|
||||||
|
<Heading>@Localizer["Filter this post"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3">@Localizer["This persona has no filter yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var filter in filters)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start" Disabled="@(IsDefaultDisabled || isSaving || Holds(filter))" OnClick="() => Add(filter)"
|
||||||
|
data-filter-choice="@filter.Id">
|
||||||
|
<i class="ion-md-funnel" aria-hidden="true"></i>
|
||||||
|
<span class="flex-1 min-w-0 truncate text-left">@filter.Title</span>
|
||||||
|
<span class="text-xs opacity-70">@Description(filter)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm justify-start" href="filters" data-filter-post-manage>
|
||||||
|
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Make a filter"]</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>The post went into a filter.</summary>
|
||||||
|
[Parameter] public EventCallback OnFiltered { get; set; }
|
||||||
|
|
||||||
|
List<Filter> filters = [];
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => filters = await FilterService.All();
|
||||||
|
|
||||||
|
bool Holds(Filter filter) => filter.Statuses.Any(s => s.StatusId == StatusId);
|
||||||
|
|
||||||
|
string Description(Filter filter) => Holds(filter) ? Localizer["Already in it"]
|
||||||
|
: !filter.IsActive ? Localizer["Expired"]
|
||||||
|
: filter.FilterAction switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Hide"],
|
||||||
|
FilterAction.Blur => Localizer["Blur"],
|
||||||
|
_ => Localizer["Warn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Add(Filter filter)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.AddStatus(filter.Id, StatusId);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Added to {0}.", filter.Title]);
|
||||||
|
await OnFiltered.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The accounts waiting for the persona to accept their follow (/api/v1/follow_requests), newest first, each opening its
|
||||||
|
profile, accepted (authorize) or refused (reject) here; "Load more" follows the Link header's next page when PrivaPub
|
||||||
|
sends one. On the persona's own profile (hidden while nobody waits) and in a sheet from the Notifications tab. *@
|
||||||
|
@if (!HideWhenEmpty || IsLoading || requests.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="flex flex-none flex-col gap-2 w-full" data-follow-requests>
|
||||||
|
@if (ShowHeading)
|
||||||
|
{
|
||||||
|
<h2 class="font-semibold text-sm inline-flex items-center gap-2"><i class="ion-md-person-add" aria-hidden="true"></i>@Localizer["Follow requests"]</h2>
|
||||||
|
}
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (requests.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70" data-follow-requests-empty>@Localizer["No follow request is waiting."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in requests)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-follow-request="@account.Acct">
|
||||||
|
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if (answers.TryGetValue(account.Id, out var accepted))
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" data-follow-request-answer="@(accepted ? "accepted" : "refused")">
|
||||||
|
@(accepted ? Localizer["Accepted."] : Localizer["Refused."])
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, true)"
|
||||||
|
data-follow-request-accept>
|
||||||
|
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Accept"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, false)"
|
||||||
|
data-follow-request-reject>
|
||||||
|
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Refuse"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (maxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-follow-requests-more>
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Load more"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Renders nothing once read with nobody waiting (the persona's own profile).</summary>
|
||||||
|
[Parameter] public bool HideWhenEmpty { get; set; }
|
||||||
|
[Parameter] public bool ShowHeading { get; set; }
|
||||||
|
/// <summary>A request was answered: true when accepted.</summary>
|
||||||
|
[Parameter] public EventCallback<bool> OnAnswered { get; set; }
|
||||||
|
|
||||||
|
List<Account> requests = [];
|
||||||
|
// what was answered here, by account id: accepted (true) or refused
|
||||||
|
Dictionary<string, bool> answers = new();
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string maxId;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isLoadingMore;
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its requests are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
requests = [];
|
||||||
|
answers = new();
|
||||||
|
maxId = null;
|
||||||
|
await LoadPage();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadPage()
|
||||||
|
{
|
||||||
|
var result = await AccountService.FollowRequests(maxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
var known = requests.Select(a => a.Id).ToHashSet();
|
||||||
|
requests.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
|
||||||
|
maxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadMore()
|
||||||
|
{
|
||||||
|
isLoadingMore = true;
|
||||||
|
await LoadPage();
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Answer(Account account, bool accept)
|
||||||
|
{
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.Answer(account.Id, accept);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
answers[account.Id] = accept;
|
||||||
|
await OnAnswered.InvokeAsync(accept);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Who may quote one of the persona's own public or unlisted posts (PUT /api/v1/statuses/:id/interaction_policy,
|
||||||
|
quote_approval_policy): anyone, its followers or nobody, from the card's menu. The post states its policy in
|
||||||
|
quote_approval.automatic; PrivaPub tells the post's readers of the change. *@
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-quote-policy-dialog>
|
||||||
|
<Heading>@Localizer["Who may quote it"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start" Active="policy == current" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => Choose(policy)"
|
||||||
|
aria-pressed="@(policy == current ? "true" : "false")" data-quote-policy-choice="@StatusService.QuotePolicyValue(policy)">
|
||||||
|
<i class="@PolicyIcon(policy)" aria-hidden="true"></i>
|
||||||
|
<span class="flex-1 text-left">@PolicyName(policy)</span>
|
||||||
|
@if (policy == current)
|
||||||
|
{
|
||||||
|
<i class="ion-md-checkmark text-primary" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public Status Status { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
/// <summary>The post as PrivaPub now describes it, its new policy in it.</summary>
|
||||||
|
[Parameter] public EventCallback<Status> OnChanged { get; set; }
|
||||||
|
|
||||||
|
QuotePolicy current = QuotePolicy.Public;
|
||||||
|
bool isSaving;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
current = StatusService.QuotePolicyOf(Status);
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string PolicyName(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => Localizer["Your followers"],
|
||||||
|
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||||
|
_ => Localizer["Anyone"]
|
||||||
|
};
|
||||||
|
|
||||||
|
static string PolicyIcon(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => "ion-md-people",
|
||||||
|
QuotePolicy.Nobody => "ion-md-close-circle",
|
||||||
|
_ => "ion-md-globe"
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Choose(QuotePolicy policy)
|
||||||
|
{
|
||||||
|
if (policy == current)
|
||||||
|
return;
|
||||||
|
isSaving = true;
|
||||||
|
var result = await StatusService.SetQuotePolicy(Status.Id, policy);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = (Status)result.Data;
|
||||||
|
current = StatusService.QuotePolicyOf(updated);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
||||||
|
await OnChanged.InvokeAsync(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -86,8 +86,6 @@ public static class GenericExtensions
|
|||||||
return "ion-md-images";
|
return "ion-md-images";
|
||||||
case MediaType.Video:
|
case MediaType.Video:
|
||||||
return "ion-md-videocam";
|
return "ion-md-videocam";
|
||||||
case MediaType.Documents:
|
|
||||||
return "ion-md-document";
|
|
||||||
case MediaType.Audio:
|
case MediaType.Audio:
|
||||||
return "ion-md-volume-high";
|
return "ion-md-volume-high";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public static class APIs
|
|||||||
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||||
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||||
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||||
|
public static string GET_M_StatisticsHost(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}";
|
||||||
|
public static string POST_M_StatisticsDescribe(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}/describe";
|
||||||
|
public const string GET_StatisticsEvents = "clientapi/admin/statistics/events";
|
||||||
|
public const string GET_StatisticsServer = "clientapi/admin/statistics/server";
|
||||||
public const string GET_AdminBackups = "clientapi/admin/backups";
|
public const string GET_AdminBackups = "clientapi/admin/backups";
|
||||||
public const string POST_AdminBackup = "clientapi/admin/backups";
|
public const string POST_AdminBackup = "clientapi/admin/backups";
|
||||||
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||||
@@ -105,6 +109,7 @@ public static class APIs
|
|||||||
public const string POST_Media = "api/v2/media";
|
public const string POST_Media = "api/v2/media";
|
||||||
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
public const string GET_InstanceV2 = "api/v2/instance";
|
public const string GET_InstanceV2 = "api/v2/instance";
|
||||||
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
@@ -117,6 +122,8 @@ public static class APIs
|
|||||||
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
||||||
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
||||||
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
||||||
|
public static string GET_M_AccountFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/followers";
|
||||||
|
public static string POST_M_RemoveFromFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/remove_from_followers";
|
||||||
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
||||||
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
@@ -146,9 +153,13 @@ public static class APIs
|
|||||||
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
||||||
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
||||||
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
|
public static string PUT_M_StatusInteractionPolicy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/interaction_policy";
|
||||||
|
public static string POST_M_RevokeQuote(string quotedId, string quotingId) =>
|
||||||
|
$"api/v1/statuses/{Uri.EscapeDataString(quotedId)}/quotes/{Uri.EscapeDataString(quotingId)}/revoke";
|
||||||
public const string GET_FollowedTags = "api/v1/followed_tags";
|
public const string GET_FollowedTags = "api/v1/followed_tags";
|
||||||
public const string GET_GroupedNotifications = "api/v2/notifications";
|
public const string GET_GroupedNotifications = "api/v2/notifications";
|
||||||
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
|
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
|
||||||
|
public static string POST_M_DismissNotificationGroup(string groupKey) => $"api/v2/notifications/{Uri.EscapeDataString(groupKey)}/dismiss";
|
||||||
public const string POST_Markers = "api/v1/markers";
|
public const string POST_Markers = "api/v1/markers";
|
||||||
public const string GET_TrendingTags = "api/v1/trends/tags";
|
public const string GET_TrendingTags = "api/v1/trends/tags";
|
||||||
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
||||||
|
|||||||
@@ -94,8 +94,12 @@ public class PrivaPubPlace
|
|||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A post's votes: its favourites are its upvotes. PrivaPub sends them on every post; a threadiverse post's
|
||||||
|
/// downvotes are the ones that mean something.</summary>
|
||||||
public class PrivaPubVotes
|
public class PrivaPubVotes
|
||||||
{
|
{
|
||||||
public int Up { get; set; }
|
public int Up { get; set; }
|
||||||
public int Down { get; set; }
|
public int Down { get; set; }
|
||||||
|
/// <summary>The persona's own downvote (POST /api/v1/statuses/:id/downvote, /undownvote); null with no persona.</summary>
|
||||||
|
public bool? Downvoted { get; set; }
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
{
|
{
|
||||||
Images = 1,
|
Images = 1,
|
||||||
Video,
|
Video,
|
||||||
Audio,
|
Audio
|
||||||
Documents
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+61
-6
@@ -6,7 +6,9 @@
|
|||||||
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
@* 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
|
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
|
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
|
||||||
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
|
(Content.FilterIn), and a card's "Filter this post" adds a post to one. The posts a filter holds are read by id when it
|
||||||
|
opens (/api/v1/statuses?id[]=) and show their author and words, linking their thread. Filters are the persona's own
|
||||||
|
and never leave PrivaPub. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
@@ -124,11 +126,33 @@
|
|||||||
@if (filter.Statuses.Count > 0)
|
@if (filter.Statuses.Count > 0)
|
||||||
{
|
{
|
||||||
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
@foreach (var item in filter.Statuses)
|
@foreach (var item in filter.Statuses)
|
||||||
{
|
{
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
|
||||||
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
|
@if (PostOf(item) is { } post)
|
||||||
|
{
|
||||||
|
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
|
||||||
|
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<span class="truncate text-xs">
|
||||||
|
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
|
||||||
|
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
|
||||||
|
</span>
|
||||||
|
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (readPostIds.Contains(item.StatusId))
|
||||||
|
{
|
||||||
|
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
|
||||||
|
}
|
||||||
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
|
<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>
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
@@ -170,6 +194,7 @@
|
|||||||
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
||||||
|
|
||||||
[Inject] FilterService FilterService { get; set; }
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
|
|
||||||
List<Filter> filters = [];
|
List<Filter> filters = [];
|
||||||
TitleForm draft = new();
|
TitleForm draft = new();
|
||||||
@@ -178,6 +203,9 @@
|
|||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
bool isSaving;
|
bool isSaving;
|
||||||
bool isConfirmingDelete;
|
bool isConfirmingDelete;
|
||||||
|
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
|
||||||
|
Dictionary<string, Status> filteredPosts = new();
|
||||||
|
HashSet<string> readPostIds = [];
|
||||||
|
|
||||||
(int Seconds, string Label)[] Expiries =>
|
(int Seconds, string Label)[] Expiries =>
|
||||||
[
|
[
|
||||||
@@ -240,6 +268,8 @@
|
|||||||
{
|
{
|
||||||
IsLoading = true;
|
IsLoading = true;
|
||||||
open = null;
|
open = null;
|
||||||
|
filteredPosts = new();
|
||||||
|
readPostIds = [];
|
||||||
filters = await FilterService.All();
|
filters = await FilterService.All();
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -260,10 +290,10 @@
|
|||||||
filters.Add(filter);
|
filters.Add(filter);
|
||||||
draft = new();
|
draft = new();
|
||||||
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
||||||
Toggle(filter);
|
await Toggle(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Toggle(Filter filter)
|
async Task Toggle(Filter filter)
|
||||||
{
|
{
|
||||||
isConfirmingDelete = false;
|
isConfirmingDelete = false;
|
||||||
if (open?.Id == filter.Id)
|
if (open?.Id == filter.Id)
|
||||||
@@ -281,6 +311,31 @@
|
|||||||
ExpiresIn = KeepExpiry,
|
ExpiresIn = KeepExpiry,
|
||||||
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
||||||
};
|
};
|
||||||
|
await ReadPosts(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
|
||||||
|
async Task ReadPosts(Filter filter)
|
||||||
|
{
|
||||||
|
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return;
|
||||||
|
foreach (var (id, status) in await TimelineService.Read(ids))
|
||||||
|
filteredPosts[id] = status;
|
||||||
|
readPostIds.UnionWith(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
|
||||||
|
|
||||||
|
// what the post says, as plain text: its content warning when it has one, else the start of its words
|
||||||
|
static string Excerpt(Status status)
|
||||||
|
{
|
||||||
|
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
|
||||||
|
if (string.IsNullOrEmpty(html))
|
||||||
|
return null;
|
||||||
|
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
|
||||||
|
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
|
||||||
|
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
void ToggleContext(FilterContext context, bool on)
|
void ToggleContext(FilterContext context, bool on)
|
||||||
|
|||||||
+41
-3
@@ -41,13 +41,37 @@
|
|||||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||||
</PhoneSheet>
|
</PhoneSheet>
|
||||||
|
|
||||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
@if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0))
|
||||||
{
|
{
|
||||||
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
|
@if (followRequests > 0)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isFollowRequestsOpen = true" data-follow-requests-open>
|
||||||
|
<i class="ion-md-person-add" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["{0} follow requests waiting", followRequests]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@if (filteredRequests > 0)
|
||||||
|
{
|
||||||
|
<a class="btn btn-sm" href="notifications/filtered" data-filtered-requests>
|
||||||
<i class="ion-md-notifications-off"></i>
|
<i class="ion-md-notifications-off"></i>
|
||||||
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (isFollowRequestsOpen)
|
||||||
|
{
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isFollowRequestsOpen = open" CloseText="@Localizer["Close"]"
|
||||||
|
data-follow-requests-dialog>
|
||||||
|
<Heading>@Localizer["Follow requests"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="mt-3">
|
||||||
|
<FollowRequestsPanel OnAnswered="FollowRequestAnswered" />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
}
|
||||||
|
|
||||||
@if (pending.Count > 0)
|
@if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -93,7 +117,7 @@
|
|||||||
|
|
||||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
|
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
|
||||||
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||||
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnDismissed="Dismissed" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
</Virtualize>
|
</Virtualize>
|
||||||
|
|
||||||
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
||||||
@@ -127,6 +151,7 @@
|
|||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] RequestStats RequestStats { get; set; }
|
[Inject] RequestStats RequestStats { get; set; }
|
||||||
[Inject] FeedMemory Memory { get; set; }
|
[Inject] FeedMemory Memory { get; set; }
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
FeedFilter Filter { get; set; } = new();
|
FeedFilter Filter { get; set; } = new();
|
||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
@@ -136,6 +161,9 @@
|
|||||||
int unreadNotifications;
|
int unreadNotifications;
|
||||||
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
||||||
int filteredRequests;
|
int filteredRequests;
|
||||||
|
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
|
||||||
|
int followRequests;
|
||||||
|
bool isFollowRequestsOpen;
|
||||||
int unreadConversations;
|
int unreadConversations;
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
@@ -431,8 +459,11 @@
|
|||||||
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
||||||
unreadNotifications = 0;
|
unreadNotifications = 0;
|
||||||
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
||||||
|
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FollowRequestAnswered(bool accepted) => followRequests = Math.Max(0, followRequests - 1);
|
||||||
|
|
||||||
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
/// <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
|
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
||||||
{
|
{
|
||||||
@@ -568,6 +599,13 @@
|
|||||||
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a dismissed notification is that item alone: others about the same post stay
|
||||||
|
void Dismissed(FeedPost post)
|
||||||
|
{
|
||||||
|
posts.RemoveAll(p => p.Key == post.Key);
|
||||||
|
pending.RemoveAll(p => p.Key == post.Key);
|
||||||
|
}
|
||||||
|
|
||||||
IEnumerable<NerdRow> FeedStats()
|
IEnumerable<NerdRow> FeedStats()
|
||||||
{
|
{
|
||||||
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
||||||
|
|||||||
+29
-2
@@ -6,7 +6,9 @@
|
|||||||
@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is
|
@* 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,
|
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,
|
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
|
||||||
never from its server directly. *@
|
never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows
|
||||||
|
(AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow
|
||||||
|
requests waiting for it show above its posts. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
@@ -142,13 +144,34 @@
|
|||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
|
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
|
||||||
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
||||||
|
@if (IsOwn)
|
||||||
|
{
|
||||||
|
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = false" data-profile-following>
|
||||||
|
<b>@account.FollowingCount</b> @Localizer["following"]
|
||||||
|
</button>
|
||||||
|
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = true" data-profile-followers>
|
||||||
|
<b>@account.FollowersCount</b> @Localizer["followers"]
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
||||||
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
@if (IsOwn)
|
||||||
|
{
|
||||||
|
@if (showAccounts is { } followers)
|
||||||
|
{
|
||||||
|
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
|
||||||
|
}
|
||||||
|
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
|
||||||
|
}
|
||||||
|
|
||||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
|
<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><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
|
||||||
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
|
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
|
||||||
@@ -212,6 +235,8 @@
|
|||||||
bool isLoadingMore;
|
bool isLoadingMore;
|
||||||
bool isListMenuOpen;
|
bool isListMenuOpen;
|
||||||
bool isReporting;
|
bool isReporting;
|
||||||
|
// the persona's own followers (true) or the accounts it follows (false), while that list is open
|
||||||
|
bool? showAccounts;
|
||||||
List<MastodonList> allLists = [];
|
List<MastodonList> allLists = [];
|
||||||
HashSet<string> inLists = [];
|
HashSet<string> inLists = [];
|
||||||
|
|
||||||
@@ -359,6 +384,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
|
||||||
|
|
||||||
void Remove(FeedPost post)
|
void Remove(FeedPost post)
|
||||||
{
|
{
|
||||||
posts.RemoveAll(p => p.Key == post.Key);
|
posts.RemoveAll(p => p.Key == post.Key);
|
||||||
|
|||||||
@@ -2486,4 +2486,115 @@
|
|||||||
<data name="Two-factor sign-in is off." xml:space="preserve">
|
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||||
<value>La verifica in due passaggi è disattivata.</value>
|
<value>La verifica in due passaggi è disattivata.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Follow requests" xml:space="preserve">
|
||||||
|
<value>Richieste di follow</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} follow requests waiting" xml:space="preserve">
|
||||||
|
<value>{0} richieste di follow in attesa</value>
|
||||||
|
</data>
|
||||||
|
<data name="No follow request is waiting." xml:space="preserve">
|
||||||
|
<value>Nessuna richiesta di follow in attesa.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody yet." xml:space="preserve">
|
||||||
|
<value>Ancora nessuno.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from followers" xml:space="preserve">
|
||||||
|
<value>Togli dai follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove {0} from your followers?" xml:space="preserve">
|
||||||
|
<value>Togliere {0} dai tuoi follower?</value>
|
||||||
|
</data>
|
||||||
|
<data name="They no longer see your followers-only posts, and may ask to follow you again." xml:space="preserve">
|
||||||
|
<value>Non vedrà più i tuoi post per soli follower, e potrà chiedere di nuovo di seguirti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from your followers." xml:space="preserve">
|
||||||
|
<value>{0} tolto dai tuoi follower.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Downvote" xml:space="preserve">
|
||||||
|
<value>Voto negativo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the downvote" xml:space="preserve">
|
||||||
|
<value>Togli il voto negativo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter this post" xml:space="preserve">
|
||||||
|
<value>Filtra questo post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Already in it" xml:space="preserve">
|
||||||
|
<value>Già presente</value>
|
||||||
|
</data>
|
||||||
|
<data name="Added to {0}." xml:space="preserve">
|
||||||
|
<value>Aggiunto a {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote it" xml:space="preserve">
|
||||||
|
<value>Chi può citarlo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your followers" xml:space="preserve">
|
||||||
|
<value>I tuoi follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke quote" xml:space="preserve">
|
||||||
|
<value>Revoca la citazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke this quote of your post?" xml:space="preserve">
|
||||||
|
<value>Revocare questa citazione del tuo post?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your post no longer shows in it." xml:space="preserve">
|
||||||
|
<value>Il tuo post non vi comparirà più.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quote revoked." xml:space="preserve">
|
||||||
|
<value>Citazione revocata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post no longer available" xml:space="preserve">
|
||||||
|
<value>Post non più disponibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe again" xml:space="preserve">
|
||||||
|
<value>Rileggi la descrizione</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub will read what {0} says of itself again." xml:space="preserve">
|
||||||
|
<value>PrivaPub rileggerà ciò che {0} dice di sé.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Day" xml:space="preserve">
|
||||||
|
<value>Giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="live" xml:space="preserve">
|
||||||
|
<value>in corso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last events" xml:space="preserve">
|
||||||
|
<value>Ultimi eventi</value>
|
||||||
|
</data>
|
||||||
|
<data name="When" xml:space="preserve">
|
||||||
|
<value>Quando</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channel" xml:space="preserve">
|
||||||
|
<value>Canale</value>
|
||||||
|
</data>
|
||||||
|
<data name="Activity" xml:space="preserve">
|
||||||
|
<value>Attività</value>
|
||||||
|
</data>
|
||||||
|
<data name="Outcome" xml:space="preserve">
|
||||||
|
<value>Esito</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reason" xml:space="preserve">
|
||||||
|
<value>Motivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Events" xml:space="preserve">
|
||||||
|
<value>Eventi</value>
|
||||||
|
</data>
|
||||||
|
<data name="All" xml:space="preserve">
|
||||||
|
<value>Tutti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server days" xml:space="preserve">
|
||||||
|
<value>Giorni del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Client API" xml:space="preserve">
|
||||||
|
<value>API client</value>
|
||||||
|
</data>
|
||||||
|
<data name="Served" xml:space="preserve">
|
||||||
|
<value>Serviti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ledger" xml:space="preserve">
|
||||||
|
<value>Registro</value>
|
||||||
|
</data>
|
||||||
|
<data name="written / dropped / failed" xml:space="preserve">
|
||||||
|
<value>scritti / scartati / falliti</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -2486,4 +2486,115 @@
|
|||||||
<data name="Two-factor sign-in is off." xml:space="preserve">
|
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||||
<value>Two-factor sign-in is off.</value>
|
<value>Two-factor sign-in is off.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Follow requests" xml:space="preserve">
|
||||||
|
<value>Follow requests</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} follow requests waiting" xml:space="preserve">
|
||||||
|
<value>{0} follow requests waiting</value>
|
||||||
|
</data>
|
||||||
|
<data name="No follow request is waiting." xml:space="preserve">
|
||||||
|
<value>No follow request is waiting.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody yet." xml:space="preserve">
|
||||||
|
<value>Nobody yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from followers" xml:space="preserve">
|
||||||
|
<value>Remove from followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove {0} from your followers?" xml:space="preserve">
|
||||||
|
<value>Remove {0} from your followers?</value>
|
||||||
|
</data>
|
||||||
|
<data name="They no longer see your followers-only posts, and may ask to follow you again." xml:space="preserve">
|
||||||
|
<value>They no longer see your followers-only posts, and may ask to follow you again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from your followers." xml:space="preserve">
|
||||||
|
<value>{0} removed from your followers.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Downvote" xml:space="preserve">
|
||||||
|
<value>Downvote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the downvote" xml:space="preserve">
|
||||||
|
<value>Remove the downvote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter this post" xml:space="preserve">
|
||||||
|
<value>Filter this post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Already in it" xml:space="preserve">
|
||||||
|
<value>Already in it</value>
|
||||||
|
</data>
|
||||||
|
<data name="Added to {0}." xml:space="preserve">
|
||||||
|
<value>Added to {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote it" xml:space="preserve">
|
||||||
|
<value>Who may quote it</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your followers" xml:space="preserve">
|
||||||
|
<value>Your followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke quote" xml:space="preserve">
|
||||||
|
<value>Revoke quote</value>
|
||||||
|
</data>
|
||||||
|
<data name="Revoke this quote of your post?" xml:space="preserve">
|
||||||
|
<value>Revoke this quote of your post?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Your post no longer shows in it." xml:space="preserve">
|
||||||
|
<value>Your post no longer shows in it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Quote revoked." xml:space="preserve">
|
||||||
|
<value>Quote revoked.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post no longer available" xml:space="preserve">
|
||||||
|
<value>Post no longer available</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe again" xml:space="preserve">
|
||||||
|
<value>Describe again</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub will read what {0} says of itself again." xml:space="preserve">
|
||||||
|
<value>PrivaPub will read what {0} says of itself again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Day" xml:space="preserve">
|
||||||
|
<value>Day</value>
|
||||||
|
</data>
|
||||||
|
<data name="live" xml:space="preserve">
|
||||||
|
<value>live</value>
|
||||||
|
</data>
|
||||||
|
<data name="Last events" xml:space="preserve">
|
||||||
|
<value>Last events</value>
|
||||||
|
</data>
|
||||||
|
<data name="When" xml:space="preserve">
|
||||||
|
<value>When</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channel" xml:space="preserve">
|
||||||
|
<value>Channel</value>
|
||||||
|
</data>
|
||||||
|
<data name="Activity" xml:space="preserve">
|
||||||
|
<value>Activity</value>
|
||||||
|
</data>
|
||||||
|
<data name="Outcome" xml:space="preserve">
|
||||||
|
<value>Outcome</value>
|
||||||
|
</data>
|
||||||
|
<data name="Reason" xml:space="preserve">
|
||||||
|
<value>Reason</value>
|
||||||
|
</data>
|
||||||
|
<data name="Events" xml:space="preserve">
|
||||||
|
<value>Events</value>
|
||||||
|
</data>
|
||||||
|
<data name="All" xml:space="preserve">
|
||||||
|
<value>All</value>
|
||||||
|
</data>
|
||||||
|
<data name="Server days" xml:space="preserve">
|
||||||
|
<value>Server days</value>
|
||||||
|
</data>
|
||||||
|
<data name="Client API" xml:space="preserve">
|
||||||
|
<value>Client API</value>
|
||||||
|
</data>
|
||||||
|
<data name="Served" xml:space="preserve">
|
||||||
|
<value>Served</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ledger" xml:space="preserve">
|
||||||
|
<value>Ledger</value>
|
||||||
|
</data>
|
||||||
|
<data name="written / dropped / failed" xml:space="preserve">
|
||||||
|
<value>written / dropped / failed</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
+20
-11
@@ -30,7 +30,7 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
|
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
|
||||||
/// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is
|
/// blocking and muting it, the persona's follow requests, followers and following, and search across the fediverse (a remote handle or URL is
|
||||||
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
|
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
@@ -154,16 +154,10 @@ namespace decePubClient.Services
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
|
/// <summary>A page of the accounts waiting for the persona to accept their follow, newest first; Data is an
|
||||||
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
|
/// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one).</summary>
|
||||||
{
|
public Task<WebResult> FollowRequests(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
|
Accounts(APIs.Mastodon.GET_FollowRequests, maxId, 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>
|
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
|
||||||
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
||||||
@@ -237,6 +231,21 @@ namespace decePubClient.Services
|
|||||||
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>A page of the persona's followers (only the persona itself may read it: PrivaPub answers [] for anyone
|
||||||
|
/// else's); Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Followers(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_M_AccountFollowers(accountId), maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Removes an account from the persona's followers (its server is told); Data is the new Relationship.</summary>
|
||||||
|
public async Task<WebResult> RemoveFromFollowers(string accountId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_RemoveFromFollowers(accountId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
||||||
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
|
/// 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
|
/// for the whole instance, the federation statistics (the overview, the servers, one server's days and events, the
|
||||||
|
/// ledger's events, the server's own days) and the backups (/clientapi/admin). PrivaPub checks the root's role on every
|
||||||
/// call. Lists come back empty and values null on failure; nothing throws.
|
/// call. Lists come back empty and values null on failure; nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
@@ -160,6 +161,61 @@ namespace decePubClient.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One server: what it says of itself, its days (counters keyed as the ledger keys them) and its latest 100
|
||||||
|
/// events; null when PrivaPub knows nothing of it or it cannot be read.</summary>
|
||||||
|
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_M_StatisticsHost(host), [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewHostDetail>(cancellationToken);
|
||||||
|
|
||||||
|
if (response.StatusCode is not HttpStatusCode.NotFound)
|
||||||
|
await LogFailure(response, nameof(Host), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks PrivaPub to read the server's NodeInfo and instance description again (a queued job, answered 202).</summary>
|
||||||
|
public async Task<WebResult> Describe(string host, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_StatisticsDescribe(host), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The ledger's events, newest first, those before `before` when given (the next page starts at the last
|
||||||
|
/// one's time); each filter is left out when null.</summary>
|
||||||
|
public async Task<List<ViewInteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { $"limit={limit}" };
|
||||||
|
if (host is { Length: > 0 })
|
||||||
|
parameters.Add($"host={Uri.EscapeDataString(host)}");
|
||||||
|
if (channel is { Length: > 0 })
|
||||||
|
parameters.Add($"channel={Uri.EscapeDataString(channel)}");
|
||||||
|
if (outcome is { Length: > 0 })
|
||||||
|
parameters.Add($"outcome={Uri.EscapeDataString(outcome)}");
|
||||||
|
if (reason is { Length: > 0 })
|
||||||
|
parameters.Add($"reason={Uri.EscapeDataString(reason)}");
|
||||||
|
if (before is { } at)
|
||||||
|
parameters.Add($"before={Uri.EscapeDataString(at.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))}");
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsEvents, [.. parameters], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewInteractionEvent>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Events), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's own days, oldest first: its client API, the documents it served and its ledger.</summary>
|
||||||
|
public async Task<List<ViewServerDay>> ServerDays(int days, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsServer, [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewServerDay>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(ServerDays), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
||||||
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
|
/// (audio and video: 202, then 206 until ready); describes and deletes what isn't posted yet; and knows what PrivaPub
|
||||||
|
/// takes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
public class MediaService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
static readonly MediaConfiguration Fallback = new()
|
static readonly MediaConfiguration Fallback = new()
|
||||||
{
|
{
|
||||||
@@ -79,6 +80,20 @@ namespace decePubClient.Services
|
|||||||
return new WebResult();
|
return new WebResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes an upload of the persona's that no post holds (DELETE /api/v1/media/:id; PrivaPub refuses a posted
|
||||||
|
/// one with 422): the composer drops what was removed before posting. A failure is logged.</summary>
|
||||||
|
public async Task<WebResult> Discard(string mediaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Media(mediaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return new WebResult();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(MediaService)}.{nameof(Discard)}", cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ namespace decePubClient.Services
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Unbookmark,
|
Unbookmark,
|
||||||
Pin,
|
Pin,
|
||||||
Unpin
|
Unpin,
|
||||||
|
/// <summary>PrivaPub's own, for the threadiverse: a downvote replaces the persona's favourite (its upvote).</summary>
|
||||||
|
Downvote,
|
||||||
|
Undownvote
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
|
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
|
||||||
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
|
/// favourites, boosts, bookmarks, downvotes, deleting, threads, who may quote the persona's posts (and revoking a quote
|
||||||
|
/// of one), and muting or blocking an author. Results are WebResults whose
|
||||||
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
|
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
@@ -228,6 +232,39 @@ namespace decePubClient.Services
|
|||||||
return await StatusResult(response, cancellationToken);
|
return await StatusResult(response, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who may quote the persona's own public or unlisted post (PUT /statuses/:id/interaction_policy); its
|
||||||
|
/// followers are told of the change. Data is the updated Status.</summary>
|
||||||
|
public async Task<WebResult> SetQuotePolicy(string statusId, QuotePolicy policy, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_StatusInteractionPolicy(statusId),
|
||||||
|
new[] { new KeyValuePair<string, string>("quote_approval_policy", QuotePolicyValue(policy)) }, cancellationToken: cancellationToken);
|
||||||
|
return await StatusResult(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The quote policy a status states (quote_approval.automatic): who may quote it without asking.</summary>
|
||||||
|
public static QuotePolicy QuotePolicyOf(Status status) => status.QuoteApproval?.Automatic switch
|
||||||
|
{
|
||||||
|
{ } automatic when automatic.Contains("public") => QuotePolicy.Public,
|
||||||
|
{ } automatic when automatic.Contains("followers") => QuotePolicy.Followers,
|
||||||
|
_ => QuotePolicy.Nobody
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A quote policy as the Mastodon API names it.</summary>
|
||||||
|
public static string QuotePolicyValue(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => "followers",
|
||||||
|
QuotePolicy.Nobody => "nobody",
|
||||||
|
_ => "public"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Takes back the permission to quote the persona's own post from a post quoting it (FEP-044f); Data is the
|
||||||
|
/// quoting Status as it is now, its quote revoked.</summary>
|
||||||
|
public async Task<WebResult> RevokeQuote(string quotedId, string quotingId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_RevokeQuote(quotedId, quotingId), 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>
|
/// <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)
|
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Dismisses a group of notifications (/api/v2/notifications/:group_key/dismiss): every notification of
|
||||||
|
/// the group goes; an ungrouped one's key is ungrouped-{id}.</summary>
|
||||||
|
public async Task<WebResult> DismissNotification(string groupKey, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissNotificationGroup(groupKey), 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>
|
/// <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)
|
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user