diff --git a/CLAUDE.md b/CLAUDE.md index 1d6bb0b..68f8729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, ``) is lo - picking a hue leaves grey. - `AppStatusService.IsDarkTheme()` tells which variant is showing. - Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones. + The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring). - `Styles/neo.css` holds the neumorphic layer: - `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the old `neomorph is-nxsmall` classes); @@ -187,9 +188,9 @@ supplies: - It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`, `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload can be an object (JSON), name/value pairs (a form) or a `Func` (multipart), built once per attempt. -- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` / - `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub - redirects it). +- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses + them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: + PrivaPub redirects it). - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from `Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410. @@ -213,7 +214,9 @@ supplies: block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and - following them, trends, the directory), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. + following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` + (`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and + `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. @@ -231,6 +234,18 @@ supplies: - **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media. +- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies + from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds + and adds what was found, in the thread's order. +- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the persona's profile (`ProfileEditor`: + `update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot, + discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, + blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers + and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import + resolves each row through PrivaPub and acts one row at a time; it can be stopped. +- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for + administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users, + and adding an admin endpoint waits for the owner, so the Users section stays empty. - **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 3fe90be..123790f 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -3,6 +3,7 @@ using decePubClient.Models; using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Admin; using PrivaPub.ClientModels.Group; using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.User; @@ -34,11 +35,20 @@ namespace decePubClient; [JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(InsertAvatarForm))] [JsonSerializable(typeof(ViewGroup))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ViewPost))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(InsertPostForm))] +[JsonSerializable(typeof(ViewReport))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DomainBlockForm))] +[JsonSerializable(typeof(ViewDomainBlock))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ViewStatisticsOverview))] +[JsonSerializable(typeof(ViewHostsPage))] +[JsonSerializable(typeof(ViewCrawler))] public partial class ClientJsonContext : JsonSerializerContext { } diff --git a/Components/Admin/ReportsPanel.razor b/Components/Admin/ReportsPanel.razor new file mode 100644 index 0000000..396b4a3 --- /dev/null +++ b/Components/Admin/ReportsPanel.razor @@ -0,0 +1,138 @@ +@inherits LocalizableComponentBase + +@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another + server, about an account and some of its posts; resolved here. *@ +
+ + + @if (IsLoading) + { +

+ + @Localizer["loading"] +

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

@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])

+ } + + @foreach (var report in reports) + { +
+
+ @CategoryName(report.Category) + @if (report.Forwarded) + { + @Localizer["Forwarded"] + } + + @report.CreatedAt.GetPassedTime(Localizer) + + + @if (!report.IsResolved) + { + + @Localizer["Resolve"] + + } + else if (report.ResolvedAt is { } resolvedAt) + { + @Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)] + } +
+

+ @Localizer["About"] + @if (report.TargetAccountId is { Length: > 0 } targetId) + { + @report.TargetActorURI + } + else + { + @report.TargetActorURI + } +

+

+ @Localizer["From"] + @(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter) +

+ @if (report.Comment is { Length: > 0 } comment) + { +

@comment

+ } + @if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0) + { +
+ @foreach (var postId in report.PostIds) + { + @Localizer["Post {0}", postId] + } + @* a report from another server names posts by their ids there when we hold none of them *@ + @if (report.PostIds.Count == 0) + { + @foreach (var uri in report.ObjectURIs) + { + @uri + } + } +
+ } +
+ } +
+ +@code { + [Inject] AdminService AdminService { get; set; } + + List reports = []; + bool resolved; + HashSet busy = []; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => reports = await AdminService.Reports(resolved); + + async Task ShowResolved(bool value) + { + resolved = value; + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + string CategoryName(string category) => category switch + { + "spam" => Localizer["Spam"], + "legal" => Localizer["Illegal"], + "violation" => Localizer["Breaks the rules"], + _ => Localizer["Other"] + }; + + async Task Resolve(ViewReport report) + { + busy.Add(report.ID); + var result = await AdminService.Resolve(report.ID); + busy.Remove(report.ID); + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + reports.Remove(report); + Toast.AddResponseSuccess(Localizer["Report resolved."]); + } +} diff --git a/Components/Admin/ServerBlocksPanel.razor b/Components/Admin/ServerBlocksPanel.razor new file mode 100644 index 0000000..b462095 --- /dev/null +++ b/Components/Admin/ServerBlocksPanel.razor @@ -0,0 +1,122 @@ +@inherits LocalizableComponentBase + +@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced + (kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@ +
+ + +
+ + + + + +
+ + + + + @Localizer["Block server"] + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

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

@Localizer["No server is blocked."]

+ } + + @foreach (var block in blocks) + { +
+
+ @block.Domain + + @(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"]) + + @if (block.RejectMedia) + { + @Localizer["Media refused"] + } + @block.CreatedAt.GetPassedTime(Localizer) + + @Localizer["Unblock"] + +
+ @if (block.PublicComment is { Length: > 0 } publicComment) + { +

@publicComment

+ } + @if (block.PrivateComment is { Length: > 0 } privateComment) + { +

@privateComment

+ } +
+ } +
+ +@code { + [Inject] AdminService AdminService { get; set; } + + List blocks = []; + DomainBlockForm form = new(); + bool isSaving; + HashSet busy = []; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => blocks = await AdminService.ServerBlocks(); + + async Task Block() + { + isSaving = true; + var result = await AdminService.BlockServer(form); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var block = (ViewDomainBlock)result.Data; + blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)]; + form = new(); + Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]); + } + + async Task Unblock(ViewDomainBlock block) + { + busy.Add(block.Domain); + var result = await AdminService.UnblockServer(block.Domain); + busy.Remove(block.Domain); + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + blocks.Remove(block); + } +} diff --git a/Components/Admin/StatisticsPanel.razor b/Components/Admin/StatisticsPanel.razor new file mode 100644 index 0000000..56265d7 --- /dev/null +++ b/Components/Admin/StatisticsPanel.razor @@ -0,0 +1,209 @@ +@inherits LocalizableComponentBase + +@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with + (by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@ +
+
+ + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

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

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

+ } + else + { + + + @Localizer["Deliveries that arrived"] + @(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—") + @Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)] + + + @Localizer["servers"] + @overview.Hosts.Active + @Localizer["{0} known", overview.Hosts.Known] + + + @Localizer["Accounts seen"] + @overview.Accounts + @Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped] + + + +
+ @foreach (var (title, counts) in Outcomes) + { +
+ @title + @foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value)) + { + @outcome@count + } +
+ } +
+ + @if (hosts is not null) + { + + + + @Localizer["server"] + @Localizer["software"] + @Localizer["In"] + @Localizer["Out"] + @Localizer["Refused"] + @Localizer["Failures"] + p50 + @Localizer["Delivery"] + + + + @foreach (var host in hosts.Hosts) + { + + @host.Host + @host.Software @host.Version + @host.In + @host.Out + @host.Refused + @host.Failures + @Ms(host.LatencyP50) + + @if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow) + { + @Localizer["paused"] + } + else if (host.Delivery?.ConsecutiveFailures > 0) + { + @Localizer["{0} failing", host.Delivery.ConsecutiveFailures] + } + + + } + + + @if (hosts.Total > PageSize) + { + + } +

@hosts.Attribution

+ } + + @if (crawler is not null) + { +
+ @Localizer["Crawler"] + + @(crawler.Enabled ? Localizer["on"] : Localizer["off"]) · + @Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed] + + @foreach (var host in crawler.Recent) + { + + @host.Host + @host.Software + @if (host.LastCrawledAt is { } crawled) + { + @crawled.GetPassedTime(Localizer) + } + @if (host.CrawlError is { Length: > 0 } crawlError) + { + @crawlError + } + + } +
+ } + } +
+ +@code { + [Inject] AdminService AdminService { get; set; } + + const int PageSize = 50; + static readonly int[] Periods = [1, 7, 30, 90]; + + ViewStatisticsOverview overview; + ViewHostsPage hosts; + ViewCrawler crawler; + int days = 30; + string sort = "traffic"; + int page = 1; + + (string Title, Dictionary Counts)[] Outcomes => + [ + (Localizer["Received"], overview.Inbound), + (Localizer["Delivered"], overview.Outbound), + (Localizer["Channels"], overview.Channels) + ]; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + overview = await AdminService.Overview(days); + hosts = await AdminService.Hosts(days, sort, page); + crawler = await AdminService.Crawler(); + } + + static string Ms(int? value) => value?.ToString() ?? "—"; + + async Task ChangeDays(int value) + { + days = value; + page = 1; + await LoadData(); + } + + async Task ChangeSort(string value) + { + sort = value; + page = 1; + hosts = await AdminService.Hosts(days, sort, page); + } + + async Task ChangePage(int value) + { + page = value; + hosts = await AdminService.Hosts(days, sort, page); + } +} diff --git a/Components/Composer.razor b/Components/Composer.razor index deaafbe..0944b54 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -258,9 +258,18 @@ _ => default }; + // a new post starts with the visibility the persona chose in Settings + MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch + { + "private" => MessageType.FollowersOnly, + "unlisted" => MessageType.Unlisted, + _ => MessageType.Public + }; + protected override async Task OnInitializedAsync() { form.GroupId = GroupId; + form.MessageType = DefaultVisibility; form.QuotedStatusId = Quoting?.Id; if (Editing is not null) { @@ -421,14 +430,14 @@ if (result.Data is ScheduledStatus scheduled) { Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]); - form = new() { GroupId = GroupId }; + form = new() { GroupId = GroupId, MessageType = DefaultVisibility }; await OnPublished.InvokeAsync(null); return; } Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]); var published = result.Data as FeedPost; - form = new() { GroupId = GroupId }; + form = new() { GroupId = GroupId, MessageType = DefaultVisibility }; await OnPublished.InvokeAsync(published); } diff --git a/Components/Content.razor b/Components/Content.razor index 459a07b..04f547a 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -60,9 +60,19 @@ } else if (Post.Conversation is { } conversation) { -

+

- @string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct)) + @string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct)) + @if (conversation.Unread) + { + @Localizer["unread"] + + + + } + + +

} @@ -139,6 +149,28 @@ @Body(status) } + @* the reactions stand outside the content warning: they are not what it hides *@ + @if (status.EmojiReactions.Count > 0) + { +
+ @foreach (var reaction in status.EmojiReactions) + { + + @if (reaction.Url is { Length: > 0 } image) + { + @reaction.Name + } + else + { + @reaction.Name + } + @reaction.Count + + } +
+ } + @if (Post.Located) {

@@ -151,13 +183,27 @@ {