Administration's Users & personas (administrators): logins searched and opened into their personas and groups; roles, bans, two-factor reset and deletion of a login; silence, suspension and deletion of a persona; a group deleted. Accounts (moderators): local personas and remote accounts searched or listed as moderated, silenced and suspended with a staff note and a warning. Reports resolve with an action. ActDialog asks for the note, the warning or the password and is the act's confirmation. A login deletes its own persona and the groups it owns with its password. Suspended personas can't be chosen; one refused a token mid-session moves the session to another, or to none, once. Warnings show in notifications, limited accounts on their profile, and moderators remove a post from its card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
265 lines
10 KiB
Plaintext
265 lines
10 KiB
Plaintext
@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, a violation with the server's rules it names (their texts as PrivaPub
|
|
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. *@
|
|
<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)
|
|
{
|
|
<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>
|
|
}
|
|
else if (report.ResolvedAt is { } resolvedAt)
|
|
{
|
|
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
|
|
}
|
|
</div>
|
|
@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>
|
|
}
|
|
<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>
|
|
@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>
|
|
}
|
|
@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>
|
|
|
|
@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" />
|
|
}
|
|
|
|
@code {
|
|
[Inject] AdminService AdminService { get; set; }
|
|
[Inject] ModerationService ModerationService { get; set; }
|
|
|
|
List<ViewReport> reports = [];
|
|
bool resolved;
|
|
// the report whose "Resolve and…" menu is open
|
|
string menuOpen;
|
|
// the report being resolved and how, while its dialog asks
|
|
ViewReport resolving;
|
|
string resolveAction;
|
|
|
|
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;
|
|
}
|
|
|
|
// 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));
|
|
|
|
string CategoryName(string category) => category switch
|
|
{
|
|
"spam" => Localizer["Spam"],
|
|
"legal" => Localizer["Illegal"],
|
|
"violation" => Localizer["Breaks the rules"],
|
|
_ => Localizer["Other"]
|
|
};
|
|
|
|
string ResolutionName(string action) => action switch
|
|
{
|
|
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
|
|
{
|
|
Action = action,
|
|
Note = answer.Note,
|
|
Warning = action == ModerationActions.None ? null : answer.Warning
|
|
});
|
|
if (result.IsInvalid)
|
|
return result;
|
|
|
|
// 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;
|
|
Toast.AddResponseSuccess(Localizer["Report resolved."]);
|
|
return result;
|
|
}
|
|
}
|