diff --git a/CLAUDE.md b/CLAUDE.md index af25c42..682dc1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - No history entries are pushed. - **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in - `MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and - signing out. + `MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a + group, dropping a scheduled post and signing out. - **On a touch screen:** - a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are pinned to the globe with a mouse only; - a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`); - pictures open in `Components/MediaViewer.razor`. - **The feed on a phone:** - - a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd - stats' Details are in More, and the author's picture is in the header; + - a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and + the nerd stats' Details are in More, and the author's picture is in the header; - a new post starts from a floating button; - the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs; - the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`). @@ -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`; circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, - threads, mute, block, 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 `/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`), `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` - (`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and + (`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads, and deleting those no post holds), + `GroupService`, `InstanceService` and `ProvenanceService`. -- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll - vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in +- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote + 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, with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending, accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`). - **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:` in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse - post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js` + 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 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" @@ -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, 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`). + 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 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. @@ -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 lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks, conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs - its media (blur). + 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 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. @@ -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); - 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`); - - 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 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 @@ -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 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. +- **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 (`/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 @@ -390,6 +415,17 @@ Never redeclare the base's members in a component. - **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 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 (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 @@ -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 mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab - links there while requests wait. + 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 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 diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 9156247..ec32c40 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -73,6 +73,13 @@ namespace decePubClient; [JsonSerializable(typeof(ViewStatisticsOverview))] [JsonSerializable(typeof(ViewHostsPage))] [JsonSerializable(typeof(ViewCrawler))] +[JsonSerializable(typeof(ViewHostDetail))] +[JsonSerializable(typeof(ViewHostInstance))] +[JsonSerializable(typeof(ViewHostDay))] +[JsonSerializable(typeof(ViewInteractionEvent))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ViewServerDay))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(ViewBackups))] [JsonSerializable(typeof(ViewBackup))] [JsonSerializable(typeof(BackupPasswordForm))] diff --git a/Components/AccountListDialog.razor b/Components/AccountListDialog.razor new file mode 100644 index 0000000..79aa6b2 --- /dev/null +++ b/Components/AccountListDialog.razor @@ -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. *@ + + @(Followers ? Localizer["followers"] : Localizer["following"]) + + @if (IsLoading) + { +

@Localizer["loading"]

+ } + else if (accounts.Count == 0) + { +

@Localizer["Nobody yet."]

+ } +
+ @foreach (var account in accounts) + { + + } +
+ @if (maxId is not null) + { +
+ + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + +
+ } +
+
+ +@code { + [Inject] AccountService AccountService { get; set; } + [Inject] ConfirmService Confirm { get; set; } + + /// The persona's own account id. + [Parameter, EditorRequired] public string AccountId { get; set; } + /// Its followers, rather than the accounts it follows. + [Parameter] public bool Followers { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + /// A follower was removed. + [Parameter] public EventCallback OnRemoved { get; set; } + + List accounts = []; + HashSet 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(); +} diff --git a/Components/Admin/EventsTable.razor b/Components/Admin/EventsTable.razor new file mode 100644 index 0000000..fb3e036 --- /dev/null +++ b/Components/Admin/EventsTable.razor @@ -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. *@ + + + + @Localizer["When"] + @if (ShowHost) + { + @Localizer["server"] + } + @Localizer["Channel"] + @Localizer["Activity"] + @Localizer["Outcome"] + @Localizer["Reason"] + HTTP + ms + + + + @foreach (var happened in Events) + { + + @happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt) + @if (ShowHost) + { + @happened.Host + } + @happened.Channel + @happened.Activity + + @happened.Outcome + + @happened.Reason + @happened.Status + @happened.LatencyMs + + } + + + +@code { + [Parameter, EditorRequired] public List Events { get; set; } = []; + /// A column for the server, where the events are of many. + [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 + }; +} diff --git a/Components/Admin/HostDetailDialog.razor b/Components/Admin/HostDetailDialog.razor new file mode 100644 index 0000000..02f61e8 --- /dev/null +++ b/Components/Admin/HostDetailDialog.razor @@ -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. *@ + + @HostName + +
+
+ + @Localizer["Describe again"] + +
+ + @if (IsLoading) + { +

@Localizer["loading"]

+ } + else if (detail is null) + { +

@Localizer["The statistics could not be read."]

+ } + else + { + @if (detail.Instance is { } instance) + { + + } + + + + + @Localizer["Day"] + @Localizer["Received"] + @Localizer["In"] + @Localizer["Out"] + @Localizer["Failures"] + @Localizer["Accounts"] + + + + @foreach (var day in detail.Days.OrderByDescending(d => d.Day)) + { + + + @day.Day.ToString(SUtility.dateTimeFormat) + @if (day.Live) + { + @Localizer["live"] + } + + @Channel(day.Counters, "recv") + @Channel(day.Counters, "in") + @Channel(day.Counters, "out") + @Failures(day.Counters) + @day.Accounts + + } + + + + @if (detail.Events.Count > 0) + { + @Localizer["Last events"] + + } +

@detail.Attribution

+ } +
+
+
+ +@code { + [Inject] AdminService AdminService { get; set; } + + [Parameter, EditorRequired] public string HostName { get; set; } + /// How many days back its days go. + [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]); + } + + /// A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent). + static long Channel(Dictionary 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 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 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(); +} diff --git a/Components/Admin/StatisticsPanel.razor b/Components/Admin/StatisticsPanel.razor index 8c352f8..793b7b1 100644 --- a/Components/Admin/StatisticsPanel.razor +++ b/Components/Admin/StatisticsPanel.razor @@ -1,7 +1,10 @@ @inherits LocalizableComponentBase @* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with - (by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@ + (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. *@
@@ -148,11 +252,24 @@ [Inject] AdminService AdminService { get; set; } 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]; ViewStatisticsOverview overview; ViewHostsPage hosts; ViewCrawler crawler; + List events = []; + List serverDays = []; + // the server whose detail is open + string openHost; + string eventsHost; + string eventsChannel; + string eventsOutcome; + bool hasMoreEvents; + bool isLoadingMore; int days = 30; string sort = "traffic"; int page = 1; @@ -179,10 +296,53 @@ } async Task LoadData() + { + await LoadDays(); + crawler = await AdminService.Crawler(); + await ReloadEvents(); + } + + // what the period chosen shows + async Task LoadDays() { overview = await AdminService.Overview(days); 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() ?? "—"; @@ -191,7 +351,7 @@ { days = value; page = 1; - await LoadData(); + await LoadDays(); } async Task ChangeSort(string value) diff --git a/Components/Composer.razor b/Components/Composer.razor index bf3a484..72c6b68 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -124,7 +124,8 @@ 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 />
- + @@ -215,7 +216,7 @@ ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)"> - @foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio }) + @foreach (var mediaType in Enum.GetValues()) { } @@ -599,10 +600,15 @@ // 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 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(); 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 diff --git a/Components/Content.razor b/Components/Content.razor index 1362320..a1207f5 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -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 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 - 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 - the time, the author and its server's history, the place or the CDN, the media and the counts. *@ + hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this + 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) {
@NotificationText(notification) @notification.CreatedAt.GetPassedTime(Localizer) + @if (!Compact) + { + + + + }

} else if (Post.BoostedBy is { } booster) @@ -283,11 +293,24 @@ } - @* a threadiverse post's downvotes: its upvotes are its favourites *@ - @if (status.Privapub?.Votes is { Down: > 0 } votes) + @* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its + favourite (on a phone, in More) *@ + @if (CanDownvote(status) && status.Privapub.Votes is { } votes) + { + + } + else if (status.Privapub?.Votes is { Down: > 0 } ownVotes) { } + @if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes) + { + + + @(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"]) + @if (phoneVotes.Down > 0) + { + @phoneVotes.Down + } + + } @Localizer["Details"] @@ -376,6 +411,17 @@ } @if (!IsOwn) { + @if (QuotesOwnPost(status)) + { + + + @Localizer["Revoke quote"] + + } + + + @Localizer["Filter this post"] + @Localizer["Report"] @@ -398,6 +444,11 @@ @(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"]) + + + @Localizer["Who may quote it"] + } @@ -461,6 +512,14 @@ { } + @if (isFilteringPost) + { + + } + @if (isChoosingQuotePolicy) + { + + } @if (isHistoryOpen) { @@ -521,6 +580,8 @@ [Parameter, EditorRequired] public FeedPost Post { get; set; } [Parameter] public EventCallback OnDeleted { get; set; } + /// Its notification was dismissed: the page takes the item out. + [Parameter] public EventCallback OnDismissed { get; set; } [Parameter] public EventCallback OnReplied { get; set; } [Parameter] public EventCallback OnTag { get; set; } [Parameter] public string CssContainer { get; set; } @@ -547,6 +608,8 @@ bool isReadingServer; bool isHistoryOpen; bool isReporting; + bool isFilteringPost; + bool isChoosingQuotePolicy; // who liked it (false) or boosted it (true), when that list is open bool? showInteractedBy; List history = []; @@ -824,6 +887,74 @@ 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] PublicNoticeService Notices { get; set; } diff --git a/Components/FilterPostDialog.razor b/Components/FilterPostDialog.razor new file mode 100644 index 0000000..8975677 --- /dev/null +++ b/Components/FilterPostDialog.razor @@ -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. *@ + + @Localizer["Filter this post"] + + @if (IsLoading) + { +

@Localizer["loading"]

+ } + else if (filters.Count == 0) + { +

@Localizer["This persona has no filter yet."]

+ } +
+ @foreach (var filter in filters) + { + + + @filter.Title + @Description(filter) + + } + + @Localizer["Make a filter"] + +
+
+
+ +@code { + [Inject] FilterService FilterService { get; set; } + + [Parameter, EditorRequired] public string StatusId { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + /// The post went into a filter. + [Parameter] public EventCallback OnFiltered { get; set; } + + List 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(); +} diff --git a/Components/FollowRequestsPanel.razor b/Components/FollowRequestsPanel.razor new file mode 100644 index 0000000..c536c7e --- /dev/null +++ b/Components/FollowRequestsPanel.razor @@ -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) +{ +
+ @if (ShowHeading) + { +

@Localizer["Follow requests"]

+ } + @if (IsLoading) + { +

@Localizer["loading"]

+ } + else if (requests.Count == 0) + { +

@Localizer["No follow request is waiting."]

+ } + @foreach (var account in requests) + { +
+ + @* alt="": the name is next to it. *@ + + + @CustomEmojis.Text(account.Name, account.Emojis) + @("@" + account.Acct) + + + @if (answers.TryGetValue(account.Id, out var accepted)) + { + + @(accepted ? Localizer["Accepted."] : Localizer["Refused."]) + + } + else + { +
+ + @Localizer["Accept"] + + + @Localizer["Refuse"] + +
+ } +
+ } + @if (maxId is not null) + { + + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + + } +
+} + +@code { + [Inject] AccountService AccountService { get; set; } + + /// Renders nothing once read with nobody waiting (the persona's own profile). + [Parameter] public bool HideWhenEmpty { get; set; } + [Parameter] public bool ShowHeading { get; set; } + /// A request was answered: true when accepted. + [Parameter] public EventCallback OnAnswered { get; set; } + + List requests = []; + // what was answered here, by account id: accepted (true) or refused + Dictionary answers = new(); + HashSet 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); + } +} diff --git a/Components/QuotePolicyDialog.razor b/Components/QuotePolicyDialog.razor new file mode 100644 index 0000000..15b9608 --- /dev/null +++ b/Components/QuotePolicyDialog.razor @@ -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. *@ + + @Localizer["Who may quote it"] + +
+ @foreach (var policy in Enum.GetValues()) + { + + + @PolicyName(policy) + @if (policy == current) + { + + } + + } +
+
+
+ +@code { + [Inject] StatusService StatusService { get; set; } + + [Parameter, EditorRequired] public Status Status { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + /// The post as PrivaPub now describes it, its new policy in it. + [Parameter] public EventCallback 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(); +} diff --git a/Extensions/GenericExtensions.cs b/Extensions/GenericExtensions.cs index c274296..903a7e5 100644 --- a/Extensions/GenericExtensions.cs +++ b/Extensions/GenericExtensions.cs @@ -86,8 +86,6 @@ public static class GenericExtensions return "ion-md-images"; case MediaType.Video: return "ion-md-videocam"; - case MediaType.Documents: - return "ion-md-document"; case MediaType.Audio: return "ion-md-volume-high"; default: diff --git a/Models/APIs.cs b/Models/APIs.cs index 2aef675..719e8e6 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -56,6 +56,10 @@ public static class APIs public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview"; public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts"; public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler"; + public static 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 POST_AdminBackup = "clientapi/admin/backups"; 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 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 DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}"; public const string GET_InstanceV2 = "api/v2/instance"; public const string DELETE_ProfileAvatar = "api/v1/profile/avatar"; 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_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_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_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context"; 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) => $"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_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_GroupedNotifications = "api/v2/notifications"; 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 GET_TrendingTags = "api/v1/trends/tags"; public const string GET_TrendingStatuses = "api/v1/trends/statuses"; diff --git a/Models/Mastodon/PrivaPubStatus.cs b/Models/Mastodon/PrivaPubStatus.cs index b179bd6..d7ae0d2 100644 --- a/Models/Mastodon/PrivaPubStatus.cs +++ b/Models/Mastodon/PrivaPubStatus.cs @@ -94,8 +94,12 @@ public class PrivaPubPlace public string Url { get; set; } } +/// 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. public class PrivaPubVotes { public int Up { get; set; } public int Down { get; set; } + /// The persona's own downvote (POST /api/v1/statuses/:id/downvote, /undownvote); null with no persona. + public bool? Downvoted { get; set; } } diff --git a/Models/Types/MediaType.cs b/Models/Types/MediaType.cs index 2a5ca7f..f75a8aa 100644 --- a/Models/Types/MediaType.cs +++ b/Models/Types/MediaType.cs @@ -4,7 +4,6 @@ { Images = 1, Video, - Audio, - Documents + Audio } } diff --git a/Pages/Filters.razor b/Pages/Filters.razor index 59c32a6..d9775b0 100644 --- a/Pages/Filters.razor +++ b/Pages/Filters.razor @@ -6,7 +6,9 @@ @* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them - (Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@ + (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. *@
@@ -124,11 +126,33 @@ @if (filter.Statuses.Count > 0) {

@Localizer["Posts filtered one by one"]

-
+
@foreach (var item in filter.Statuses) { -
- @item.StatusId +
+ @if (PostOf(item) is { } post) + { + + @* alt="": the name is next to it. *@ + + + + @CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? []) + @("@" + post.Account?.Acct) + + @(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"]) + + + } + else if (readPostIds.Contains(item.StatusId)) + { + @Localizer["Post no longer available"] + } + else + { + + } @@ -170,6 +194,7 @@ static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur]; [Inject] FilterService FilterService { get; set; } + [Inject] TimelineService TimelineService { get; set; } List filters = []; TitleForm draft = new(); @@ -178,6 +203,9 @@ string loadedPersonaId; bool isSaving; 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 filteredPosts = new(); + HashSet readPostIds = []; (int Seconds, string Label)[] Expiries => [ @@ -240,6 +268,8 @@ { IsLoading = true; open = null; + filteredPosts = new(); + readPostIds = []; filters = await FilterService.All(); IsLoading = false; } @@ -260,10 +290,10 @@ filters.Add(filter); draft = new(); Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]); - Toggle(filter); + await Toggle(filter); } - void Toggle(Filter filter) + async Task Toggle(Filter filter) { isConfirmingDelete = false; if (open?.Id == filter.Id) @@ -281,6 +311,31 @@ ExpiresIn = KeepExpiry, 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) diff --git a/Pages/Index.razor b/Pages/Index.razor index e227f3d..284771f 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -41,12 +41,36 @@ - @if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0) + @if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0)) { - - - @Localizer["{0} filtered, waiting for you", filteredRequests] - +
+ @if (followRequests > 0) + { + + + @Localizer["{0} follow requests waiting", followRequests] + + } + @if (filteredRequests > 0) + { + + + @Localizer["{0} filtered, waiting for you", filteredRequests] + + } +
+ } + @if (isFollowRequestsOpen) + { + + @Localizer["Follow requests"] + +
+ +
+
+
} @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 *@ - +
@@ -127,6 +151,7 @@ [Inject] InstanceService InstanceService { get; set; } [Inject] RequestStats RequestStats { get; set; } [Inject] FeedMemory Memory { get; set; } + [Inject] AccountService AccountService { get; set; } FeedFilter Filter { get; set; } = new(); List posts = []; @@ -136,6 +161,9 @@ int unreadNotifications; // the requests the persona's notification policy filtered, shown on the Notifications tab 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; string maxId; string minId; @@ -431,8 +459,11 @@ await TimelineService.ReadNotificationsUpTo(newest, cancellationToken); unreadNotifications = 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); + /// The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream. 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); } + // 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 FeedStats() { yield return NerdRow.Of(Localizer["items"], posts.Count); diff --git a/Pages/Profile.razor b/Pages/Profile.razor index 1a98254..d78fd53 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -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 to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts, page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows, - never from its server directly. *@ + 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. *@
@@ -142,13 +144,34 @@ } -
+
@account.StatusesCount @Localizer["posts"] - @account.FollowingCount @Localizer["following"] - @account.FollowersCount @Localizer["followers"] + @if (IsOwn) + { + + + } + else + { + @account.FollowingCount @Localizer["following"] + @account.FollowersCount @Localizer["followers"] + }
+ @if (IsOwn) + { + @if (showAccounts is { } followers) + { + + } + + } +
@Localizer["posts"]
@Localizer["Posts and replies"]
@@ -212,6 +235,8 @@ bool isLoadingMore; bool isListMenuOpen; bool isReporting; + // the persona's own followers (true) or the accounts it follows (false), while that list is open + bool? showAccounts; List allLists = []; HashSet inLists = []; @@ -359,6 +384,8 @@ }); } + void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1); + void Remove(FeedPost post) { posts.RemoveAll(p => p.Key == post.Key); diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 47d06e7..3b2c139 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -2486,4 +2486,115 @@ La verifica in due passaggi è disattivata. + + Richieste di follow + + + {0} richieste di follow in attesa + + + Nessuna richiesta di follow in attesa. + + + Ancora nessuno. + + + Togli dai follower + + + Togliere {0} dai tuoi follower? + + + Non vedrà più i tuoi post per soli follower, e potrà chiedere di nuovo di seguirti. + + + {0} tolto dai tuoi follower. + + + Voto negativo + + + Togli il voto negativo + + + Filtra questo post + + + Già presente + + + Aggiunto a {0}. + + + Chi può citarlo + + + I tuoi follower + + + Revoca la citazione + + + Revocare questa citazione del tuo post? + + + Il tuo post non vi comparirà più. + + + Citazione revocata. + + + Post non più disponibile + + + Rileggi la descrizione + + + PrivaPub rileggerà ciò che {0} dice di sé. + + + Giorno + + + in corso + + + Ultimi eventi + + + Quando + + + Canale + + + Attività + + + Esito + + + Motivo + + + Eventi + + + Tutti + + + Giorni del server + + + API client + + + Serviti + + + Registro + + + scritti / scartati / falliti + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 0d0dd0d..a8a39ac 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -2486,4 +2486,115 @@ Two-factor sign-in is off. + + Follow requests + + + {0} follow requests waiting + + + No follow request is waiting. + + + Nobody yet. + + + Remove from followers + + + Remove {0} from your followers? + + + They no longer see your followers-only posts, and may ask to follow you again. + + + {0} removed from your followers. + + + Downvote + + + Remove the downvote + + + Filter this post + + + Already in it + + + Added to {0}. + + + Who may quote it + + + Your followers + + + Revoke quote + + + Revoke this quote of your post? + + + Your post no longer shows in it. + + + Quote revoked. + + + Post no longer available + + + Describe again + + + PrivaPub will read what {0} says of itself again. + + + Day + + + live + + + Last events + + + When + + + Channel + + + Activity + + + Outcome + + + Reason + + + Events + + + All + + + Server days + + + Client API + + + Served + + + Ledger + + + written / dropped / failed + diff --git a/Services/AccountService.cs b/Services/AccountService.cs index b3da097..f06edc5 100644 --- a/Services/AccountService.cs +++ b/Services/AccountService.cs @@ -30,7 +30,7 @@ namespace decePubClient.Services /// /// 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. /// public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) @@ -154,16 +154,10 @@ namespace decePubClient.Services return []; } - /// The accounts waiting for the persona to accept their follow. - public async Task> FollowRequests(CancellationToken cancellationToken = default) - { - using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken); - if (response.IsSuccessStatusCode) - return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; - - await LogFailure(response, nameof(FollowRequests), cancellationToken); - return []; - } + /// A page of the accounts waiting for the persona to accept their follow, newest first; Data is an + /// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one). + public Task FollowRequests(string maxId, CancellationToken cancellationToken = default) => + Accounts(APIs.Mastodon.GET_FollowRequests, maxId, cancellationToken); /// Accepts (authorize) or refuses (reject) a follow request. public async Task Answer(string accountId, bool accept, CancellationToken cancellationToken = default) @@ -237,6 +231,21 @@ namespace decePubClient.Services public Task Following(string accountId, string maxId, CancellationToken cancellationToken = default) => Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken); + /// A page of the persona's followers (only the persona itself may read it: PrivaPub answers [] for anyone + /// else's); Data is an AccountPage. + public Task Followers(string accountId, string maxId, CancellationToken cancellationToken = default) => + Accounts(APIs.Mastodon.GET_M_AccountFollowers(accountId), maxId, cancellationToken); + + /// Removes an account from the persona's followers (its server is told); Data is the new Relationship. + public async Task 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(cancellationToken) }; + } + /// A page of the accounts the persona muted; Data is an AccountPage. public Task Mutes(string maxId, CancellationToken cancellationToken = default) => Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken); diff --git a/Services/AdminService.cs b/Services/AdminService.cs index dead7ac..3007a29 100644 --- a/Services/AdminService.cs +++ b/Services/AdminService.cs @@ -10,7 +10,8 @@ namespace decePubClient.Services { /// /// 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. /// public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) @@ -160,6 +161,61 @@ namespace decePubClient.Services return null; } + /// 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. + public async Task 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(cancellationToken); + + if (response.StatusCode is not HttpStatusCode.NotFound) + await LogFailure(response, nameof(Host), cancellationToken); + return null; + } + + /// Asks PrivaPub to read the server's NodeInfo and instance description again (a queued job, answered 202). + public async Task 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); + } + + /// 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. + public async Task> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, + CancellationToken cancellationToken = default) + { + var parameters = new List { $"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>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Events), cancellationToken); + return []; + } + + /// The server's own days, oldest first: its client API, the documents it served and its ledger. + public async Task> 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>(cancellationToken) ?? []; + + await LogFailure(response, nameof(ServerDays), cancellationToken); + return []; + } + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) { var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); diff --git a/Services/MediaService.cs b/Services/MediaService.cs index 3f910c5..ca8ba37 100644 --- a/Services/MediaService.cs +++ b/Services/MediaService.cs @@ -11,9 +11,10 @@ namespace decePubClient.Services { /// /// 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. /// - public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer) + public class MediaService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { static readonly MediaConfiguration Fallback = new() { @@ -79,6 +80,20 @@ namespace decePubClient.Services return new WebResult(); } + /// 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. + public async Task 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 Attachment(HttpResponseMessage response, CancellationToken cancellationToken) { var attachment = await response.MastodonReadFromJsonAsync(cancellationToken); diff --git a/Services/StatusService.cs b/Services/StatusService.cs index ad96d24..5504ef0 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -21,12 +21,16 @@ namespace decePubClient.Services Bookmark, Unbookmark, Pin, - Unpin + Unpin, + /// PrivaPub's own, for the threadiverse: a downvote replaces the persona's favourite (its upvote). + Downvote, + Undownvote } /// /// 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. /// public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer) @@ -228,6 +232,39 @@ namespace decePubClient.Services return await StatusResult(response, cancellationToken); } + /// 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. + public async Task SetQuotePolicy(string statusId, QuotePolicy policy, CancellationToken cancellationToken = default) + { + using var response = await httpService.Put(APIs.Mastodon.PUT_M_StatusInteractionPolicy(statusId), + new[] { new KeyValuePair("quote_approval_policy", QuotePolicyValue(policy)) }, cancellationToken: cancellationToken); + return await StatusResult(response, cancellationToken); + } + + /// The quote policy a status states (quote_approval.automatic): who may quote it without asking. + 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 + }; + + /// A quote policy as the Mastodon API names it. + public static string QuotePolicyValue(QuotePolicy policy) => policy switch + { + QuotePolicy.Followers => "followers", + QuotePolicy.Nobody => "nobody", + _ => "public" + }; + + /// 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. + public async Task 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); + } + /// Votes in a poll as the persona (each option by its index); Data is the updated Poll. public async Task Vote(string pollId, IReadOnlyCollection choices, CancellationToken cancellationToken = default) { diff --git a/Services/TimelineService.cs b/Services/TimelineService.cs index 5bf3cb5..56205f7 100644 --- a/Services/TimelineService.cs +++ b/Services/TimelineService.cs @@ -156,6 +156,14 @@ namespace decePubClient.Services return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } + /// 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}. + public async Task 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); + } + /// Moves the notifications marker to the newest one seen, which reads everything up to it. public async Task ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default) {