2026-10-05 06:20:18 +02:00
@inherits LocalizableComponentBase
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
2026-10-17 04:32:57 +02:00
server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub
2026-10-17 05:41:10 +02:00
keeps them, a removed rule's too). "Resolve and…" resolves one with what the staff does about it (nothing more, the
account silenced or suspended, the reported posts removed), a note for the staff and a warning for a persona here; the
act comes first, so one that fails leaves the report open. A resolved report shows what was done and its note. *@
2026-10-05 06:20:18 +02:00
<div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (reports.Count == 0)
{
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
}
@foreach (var report in reports)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
<div class="flex flex-wrap items-center gap-2">
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
@if (report.Forwarded)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
}
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
@report.CreatedAt.GetPassedTime(Localizer)
</span>
<span class="flex-1"></span>
@if (!report.IsResolved)
{
2026-10-17 05:41:10 +02:00
<DDropdown IsOpen="@(menuOpen == report.ID)" IsOpenChanged="open => menuOpen = open ? report.ID : null" Align="DDropdownAlign.End"
CloseOnContentClick="true" SheetOnNarrow="true"
TriggerClass="@(menuOpen == report.ID ? "btn btn-sm btn-success btn-active" : "btn btn-sm btn-success")"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box w-64 max-md:w-full bg-base-100 neo-raised neo-xs" data-report-resolve>
<Trigger>
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Resolve and…"]</span>
</Trigger>
<ChildContent>
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.None)"
data-report-resolve-action="none">
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Resolve only"]</span>
</DButton>
@if (report.TargetAccountId is { Length: > 0 })
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.Silence)"
data-report-resolve-action="silence">
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence the account"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.Suspend)"
data-report-resolve-action="suspend">
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend the account"]</span>
</DButton>
}
@if (report.PostIds.Count > 0)
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="IsDefaultDisabled" OnClick="() => AskResolve(report, ModerationActions.RemovePosts)"
data-report-resolve-action="remove_posts">
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove the reported posts"]</span>
</DButton>
}
</ChildContent>
</DDropdown>
2026-10-05 06:20:18 +02:00
}
else if (report.ResolvedAt is { } resolvedAt)
{
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
}
</div>
2026-10-17 05:41:10 +02:00
@if (report.IsResolved && report.Resolution is { Length: > 0 } resolution)
{
<div class="flex flex-col gap-1 text-sm rounded-lg neo-inset neo-2xs p-2" data-report-resolution="@resolution">
<b>@ResolutionName(resolution)</b>
@if (report.ResolutionNote is { Length: > 0 } resolutionNote)
{
<span class="text-xs opacity-80 whitespace-pre-line break-words" data-report-resolution-note>@resolutionNote</span>
}
</div>
}
2026-10-05 06:20:18 +02:00
<p class="text-sm">
<b>@Localizer["About"]</b>
@if (report.TargetAccountId is { Length: > 0 } targetId)
{
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
}
else
{
<span class="break-all">@report.TargetActorURI</span>
}
</p>
<p class="text-sm">
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
2026-10-17 04:32:57 +02:00
@if (report.RuleIds.Count > 0)
{
<div class="flex flex-col gap-1 text-sm" data-report-rules>
<b>@Localizer["Rules it names"]</b>
<ol class="list-decimal ps-5">
@foreach (var (ruleId, text) in RulesOf(report))
{
<li class="break-words" data-report-rule-id="@ruleId">
@if (text is { Length: > 0 })
{
@text
}
else
{
<span class="opacity-70">@Localizer["A rule no longer kept"]</span>
}
</li>
}
</ol>
</div>
}
2026-10-05 06:20:18 +02:00
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
}
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
{
<div class="flex flex-wrap gap-2 text-xs">
@foreach (var postId in report.PostIds)
{
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
}
@* 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)
{
<span class="break-all opacity-70">@uri</span>
}
}
</div>
}
</article>
}
</div>
2026-10-17 05:41:10 +02:00
@if (resolving is not null)
{
<ActDialog Question="@ResolveQuestion" Text="@ResolveText" ConfirmText="@ResolveConfirm" Danger="@(resolveAction != ModerationActions.None)"
Act="@("resolve-" + resolveAction)" Staff="true" AskNote="true" AskWarning="@(resolveAction != ModerationActions.None)"
WarningHint="@Localizer["Only a persona here is told; an account elsewhere is told nothing."]" OnConfirm="Resolve" OnClosed="CloseResolve" />
}
2026-10-05 06:20:18 +02:00
@code {
[Inject] AdminService AdminService { get; set; }
2026-10-17 05:41:10 +02:00
[Inject] ModerationService ModerationService { get; set; }
2026-10-05 06:20:18 +02:00
List<ViewReport> reports = [];
bool resolved;
2026-10-17 05:41:10 +02:00
// the report whose "Resolve and…" menu is open
string menuOpen;
// the report being resolved and how, while its dialog asks
ViewReport resolving;
string resolveAction;
2026-10-05 06:20:18 +02:00
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;
}
2026-10-17 04:32:57 +02:00
// each rule the report names with its text, in the report's order (an empty text: the server no longer has it)
static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) =>
report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null));
2026-10-05 06:20:18 +02:00
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
"legal" => Localizer["Illegal"],
"violation" => Localizer["Breaks the rules"],
_ => Localizer["Other"]
};
2026-10-17 05:41:10 +02:00
string ResolutionName(string action) => action switch
2026-10-05 06:20:18 +02:00
{
2026-10-17 05:41:10 +02:00
ModerationActions.Silence => Localizer["The account was silenced."],
ModerationActions.Suspend => Localizer["The account was suspended."],
ModerationActions.RemovePosts => Localizer["The reported posts were removed."],
_ => Localizer["Resolved without an act."]
};
string ResolveQuestion => resolveAction switch
{
ModerationActions.Silence => Localizer["Resolve it and silence the account?"],
ModerationActions.Suspend => Localizer["Resolve it and suspend the account?"],
ModerationActions.RemovePosts => Localizer["Resolve it and remove the reported posts?"],
_ => Localizer["Resolve the report?"]
};
string ResolveText => resolveAction switch
{
ModerationActions.Silence => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
ModerationActions.Suspend => Localizer["Nothing of it is shown here any more and it can't act; nothing new is told to other servers. It can be lifted later."],
ModerationActions.RemovePosts => Localizer["A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here."],
_ => Localizer["Nothing is done to the account or its posts."]
};
string ResolveConfirm => resolveAction switch
{
ModerationActions.Silence => Localizer["Silence"],
ModerationActions.Suspend => Localizer["Suspend"],
ModerationActions.RemovePosts => Localizer["Remove the posts"],
_ => Localizer["Resolve"]
};
void AskResolve(ViewReport report, string action)
{
menuOpen = null;
resolving = report;
resolveAction = action;
}
void CloseResolve() => (resolving, resolveAction) = (null, null);
async Task<WebResult> Resolve(ActDialog.Answer answer)
{
var report = resolving;
var action = resolveAction;
var result = await ModerationService.ResolveReport(report.ID, new ResolveReportForm
2026-10-05 06:20:18 +02:00
{
2026-10-17 05:41:10 +02:00
Action = action,
Note = answer.Note,
Warning = action == ModerationActions.None ? null : answer.Warning
});
if (result.IsInvalid)
return result;
2026-10-05 06:20:18 +02:00
2026-10-17 05:41:10 +02:00
// shown at once as PrivaPub now keeps it: resolved, with what was done and its note
report.IsResolved = true;
report.ResolvedAt = DateTime.UtcNow;
report.Resolution = action;
report.ResolutionNote = answer.Note;
2026-10-05 06:20:18 +02:00
Toast.AddResponseSuccess(Localizer["Report resolved."]);
2026-10-17 05:41:10 +02:00
return result;
2026-10-05 06:20:18 +02:00
}
}