From a0f07abf8c47888bcec772f070f2750f4ed5660a Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 16:47:19 +0200 Subject: [PATCH] Reports, and who liked or boosted a post Another's profile and posts can be reported from the client (/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 (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin helper the tests share moves to Support. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 + Components/Content.razor | 29 ++++++++ Components/InteractedByDialog.razor | 59 +++++++++++++++++ Components/ReportDialog.razor | 62 +++++++++++++++++ MastodonJsonContext.cs | 1 + Models/APIs.cs | 3 + Models/Mastodon/PrivaPubApi.cs | 12 ++++ Models/ReportForm.cs | 18 +++++ Models/Types/ReportCategory.cs | 10 +++ Pages/Profile.razor | 9 +++ Resources/AllStrings.it.resx | 30 +++++++++ Resources/AllStrings.resx | 30 +++++++++ Services/AccountService.cs | 17 +++++ Services/StatusService.cs | 14 ++++ tests/e2e/Actions/AdministrationTests.cs | 19 +----- tests/e2e/Actions/ReportTests.cs | 84 ++++++++++++++++++++++++ tests/e2e/Support/PastureAdmin.cs | 26 ++++++++ 17 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 Components/InteractedByDialog.razor create mode 100644 Components/ReportDialog.razor create mode 100644 Models/ReportForm.cs create mode 100644 Models/Types/ReportCategory.cs create mode 100644 tests/e2e/Actions/ReportTests.cs create mode 100644 tests/e2e/Support/PastureAdmin.cs diff --git a/CLAUDE.md b/CLAUDE.md index 37e9841..3640ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,9 @@ supplies: persona pins and unpins its own public and unlisted posts from the card's menu. An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), linking the new account's profile. + 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`). - **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. diff --git a/Components/Content.razor b/Components/Content.razor index 4b57c0b..a29dce8 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -302,6 +302,20 @@ @Localizer["Open original"] } + @if (status.FavouritesCount > 0) + { + + + @Localizer["Liked by"] + + } + @if (status.ReblogsCount > 0) + { + + + @Localizer["Boosted by"] + + } @if (CanQuote(status)) { @@ -318,6 +332,10 @@ } @if (!IsOwn) { + + + @Localizer["Report"] + @Localizer["Mute"] @@ -370,6 +388,14 @@ { } + @if (isReporting && status.Account is { } reported) + { + + } + @if (showInteractedBy is { } boosts) + { + + } @if (isHistoryOpen) { @@ -452,6 +478,9 @@ bool isReadingProvenance; bool isReadingServer; bool isHistoryOpen; + bool isReporting; + // who liked it (false) or boosted it (true), when that list is open + bool? showInteractedBy; List history = []; readonly HashSet revealedMedia = []; diff --git a/Components/InteractedByDialog.razor b/Components/InteractedByDialog.razor new file mode 100644 index 0000000..72ba5d8 --- /dev/null +++ b/Components/InteractedByDialog.razor @@ -0,0 +1,59 @@ +@inherits LocalizableComponentBase + +@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to + have done so, newest first, each opening its profile. *@ + + @(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"]) + + @if (IsLoading) + { +

@Localizer["loading"]

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

@Localizer["Nobody this server knows of."]

+ } + +
+
+ +@code { + [Inject] StatusService StatusService { get; set; } + + [Parameter, EditorRequired] public string StatusId { get; set; } + /// Who boosted it, rather than who liked it. + [Parameter] public bool Boosts { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + + List accounts = []; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts); + + Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync(); +} diff --git a/Components/ReportDialog.razor b/Components/ReportDialog.razor new file mode 100644 index 0000000..e4ff613 --- /dev/null +++ b/Components/ReportDialog.razor @@ -0,0 +1,62 @@ +@inherits LocalizableComponentBase + +@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports). + Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@ + + @Localizer["Report {0}", Account.Name] + + + + + + + + + + + @if (Host is { } host) + { + +

@Localizer["The copy comes from the server, never from you."]

+ } + @Localizer["Report"] +
+
+
+ +@code { + [Inject] AccountService AccountService { get; set; } + + [Parameter, EditorRequired] public Account Account { get; set; } + /// The post it was opened from, sent with the report. + [Parameter] public Status Post { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + + ReportForm form = new(); + bool isSending; + string error; + + // the account's own server, for a remote account + string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null; + + protected override void OnInitialized() => IsLoading = false; + + async Task Send() + { + isSending = true; + error = null; + var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward); + isSending = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]); + await OnClosed.InvokeAsync(); + } + + Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync(); +} diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index 8391e1e..318c3c9 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -37,6 +37,7 @@ namespace decePubClient; [JsonSerializable(typeof(StatusEditForm))] [JsonSerializable(typeof(StatusSource))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ReportRequest))] [JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(PollVoteForm))] [JsonSerializable(typeof(MastodonList))] diff --git a/Models/APIs.cs b/Models/APIs.cs index 58b26cb..63f5784 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -101,6 +101,9 @@ public static class APIs public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source"; public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history"; + public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by"; + public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by"; + public const string POST_Reports = "api/v1/reports"; public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public const string GET_FollowedTags = "api/v1/followed_tags"; public const string GET_GroupedNotifications = "api/v2/notifications"; diff --git a/Models/Mastodon/PrivaPubApi.cs b/Models/Mastodon/PrivaPubApi.cs index 77c8d05..bfec258 100644 --- a/Models/Mastodon/PrivaPubApi.cs +++ b/Models/Mastodon/PrivaPubApi.cs @@ -206,6 +206,18 @@ public class StatusSource public string SpoilerText { get; set; } } +/// /api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded, +/// a remote account's server gets it too, from the server's own actor. +public class ReportRequest +{ + public string AccountId { get; set; } + public List StatusIds { get; set; } = []; + public string Comment { get; set; } + /// spam, legal or other. + public string Category { get; set; } + public bool Forward { get; set; } +} + /// /api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last. public class StatusEdit { diff --git a/Models/ReportForm.cs b/Models/ReportForm.cs new file mode 100644 index 0000000..0c78935 --- /dev/null +++ b/Models/ReportForm.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +using decePubClient.Models.Types; +using decePubClient.Resources; + +namespace decePubClient.Models; + +/// A report of an account, and of the post it was made from, as the report dialog edits it. +public class ReportForm +{ + public const int CommentLength = 1000; + + public ReportCategory Category { get; set; } = ReportCategory.Other; + [StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))] + public string Comment { get; set; } + /// A copy to a remote account's own server, from PrivaPub's actor. + public bool Forward { get; set; } = true; +} diff --git a/Models/Types/ReportCategory.cs b/Models/Types/ReportCategory.cs new file mode 100644 index 0000000..e9fd597 --- /dev/null +++ b/Models/Types/ReportCategory.cs @@ -0,0 +1,10 @@ +namespace decePubClient.Models.Types +{ + /// Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of). + public enum ReportCategory + { + Other = 0, + Spam = 1, + Legal = 2 + } +} diff --git a/Pages/Profile.razor b/Pages/Profile.razor index 869d090..625cb4f 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -98,7 +98,15 @@ @(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])
+ + + @Localizer["Report"] + + @if (isReporting) + { + + } } @@ -201,6 +209,7 @@ bool isActing; bool isLoadingMore; bool isListMenuOpen; + bool isReporting; List allLists = []; HashSet inLists = []; diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 140e70d..2af4c21 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1745,4 +1745,34 @@ modificato + + Segnala + + + Segnala {0} + + + Contro la legge + + + Che cosa c'รจ che non va? + + + Invia una copia a {0} + + + La copia parte dal server, mai da te. + + + Segnalazione inviata ai moderatori. + + + Piace a + + + Condiviso da + + + Nessuno che questo server conosca. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index cd3da04..60b4bc3 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1745,4 +1745,34 @@ edited + + Report + + + Report {0} + + + Against the law + + + What is wrong with it? + + + Send a copy to {0} + + + The copy comes from the server, never from you. + + + Report sent to the moderators. + + + Liked by + + + Boosted by + + + Nobody this server knows of. + diff --git a/Services/AccountService.cs b/Services/AccountService.cs index d31fff2..0ca9d04 100644 --- a/Services/AccountService.cs +++ b/Services/AccountService.cs @@ -5,6 +5,7 @@ using collAnon.Client.Services; using decePubClient.Extensions; using decePubClient.Models; using decePubClient.Models.Mastodon; +using decePubClient.Models.Types; using PrivaPub.ClientModels; @@ -250,6 +251,22 @@ namespace decePubClient.Services return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } + /// Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server + /// gets the report too, from PrivaPub's actor and never naming the persona. + public async Task Report(string accountId, List statusIds, string comment, ReportCategory category, bool forward, + CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest + { + AccountId = accountId, + StatusIds = statusIds, + Comment = comment?.Trim(), + Category = category.ToString().ToLowerInvariant(), + Forward = forward + }, cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + public async Task UnblockDomain(string domain, CancellationToken cancellationToken = default) { using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"], diff --git a/Services/StatusService.cs b/Services/StatusService.cs index fdcbabe..8b53241 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -90,6 +90,20 @@ namespace decePubClient.Services return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; } + /// Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read. + public async Task> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId), + ["limit=80"], cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken); + return []; + } + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + } + /// Edits the persona's own post: new words and warning, the media kept; Data is the edited Status. public async Task Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default) { diff --git a/tests/e2e/Actions/AdministrationTests.cs b/tests/e2e/Actions/AdministrationTests.cs index f062200..91711e4 100644 --- a/tests/e2e/Actions/AdministrationTests.cs +++ b/tests/e2e/Actions/AdministrationTests.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Json; using System.Text.Json.Nodes; using decePubClient.E2E.Support; @@ -11,22 +10,10 @@ namespace decePubClient.E2E.Actions // and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi public sealed class AdministrationTests(Browser browser) { - const string Root = "pastureroot"; - const string Password = "Pasture-Pass-1!"; - static readonly Pasture.Persona Admin = new("alice", "alice", Root, Password, null, null, null); + const string Root = PastureAdmin.Root; + static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null); - static async Task AsAdmin(string path) - { - using var http = Pasture.Http(); - var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password }); - login.EnsureSuccessStatusCode(); - var jwt = (await login.Content.ReadFromJsonAsync())!["token"]!.GetValue(); - using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path); - request.Headers.Authorization = new("Bearer", jwt); - using var response = await http.SendAsync(request); - response.EnsureSuccessStatusCode(); - return JsonNode.Parse(await response.Content.ReadAsStringAsync()); - } + static Task AsAdmin(string path) => PastureAdmin.Get(path); static async Task Open(IPage page, string section) { diff --git a/tests/e2e/Actions/ReportTests.cs b/tests/e2e/Actions/ReportTests.cs new file mode 100644 index 0000000..960c445 --- /dev/null +++ b/tests/e2e/Actions/ReportTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // A persona reports a remote account from its profile, with a category, its words and a copy to the account's server: + // PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed + // from its card + public sealed class ReportTests(Browser browser) + { + [Fact] + public async Task A_remote_account_is_reported_from_its_profile() + { + var persona = Pasture.Personas().First(); + var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault(); + Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon"); + var targetId = target!["id"]!.GetValue(); + var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40]; + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.action.report", "mastodon", persona.UserName, async () => + { + await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}"); + // (the profile's own button: each of its posts' cards has one too) + await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 }); + var dialog = page.Locator("[data-report-dialog]"); + await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam"); + await dialog.Locator("[data-report-comment]").FillAsync(words); + await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync(); + await dialog.Locator("[data-report-send]").ClickAsync(); + await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 }); + + var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray() + .FirstOrDefault(r => r!["comment"]?.GetValue() == words); + Assert.NotNull(report); + Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue(), report["category"]!.GetValue(), + report["forwarded"]!.GetValue())); + await session.AssertGuards(); + }, page); + } + + [Fact] + public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card() + { + var personas = Pasture.Personas(); + Assert.SkipWhen(personas.Count < 2, "the town has one persona"); + var (author, fan) = (personas[0], personas[1]); + var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post, + new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!; + var id = post["id"]!.GetValue(); + var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue(); + await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post); + await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post); + await using var session = await SignIn.As(browser, author); + var page = session.Page; + try + { + await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () => + { + await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}"); + var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") }) + { + await card.Locator("footer [title='Other'] > [role=button]").ClickAsync(); + await card.Locator($"button[{button}]").ClickAsync(); + var dialog = card.Locator($"[data-interacted-by='{list}']"); + await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 }); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(dialog).ToHaveCountAsync(0); + } + await session.AssertGuards(); + }, page); + } + finally + { + await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete); + } + } + } +} diff --git a/tests/e2e/Support/PastureAdmin.cs b/tests/e2e/Support/PastureAdmin.cs new file mode 100644 index 0000000..6e70c31 --- /dev/null +++ b/tests/e2e/Support/PastureAdmin.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace decePubClient.E2E.Support +{ + // PrivaPub's /clientapi as the pasture's administrator (pastureroot, whom the scenarios make): what its moderators + // and administrators see + public static class PastureAdmin + { + public const string Root = "pastureroot"; + public const string Password = "Pasture-Pass-1!"; + + public static async Task Get(string path) + { + using var http = Pasture.Http(); + var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password }); + login.EnsureSuccessStatusCode(); + var jwt = (await login.Content.ReadFromJsonAsync())!["token"]!.GetValue(); + using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path); + request.Headers.Authorization = new("Bearer", jwt); + using var response = await http.SendAsync(request); + response.EnsureSuccessStatusCode(); + return JsonNode.Parse(await response.Content.ReadAsStringAsync()); + } + } +}