R6 moderation in decePub: users and personas, accounts, report actions, deletions, suspended personas

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 05:41:10 +02:00
1 parent 4c88980374
commit c572471b88
30 files changed
+2604 -67

No files matched your search

+116 -16
View File
@@ -2,7 +2,9 @@
@* 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); resolved here. *@
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" />
@@ -33,16 +35,54 @@
<span class="flex-1"></span>
@if (!report.IsResolved)
{
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
data-report-resolve>
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
</DButton>
<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)
@@ -104,12 +144,24 @@
}
</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;
HashSet<string> busy = [];
// 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()
{
@@ -147,18 +199,66 @@
_ => Localizer["Other"]
};
async Task Resolve(ViewReport report)
string ResolutionName(string action) => action switch
{
busy.Add(report.ID);
var result = await AdminService.Resolve(report.ID);
busy.Remove(report.ID);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
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."]
};
reports.Remove(report);
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;
}
}