From d7d836ba3951d5874a8f70a5b897f62eed3e22ba Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 06:20:18 +0200 Subject: [PATCH] Settings, Administration and conversations; threads that fill in Settings, which held only the theme, now belong to the persona too: - its profile (update_credentials, a new PATCH in IHttpService): pictures, name, biography and fields as written, locked, bot, discoverable, indexable, and how its posts start (visibility, language, sensitive media, who may quote them); the composer starts at that visibility; - the root's personas, each used in turn, and a new one made; - the accounts it muted or blocked and the servers it blocked, each undone there, a server blocked from a pasted address or handle; - its follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes (PortabilityService): an import resolves each row through PrivaPub, one at a time, and can stop. Administration opens to moderators: reports, resolved there; and for administrators the servers blocked for everyone and the federation statistics (traffic by server, delivery success and latency, the crawler). The Direct tab pages conversations, badges the unread ones, and a card marks one read or takes it off the list; opening one reads it. A remote thread is read again 4 and 15 seconds after it opens, since PrivaPub then fetches the replies its servers hold. A thread its community locked shows a lock instead of a reply button, reactions stay visible below a content warning, and a searched handle opens its account's profile whatever else matched. The e2e tests cover each: the profile saved and read back, a persona made, servers blocked and imported, follows exported, an instance block and the statistics as the pasture's admin, a conversation read and removed, and a reply only Mastodon held appearing in its thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 23 +- ClientJsonContext.cs | 10 + Components/Admin/ReportsPanel.razor | 138 +++++++++++ Components/Admin/ServerBlocksPanel.razor | 122 ++++++++++ Components/Admin/StatisticsPanel.razor | 209 +++++++++++++++++ Components/Composer.razor | 13 +- Components/Content.razor | 124 +++++++--- Components/Feed/FeedTabs.razor | 6 + Components/Settings/DataTransfer.razor | 150 ++++++++++++ Components/Settings/MutesAndBlocks.razor | 229 ++++++++++++++++++ Components/Settings/PersonasEditor.razor | 78 +++++++ Components/Settings/ProfileEditor.razor | 210 +++++++++++++++++ Helpers/Languages.cs | 18 ++ LayerComponents/CascadingState.razor | 23 ++ MastodonJsonContext.cs | 1 + Models/APIs.cs | 18 ++ Models/Mastodon/Account.cs | 31 +++ Models/Mastodon/PrivaPubStatus.cs | 2 + Models/ProfileForm.cs | 101 ++++++++ Models/Types/QuotePolicy.cs | 10 + Pages/Administration.razor | 40 +++- Pages/ExpandMessage.razor | 56 ++++- Pages/Index.razor | 21 +- Pages/Search.razor | 7 +- Pages/Settings.razor | 53 ++++- Program.cs | 2 + Resources/AllStrings.it.resx | 282 +++++++++++++++++++++++ Resources/AllStrings.resx | 282 +++++++++++++++++++++++ Services/AccountService.cs | 94 ++++++++ Services/AdminService.cs | 104 +++++++++ Services/AuthService.cs | 10 + Services/IHttpService.cs | 5 + Services/PortabilityService.cs | 279 ++++++++++++++++++++++ Services/TimelineService.cs | 48 +++- Shared/NavMenu.razor | 2 +- _Imports.razor | 3 + tests/e2e/Actions/AdministrationTests.cs | 86 +++++++ tests/e2e/Actions/ConversationTests.cs | 58 +++++ tests/e2e/Actions/SettingsTests.cs | 208 +++++++++++++++++ tests/e2e/Actions/ThreadTests.cs | 61 +++++ 40 files changed, 3155 insertions(+), 62 deletions(-) create mode 100644 Components/Admin/ReportsPanel.razor create mode 100644 Components/Admin/ServerBlocksPanel.razor create mode 100644 Components/Admin/StatisticsPanel.razor create mode 100644 Components/Settings/DataTransfer.razor create mode 100644 Components/Settings/MutesAndBlocks.razor create mode 100644 Components/Settings/PersonasEditor.razor create mode 100644 Components/Settings/ProfileEditor.razor create mode 100644 Helpers/Languages.cs create mode 100644 Models/ProfileForm.cs create mode 100644 Models/Types/QuotePolicy.cs create mode 100644 Services/AdminService.cs create mode 100644 Services/PortabilityService.cs create mode 100644 tests/e2e/Actions/AdministrationTests.cs create mode 100644 tests/e2e/Actions/ConversationTests.cs create mode 100644 tests/e2e/Actions/SettingsTests.cs create mode 100644 tests/e2e/Actions/ThreadTests.cs 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 @@ {