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:
1 parent
4c88980374
commit
c572471b88
30 files changed
+2604
-67
No files matched your search
@@ -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)]
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user