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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 16:47:19 +02:00
1 parent bb68ff8d5e
commit a0f07abf8c
17 files changed
+410 -16

No files matched your search

+29
View File
@@ -302,6 +302,20 @@
<span>@Localizer["Open original"]</span>
</a>
}
@if (status.FavouritesCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = false)" data-liked-by>
<i aria-hidden="true" class="ion-md-heart text-base"></i>
<span>@Localizer["Liked by"]</span>
</DButton>
}
@if (status.ReblogsCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = true)" data-boosted-by>
<i aria-hidden="true" class="ion-md-repeat text-base"></i>
<span>@Localizer["Boosted by"]</span>
</DButton>
}
@if (CanQuote(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
@@ -318,6 +332,10 @@
}
@if (!IsOwn)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span>
@@ -370,6 +388,14 @@
{
<Composer Editing="status" OnPublished="Edited" />
}
@if (isReporting && status.Account is { } reported)
{
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
}
@if (showInteractedBy is { } boosts)
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isHistoryOpen)
{
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
@@ -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<StatusEdit> history = [];
readonly HashSet<string> revealedMedia = [];
+59
View File
@@ -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. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (accounts.Count == 0)
{
<p class="text-sm opacity-70 mt-3">@Localizer["Nobody this server knows of."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var account in accounts)
{
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] StatusService StatusService { get; set; }
[Parameter, EditorRequired] public string StatusId { get; set; }
/// <summary>Who boosted it, rather than who liked it.</summary>
[Parameter] public bool Boosts { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
List<Account> 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();
}
+62
View File
@@ -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. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
<ChildContent>
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<ErrorViewer AdditionalError="@error" />
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect>
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
@if (Host is { } host)
{
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
}
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
</EditForm>
</ChildContent>
</DModal>
@code {
[Inject] AccountService AccountService { get; set; }
[Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary>
[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();
}