R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
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:
thepraandClaude Opus 5.5 committed 2026-10-17 04:02:20 +02:00
1 parent b731d8c8c0
commit b8d7858acb
25 files changed
+1538 -56

No files matched your search

+51 -13
View File
@@ -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
+7
View File
@@ -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))]
+136
View File
@@ -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();
}
+58
View File
@@ -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
};
}
+155
View File
@@ -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();
}
+164 -4
View File
@@ -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)
+9 -3
View File
@@ -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
View File
@@ -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; }
+88
View File
@@ -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();
}
+147
View File
@@ -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);
}
}
+77
View File
@@ -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();
}
-2
View File
@@ -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:
+11
View File
@@ -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";
+4
View File
@@ -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; }
} }
+1 -2
View File
@@ -4,7 +4,6 @@
{ {
Images = 1, Images = 1,
Video, Video,
Audio, Audio
Documents
} }
} }
+61 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+111
View File
@@ -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>
+111
View File
@@ -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
View File
@@ -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);
+57 -1
View File
@@ -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);
+17 -2
View File
@@ -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);
+39 -2
View File
@@ -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)
{ {
+8
View File
@@ -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)
{ {