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

+296
View File
@@ -0,0 +1,296 @@
@inherits LocalizableComponentBase
@* Accounts the staff moderate (/clientapi/moderator/accounts), for moderators and administrators: found by a name,
user@host or an actor's address (personas here, never their logins, and accounts elsewhere PrivaPub holds), and the
ones the staff silenced or suspended, most recently first, 50 at a time. Each is silenced, suspended or given back
with a note for the staff and, for a persona here, a warning it is told; an account elsewhere is told nothing. A
server silenced or suspended by a domain block holds its accounts back without their own silence, and says so. *@
<div class="flex flex-col gap-3 w-full" data-accounts>
@* its form attribute names no form, so Enter in the search submits nothing *@
<DInput TValue="string" class="w-full" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true" autocomplete="off"
form="accounts-search" placeholder="@Localizer["A name, user@host or an address"]" aria-label="@Localizer["Search accounts"]" data-accounts-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
@if (query is { Length: > 0 })
{
<div class="flex flex-col gap-2" data-accounts-results>
@if (isSearching)
{
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (found.Count == 0)
{
<p class="text-sm opacity-70" data-accounts-results-empty>@Localizer["No account found."]</p>
}
@foreach (var account in found)
{
@Row(account)
}
</div>
}
<b class="text-sm mt-2">@Localizer["Silenced and suspended"]</b>
@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 (failed)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The accounts could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload">@Localizer["Retry"]</DButton>
</div>
}
else if (moderated.Count == 0)
{
<p class="text-sm opacity-70" data-accounts-moderated-empty>@Localizer["No account is silenced or suspended."]</p>
}
<div class="flex flex-col gap-2" data-accounts-moderated>
@foreach (var account in moderated)
{
@Row(account)
}
</div>
@if (next is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-accounts-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
@if (acting is not null)
{
<ActDialog Question="@Question" Text="@ActText" ConfirmText="@ConfirmText" Danger="@(action is ModerationActions.Silence or ModerationActions.Suspend)"
Act="@action" Staff="true" AskNote="true" AskWarning="@(acting.IsLocal && action is ModerationActions.Silence or ModerationActions.Suspend)"
OnConfirm="Moderate" OnClosed="CloseAct" />
}
@code {
const int PageSize = 50;
const int SearchSize = 20;
[Inject] ModerationService ModerationService { get; set; }
string query;
List<ViewModeratedAccount> found = [];
List<ViewModeratedAccount> moderated = [];
// the offset of the moderated list's next page; null on the last
int? next;
bool failed;
bool isSearching;
bool isLoadingMore;
int searching;
// the account acted on and how, while the dialog asks
ViewModeratedAccount acting;
string action;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
var page = await ModerationService.ModeratedAccounts(0, PageSize);
failed = page is null;
moderated = page?.Items ?? [];
next = page?.Next;
}
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
async Task More()
{
isLoadingMore = true;
var page = await ModerationService.ModeratedAccounts(next ?? 0, PageSize);
isLoadingMore = false;
if (page is null)
{
Toast.AddResponseError(Localizer["The accounts could not be read."]);
return;
}
var known = moderated.Select(a => a.Id).ToHashSet();
moderated.AddRange(page.Items.Where(a => known.Add(a.Id)));
next = page.Next;
}
async Task Search(string text)
{
query = text;
var lookup = ++searching;
if (string.IsNullOrWhiteSpace(text))
{
found = [];
isSearching = false;
return;
}
isSearching = true;
await Task.Delay(300);
if (lookup != searching)
return;
var page = await ModerationService.SearchAccounts(text.Trim(), SearchSize);
if (lookup != searching)
return;
found = page?.Items ?? [];
isSearching = false;
}
static string State(ViewModeratedAccount account) =>
account.SuspendedAt is not null ? "suspended" : account.SilencedAt is not null ? "silenced" : "normal";
RenderFragment Row(ViewModeratedAccount account) => @<article class="flex flex-col gap-2 p-2 md:p-3 rounded-lg neo-raised neo-2xs"
data-moderated-account="@account.Handle" data-account-state="@State(account)" data-account-id="@account.Id">
<div class="flex flex-wrap items-center gap-2 min-w-0">
<span class="flex flex-col min-w-0 flex-1">
<a class="font-semibold truncate link link-hover pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(account.Id)}")"
title="@account.Url">@(account.DisplayName is { Length: > 0 } displayName ? displayName : account.UserName)</a>
<span class="text-xs opacity-70 truncate">@("@" + account.Handle)</span>
</span>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(account.IsLocal ? Localizer["here"] : account.Domain)</DBadge>
@if (account.SilencedAt is { } silencedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@silencedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"
data-account-silenced>@Localizer["Silenced"]</DBadge>
}
@if (account.SuspendedAt is { } suspendedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@suspendedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"
data-account-suspended>@Localizer["Suspended"]</DBadge>
}
@if (account.DomainSilenced)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" Color="DColor.Warning" data-account-domain-silenced>@Localizer["its server is silenced"]</DBadge>
}
@if (account.DomainSuspended)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" Color="DColor.Error" data-account-domain-suspended>@Localizer["its server is suspended"]</DBadge>
}
</div>
@if (account.Note is { Length: > 0 } staffNote)
{
<p class="text-xs opacity-80 whitespace-pre-line break-words" data-account-note><b>@Localizer["Staff note"]</b> @staffNote</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (account.SilencedAt is null)
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Silence)" data-account-silence>
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Unsilence)" data-account-unsilence>
<i class="ion-md-volume-high" aria-hidden="true"></i><span>@Localizer["Unsilence"]</span>
</DButton>
}
@if (account.SuspendedAt is null)
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Suspend)" data-account-suspend>
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="() => Ask(account, ModerationActions.Unsuspend)" data-account-unsuspend>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unsuspend"]</span>
</DButton>
}
</div>
</article>;
string Name => acting?.DisplayName is { Length: > 0 } displayName ? displayName : "@" + acting?.Handle;
string Question => action switch
{
ModerationActions.Silence => Localizer["Silence {0}?", Name],
ModerationActions.Unsilence => Localizer["Unsilence {0}?", Name],
ModerationActions.Suspend => Localizer["Suspend {0}?", Name],
_ => Localizer["Unsuspend {0}?", Name]
};
string ActText => (action, acting?.IsLocal == true) switch
{
(ModerationActions.Silence, _) => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
(ModerationActions.Suspend, true) => Localizer["The persona can't act or be seen here any more, and its sessions end; nothing new is told to other servers. It reads the warning once the suspension is lifted."],
(ModerationActions.Suspend, false) => Localizer["Nothing of it is shown here any more, what it sends is dropped and nothing is delivered to it. Its server is told nothing."],
(ModerationActions.Unsilence, _) => Localizer["Its posts are listed again."],
_ => Localizer["It is given back as it was, but the sessions a suspension ended."]
};
string ConfirmText => action switch
{
ModerationActions.Silence => Localizer["Silence"],
ModerationActions.Unsilence => Localizer["Unsilence"],
ModerationActions.Suspend => Localizer["Suspend"],
_ => Localizer["Unsuspend"]
};
void Ask(ViewModeratedAccount account, string act) => (acting, action) = (account, act);
void CloseAct() => (acting, action) = (null, null);
async Task<WebResult> Moderate(ActDialog.Answer answer)
{
var account = acting;
var act = action;
var result = await ModerationService.Moderate(new ModerateAccountForm
{
AccountId = account.Id,
Action = act,
Note = answer.Note,
Warning = account.IsLocal && act is ModerationActions.Silence or ModerationActions.Suspend ? answer.Warning : null
});
if (result.IsInvalid)
return result;
Changed(result.Data as ViewModeratedAccount ?? account);
Toast.AddResponseSuccess(act switch
{
ModerationActions.Silence => Localizer["{0} is silenced.", "@" + account.Handle],
ModerationActions.Unsilence => Localizer["{0} is no longer silenced.", "@" + account.Handle],
ModerationActions.Suspend => Localizer["{0} is suspended.", "@" + account.Handle],
_ => Localizer["{0} is no longer suspended.", "@" + account.Handle]
});
return result;
}
// the account as PrivaPub has it now, in both lists: one no longer held back leaves the moderated list, one newly held
// back joins it at the top
void Changed(ViewModeratedAccount account)
{
found = [.. found.Select(a => a.Id == account.Id ? account : a)];
var held = account.SilencedAt is not null || account.SuspendedAt is not null;
var listed = moderated.Any(a => a.Id == account.Id);
moderated = held switch
{
true when listed => [.. moderated.Select(a => a.Id == account.Id ? account : a)],
true => [account, .. moderated],
false => [.. moderated.Where(a => a.Id != account.Id)]
};
}
}