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

+113
View File
@@ -0,0 +1,113 @@
@inherits LocalizableComponentBase
@* Asks before an act that takes words or a password: what it does, then the fields it needs, then a confirm button. The
staff's acts (Staff) take a note only the staff read (data-moderate-note), a warning the persona is told
(data-moderate-warning) and, to delete, the administrator's own password (data-admin-password); the dialog is
data-moderate-confirm="{act}". A login's own deletions take its password (data-own-password) in data-own-confirm="{act}".
OnConfirm does the act: a failure stays in the dialog (a wrong password is typed again), a success closes it. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-moderate-confirm="@(Staff ? Act : null)" data-own-confirm="@(Staff ? null : Act)">
<Heading>@Question</Heading>
<ChildContent>
<div class="flex flex-col gap-3 mt-2 text-sm">
@if (Text is { Length: > 0 })
{
<p class="whitespace-pre-line">@Text</p>
}
@if (AskNote)
{
<label class="flex flex-col gap-1">
<span class="font-semibold">@Localizer["A note for the staff (optional)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="note" Immediate="true" rows="2" autocomplete="off" maxlength="1000"
aria-label="@Localizer["A note for the staff (optional)"]" data-moderate-note />
<span class="text-xs opacity-70">@Localizer["Only the staff read it, in the moderation log."]</span>
</label>
}
@if (AskWarning)
{
<label class="flex flex-col gap-1">
<span class="font-semibold">@Localizer["What the persona is told (optional)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="warning" Immediate="true" rows="3" autocomplete="off" maxlength="1000"
aria-label="@Localizer["What the persona is told (optional)"]" data-moderate-warning />
<span class="text-xs opacity-70">@(WarningHint ?? Localizer["It reaches the persona as a warning from this server's staff."])</span>
</label>
}
@if (AskPassword)
{
<DInput TValue="string" Type="password" class="w-full" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
aria-label="@(Staff ? Localizer["Your password"] : Localizer["Current password"])"
data-admin-password="@(Staff ? "" : null)" data-own-password="@(Staff ? null : "")" />
}
<ErrorViewer AdditionalError="@error" />
</div>
</ChildContent>
<Actions>
<DButton class="max-md:flex-1" OnClick="Cancel" data-act-cancel>@Localizer["Cancel"]</DButton>
<DButton class="max-md:flex-1" Color="@(Danger ? DColor.Error : DColor.Primary)"
Disabled="@(IsDefaultDisabled || isActing || (AskPassword && string.IsNullOrEmpty(password)))" OnClick="Confirm" data-act-submit>
@if (isActing)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@ConfirmText</span>
</DButton>
</Actions>
</DModal>
@code {
/// <summary>The question the dialog asks, its heading.</summary>
[Parameter, EditorRequired] public string Question { get; set; }
/// <summary>What the act does, in words.</summary>
[Parameter] public string Text { get; set; }
[Parameter, EditorRequired] public string ConfirmText { get; set; }
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
[Parameter] public bool Danger { get; set; }
/// <summary>The act's name in the dialog's data attribute (silence, persona-delete, ...).</summary>
[Parameter, EditorRequired] public string Act { get; set; }
/// <summary>A staff act (data-moderate-confirm, the administrator's password) or the login's own (data-own-confirm).</summary>
[Parameter] public bool Staff { get; set; }
[Parameter] public bool AskNote { get; set; }
[Parameter] public bool AskWarning { get; set; }
/// <summary>Who reads the warning, when it isn't simply the persona (an account elsewhere is told nothing).</summary>
[Parameter] public string WarningHint { get; set; }
[Parameter] public bool AskPassword { get; set; }
/// <summary>Does the act with what was typed; an invalid result stays in the dialog, a valid one closes it.</summary>
[Parameter, EditorRequired] public Func<Answer, Task<WebResult>> OnConfirm { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>What was typed: the note and the warning trimmed, null when empty; the password as typed.</summary>
public sealed record Answer(string Note, string Warning, string Password);
string note;
string warning;
string password;
string error;
bool isActing;
protected override void OnInitialized() => IsLoading = false;
async Task Confirm()
{
error = default;
isActing = true;
var result = await OnConfirm(new Answer(Trimmed(note), Trimmed(warning), password));
isActing = false;
if (result.IsInvalid)
{
// a cancelled request says nothing; anything else is said here, where it can be fixed
if (result.StatusCode is not 410)
error = result.ErrorMessage;
password = default;
return;
}
await OnClosed.InvokeAsync();
}
static string Trimmed(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
Task Cancel() => OnClosed.InvokeAsync();
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+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)]
};
}
}
+10
View File
@@ -156,6 +156,16 @@
"root.delete" => Localizer["Login deleted"],
"root.reset_totp" => Localizer["Two-factor sign-in reset"],
"persona.move" => Localizer["Persona moved to another login"],
"root.role" => Localizer["Login's role changed"],
"root.ban" => Localizer["Login banned"],
"root.unban" => Localizer["Login's ban lifted"],
"persona.silence" or "account.silence" => Localizer["Account silenced"],
"persona.unsilence" or "account.unsilence" => Localizer["Account no longer silenced"],
"persona.suspend" or "account.suspend" => Localizer["Account suspended"],
"persona.unsuspend" or "account.unsuspend" => Localizer["Account no longer suspended"],
"persona.delete" => Localizer["Persona deleted"],
"group.delete" => Localizer["Group deleted"],
"post.remove" => Localizer["Post removed"],
_ => action
};
}
+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;
}
}
+502
View File
@@ -0,0 +1,502 @@
@inherits LocalizableComponentBase
@* The logins, for administrators (/clientapi/admin/roots): the one place a login is shown with its personas and the
groups they own. Newest first, 50 at a time, found by a login's name or a persona's. A login's role (user, moderator,
administrator), its ban, its two-factor sign-in and its deletion are asked first (ConfirmService); never one's own,
and never the last administrator's role (PrivaPub answers 409). A persona is silenced, suspended or given back with a
note for the staff and a warning it is told; a persona or a group is deleted with the administrator's own password.
Every act is kept in the staff's log. *@
<div class="flex flex-col gap-3 w-full" data-users>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
@* its form attribute names no form, so Enter in the search submits nothing *@
<DInput TValue="string" class="flex-1 min-w-48" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true" autocomplete="off"
form="users-search" placeholder="@Localizer["A login's name, or one of its personas'"]" aria-label="@Localizer["Search logins"]" data-users-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-users-reload>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
</DButton>
</div>
@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 logins could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload">@Localizer["Retry"]</DButton>
</div>
}
else if (roots.Count == 0)
{
<p class="text-sm opacity-70" data-users-empty>@Localizer["No login found."]</p>
}
<div class="flex flex-col gap-2">
@foreach (var root in roots)
{
<article @key="root.Id" class="flex flex-col gap-2 p-2 md:p-3 rounded-lg neo-raised neo-2xs" data-user="@root.UserName" data-user-role="@root.Role"
data-user-banned="@(root.IsBanned ? "true" : "false")">
<div class="flex flex-wrap items-center gap-2 min-w-0">
<i class="ion-md-key opacity-60" aria-hidden="true"></i>
<b class="truncate min-w-0">@root.UserName</b>
@if (root.IsYou)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary" data-user-you>@Localizer["you"]</DBadge>
}
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@RoleColor(root.Role)">@RoleName(root.Role)</DBadge>
@if (root.IsBanned)
{
<DBadge Size="DSize.Sm" Color="DColor.Error" data-user-banned-badge>@Localizer["banned"]</DBadge>
}
@if (root.HasTotp)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-user-totp>@Localizer["two-factor"]</DBadge>
}
@if (!root.HasEmail)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" data-user-email="none">@Localizer["no email address"]</DBadge>
}
else if (root.IsEmailValidated)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-user-email="verified">@Localizer["email verified"]</DBadge>
}
else
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-user-email="unverified">@Localizer["email not verified"]</DBadge>
}
<span class="flex-1"></span>
<span class="text-xs opacity-70" title="@root.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@root.CreatedAt.GetPassedTime(Localizer)</span>
</div>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Active="@opened.Contains(root.Id)" OnClick="() => Toggle(root)" aria-expanded="@(opened.Contains(root.Id) ? "true" : "false")"
data-user-open>
<i class="@(opened.Contains(root.Id) ? "ion-md-arrow-dropup" : "ion-md-arrow-dropdown")" aria-hidden="true"></i>
<span>@Localizer["Personas: {0}", root.Personas.Count]</span>
</DButton>
<span class="flex-1"></span>
@if (!root.IsYou)
{
<DSelect @key="@($"{root.Id}:{roleRenders}")" TValue="string" Size="DSize.Sm" Value="@root.Role" ValueChanged="role => ChangeRole(root, role)"
ValueExpression="() => root.Role" disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" aria-label="@Localizer["Role"]"
data-user-role-select>
<option value="user">@RoleName("user")</option>
<option value="moderator">@RoleName("moderator")</option>
<option value="admin">@RoleName("admin")</option>
</DSelect>
@if (root.IsBanned)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => Unban(root)" data-user-unban>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unban"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => Ban(root)" data-user-ban>
<i class="ion-md-hand text-warning" aria-hidden="true"></i><span>@Localizer["Ban"]</span>
</DButton>
}
@if (root.HasTotp)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => ResetTotp(root)" data-user-totp-reset>
<i class="ion-md-unlock" aria-hidden="true"></i><span>@Localizer["Reset two-factor"]</span>
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(root.Id))" OnClick="() => DeleteRoot(root)" data-user-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete the login"]</span>
</DButton>
}
</div>
@if (opened.Contains(root.Id))
{
<div class="flex flex-col gap-2 ps-2 md:ps-4 border-s-2 border-base-300" data-user-personas>
@if (root.Personas.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This login has no persona."]</p>
}
@foreach (var persona in root.Personas)
{
<div class="flex flex-col gap-2 p-2 rounded-lg neo-inset neo-2xs" data-persona="@persona.UserName" data-persona-state="@State(persona)">
<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(persona.Id)}")">
@(persona.DisplayName is { Length: > 0 } displayName ? displayName : persona.UserName)
</a>
<span class="text-xs opacity-70 truncate">@("@" + persona.Handle)</span>
</span>
@if (persona.SilencedAt is { } silencedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@silencedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Silenced"]
</DBadge>
}
@if (persona.SuspendedAt is { } suspendedAt)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@suspendedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Suspended"]
</DBadge>
}
</div>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (persona.SilencedAt is null)
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Silence)" data-persona-silence>
<i class="ion-md-volume-off text-warning" aria-hidden="true"></i><span>@Localizer["Silence"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Unsilence)" data-persona-unsilence>
<i class="ion-md-volume-high" aria-hidden="true"></i><span>@Localizer["Unsilence"]</span>
</DButton>
}
@if (persona.SuspendedAt is null)
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Suspend)" data-persona-suspend>
<i class="ion-md-remove-circle text-error" aria-hidden="true"></i><span>@Localizer["Suspend"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, ModerationActions.Unsuspend)" data-persona-unsuspend>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Unsuspend"]</span>
</DButton>
}
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, null, PersonaDelete)" data-persona-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</div>
@foreach (var group in persona.Groups)
{
<div class="flex flex-wrap items-center gap-2 min-w-0 text-sm" data-group="@group.UserName">
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") opacity-70" aria-hidden="true"></i>
<span class="flex-1 min-w-0 truncate">
<b>@(group.Name ?? group.UserName)</b> <span class="text-xs opacity-70">@("@" + group.UserName)</span>
</span>
<span class="text-xs tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person" aria-hidden="true"></i> @group.MembersCount</span>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="() => Ask(root, persona, group, GroupDelete)" title="@Localizer["Delete the group"]"
aria-label="@Localizer["Delete the group"]" data-group-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span class="hidden sm:inline">@Localizer["Delete the group"]</span>
</DButton>
</div>
}
</div>
}
</div>
}
</article>
}
</div>
@if (next is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-users-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
@if (act is not null)
{
<ActDialog Question="@Question" Text="@ActText" ConfirmText="@ConfirmText" Danger="@(act is not (ModerationActions.Unsilence or ModerationActions.Unsuspend))"
Act="@act" Staff="true" AskNote="true" AskWarning="@(act is ModerationActions.Silence or ModerationActions.Suspend)"
AskPassword="@(act is PersonaDelete or GroupDelete)" OnConfirm="Act" OnClosed="CloseAct" />
}
@code {
const int PageSize = 50;
const string PersonaDelete = "persona-delete";
const string GroupDelete = "group-delete";
[Inject] ModerationService ModerationService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
string query;
List<ViewAdminRoot> roots = [];
// the next page's `before`; null on the last
string next;
bool failed;
bool isLoadingMore;
int searching;
// the logins whose personas are shown
HashSet<string> opened = [];
// the logins being changed
HashSet<string> busy = [];
// rebuilds the role selects, so one whose change was cancelled shows the role again
int roleRenders;
// the act the dialog asks about (a persona's silence or suspension, a persona's or a group's deletion) and its targets
string act;
ViewAdminRoot actingRoot;
ViewAdminPersona actingPersona;
ViewAdminGroup actingGroup;
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.Roots(query?.Trim(), null, PageSize);
failed = page is null;
roots = page?.Items ?? [];
next = page?.Next;
}
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
async Task Search(string text)
{
query = text;
var lookup = ++searching;
await Task.Delay(300);
if (lookup != searching)
return;
IsLoading = true;
await LoadData();
if (lookup != searching)
return;
IsLoading = false;
}
async Task More()
{
isLoadingMore = true;
var page = await ModerationService.Roots(query?.Trim(), next, PageSize);
isLoadingMore = false;
if (page is null)
{
Toast.AddResponseError(Localizer["The logins could not be read."]);
return;
}
var known = roots.Select(r => r.Id).ToHashSet();
roots.AddRange(page.Items.Where(r => known.Add(r.Id)));
next = page.Next;
}
void Toggle(ViewAdminRoot root)
{
if (!opened.Remove(root.Id))
opened.Add(root.Id);
}
static string State(ViewAdminPersona persona) =>
persona.SuspendedAt is not null ? "suspended" : persona.SilencedAt is not null ? "silenced" : "normal";
string RoleName(string role) => role switch
{
"admin" => Localizer["administrator"],
"moderator" => Localizer["moderator"],
_ => Localizer["user"]
};
static DColor RoleColor(string role) => role switch
{
"admin" => DColor.Secondary,
"moderator" => DColor.Info,
_ => DColor.Neutral
};
// the login as PrivaPub has it now, in its place; one gone (deleted) leaves the list
async Task Refresh(ViewAdminRoot root)
{
var now = await ModerationService.Root(root.Id);
roots = now is null ? [.. roots.Where(r => r.Id != root.Id)] : [.. roots.Select(r => r.Id == root.Id ? now : r)];
}
async Task ChangeRole(ViewAdminRoot root, string role)
{
var (question, text) = role switch
{
"admin" => (Localizer["Make {0} an administrator?", root.UserName],
Localizer["Administrators do everything moderators do, and change logins, the server's settings, its relays and its backups."]),
"moderator" => (Localizer["Make {0} a moderator?", root.UserName],
Localizer["Moderators handle reports, silence and suspend accounts and remove posts; never logins or the server's settings."]),
_ => (Localizer["Make {0} a user?", root.UserName], Localizer["It keeps its personas, and loses what the staff do."])
};
if (!await Confirm.Ask(question, text, Localizer["Change the role"], true))
{
roleRenders++;
return;
}
busy.Add(root.Id);
var result = await ModerationService.SetRole(root.Id, role);
busy.Remove(root.Id);
if (result.IsInvalid)
{
roleRenders++;
Toast.AddResponseError(result);
return;
}
var changed = result.Data as ViewAdminRoot;
if (changed is not null)
roots = [.. roots.Select(r => r.Id == root.Id ? changed : r)];
Toast.AddResponseSuccess(Localizer["{0} is now {1}.", root.UserName, RoleName(changed?.Role ?? role)]);
}
async Task Ban(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Ban {0}?", root.UserName],
Localizer["Its sessions end and its personas are held back as suspended ones, without a word written on any of them. It can be lifted."],
Localizer["Ban"], true))
return;
await Run(root, ModerationService.Ban, Localizer["{0} is banned.", root.UserName]);
}
async Task Unban(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Lift {0}'s ban?", root.UserName], Localizer["Its personas are given back as they were."], Localizer["Unban"], false))
return;
await Run(root, ModerationService.Unban, Localizer["{0} is no longer banned.", root.UserName]);
}
async Task ResetTotp(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Turn off {0}'s two-factor sign-in?", root.UserName],
Localizer["For a login that lost its authenticator app and its recovery codes: its password and its sessions stay."],
Localizer["Reset two-factor"], true))
return;
await Run(root, ModerationService.ResetTotp, Localizer["{0} signs in with its password alone now.", root.UserName]);
}
async Task DeleteRoot(ViewAdminRoot root)
{
if (!await Confirm.Ask(Localizer["Delete {0}?", root.UserName],
Localizer["Every persona of it goes with it, and everything public they had, here and on the servers that hold it. Its names stay taken. It cannot be undone."],
Localizer["Delete for good"], true))
return;
busy.Add(root.Id);
var result = await ModerationService.DeleteRoot(root.Id);
busy.Remove(root.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
roots = [.. roots.Where(r => r.Id != root.Id)];
Toast.AddResponseSuccess(Localizer["{0} is deleted.", root.UserName]);
}
async Task Run(ViewAdminRoot root, Func<string, CancellationToken, Task<WebResult>> change, string done)
{
busy.Add(root.Id);
var result = await change(root.Id, default);
busy.Remove(root.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await Refresh(root);
Toast.AddResponseSuccess(done);
}
string Named => actingGroup is { } group ? "@" + group.UserName
: actingPersona?.DisplayName is { Length: > 0 } displayName ? displayName
: "@" + actingPersona?.Handle;
string Question => act switch
{
ModerationActions.Silence => Localizer["Silence {0}?", Named],
ModerationActions.Unsilence => Localizer["Unsilence {0}?", Named],
ModerationActions.Suspend => Localizer["Suspend {0}?", Named],
ModerationActions.Unsuspend => Localizer["Unsuspend {0}?", Named],
PersonaDelete => Localizer["Delete {0}?", Named],
_ => Localizer["Delete the group {0}?", Named]
};
string ActText => act switch
{
ModerationActions.Silence => Localizer["Its posts stay out of public timelines, hashtags, trends and search; its followers still read them."],
ModerationActions.Suspend => 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.Unsilence => Localizer["Its posts are listed again."],
ModerationActions.Unsuspend => Localizer["It is given back as it was, but the sessions a suspension ended."],
PersonaDelete when actingPersona?.Groups.Count > 0 => Localizer["Everything public it had goes, here and on the servers that hold it, and the {0} groups it owns with it. Its name stays taken. It cannot be undone.",
actingPersona.Groups.Count],
PersonaDelete => Localizer["Everything public it had goes, here and on the servers that hold it, with the groups it owns. Its name stays taken. It cannot be undone."],
_ => Localizer["Its members and followers are told, and its name stays taken. It cannot be undone."]
};
string ConfirmText => act switch
{
ModerationActions.Silence => Localizer["Silence"],
ModerationActions.Unsilence => Localizer["Unsilence"],
ModerationActions.Suspend => Localizer["Suspend"],
ModerationActions.Unsuspend => Localizer["Unsuspend"],
_ => Localizer["Delete for good"]
};
void Ask(ViewAdminRoot root, ViewAdminPersona persona, ViewAdminGroup group, string what) =>
(actingRoot, actingPersona, actingGroup, act) = (root, persona, group, what);
void CloseAct() => (actingRoot, actingPersona, actingGroup, act) = (null, null, null, null);
async Task<WebResult> Act(ActDialog.Answer answer)
{
var (root, persona, group, what) = (actingRoot, actingPersona, actingGroup, act);
var result = what switch
{
PersonaDelete => await ModerationService.DeletePersona(persona.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }),
GroupDelete => await ModerationService.DeleteGroup(group.Id, new AdminPasswordForm { Password = answer.Password, Note = answer.Note }),
_ => await ModerationService.Moderate(new ModerateAccountForm
{
AccountId = persona.Id,
Action = what,
Note = answer.Note,
Warning = what is ModerationActions.Silence or ModerationActions.Suspend ? answer.Warning : null
})
};
if (result.IsInvalid)
return result;
await Refresh(root);
// one of the administrator's own personas: the session follows it
if (root.IsYou && what == PersonaDelete)
await CascadingState.PersonaDeleted(persona.Id);
else if (root.IsYou)
await CascadingState.ReloadPersonas();
Toast.AddResponseSuccess(what switch
{
ModerationActions.Silence => Localizer["{0} is silenced.", Named],
ModerationActions.Unsilence => Localizer["{0} is no longer silenced.", Named],
ModerationActions.Suspend => Localizer["{0} is suspended.", Named],
ModerationActions.Unsuspend => Localizer["{0} is no longer suspended.", Named],
_ => Localizer["{0} is deleted.", Named]
});
return result;
}
}
+72 -3
View File
@@ -10,8 +10,10 @@
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a
warning from the server's staff (moderation_warning) shows what they did and what they wrote. A moderator removes
another's post from its menu, with a note and a warning, asked first. Nerd stats are on the time, the author and its
server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -60,6 +62,20 @@
</DButton>
}
</p>
@if (notification.ModerationWarning is { } warning)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs text-sm" data-notification-warning="@warning.Action">
<b>@WarningActionText(warning.Action)</b>
@if (warning.Text is { Length: > 0 } warningText)
{
<p class="whitespace-pre-line break-words" data-notification-warning-text>@warningText</p>
}
@if (warning.StatusIds.Count > 0)
{
<p class="text-xs opacity-70">@Localizer["It is about {0} of your posts.", warning.StatusIds.Count]</p>
}
</div>
}
}
else if (Post.BoostedBy is { } booster)
{
@@ -434,6 +450,14 @@
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span>
</DButton>
@if (CascadingState.IsModerator && !Post.Located)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="@(() => isModeratingPost = true)" data-moderate-remove-post>
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Remove post (moderation)"]</span>
</DButton>
}
}
else
{
@@ -508,6 +532,14 @@
{
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
}
@if (isModeratingPost)
{
<ActDialog Question="@Localizer["Remove this post as a moderator?"]"
Text="@Localizer["A post written here is deleted as its author would delete it; a copy of one from elsewhere is hidden here."]"
ConfirmText="@Localizer["Remove the post"]" Danger="true" Act="remove-post" Staff="true" AskNote="true" AskWarning="true"
WarningHint="@Localizer["Only a persona here is told; an account elsewhere is told nothing."]" OnConfirm="RemoveAsModerator"
OnClosed="ModerationClosed" />
}
@if (showInteractedBy is { } boosts)
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
@@ -544,7 +576,7 @@
}
}
}
else if (Post.Notification?.Account is { } follower)
else if (Post.Notification is { Type: not "moderation_warning", Account: { } follower })
{
<p class="text-sm">
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
@@ -577,6 +609,7 @@
[Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; }
[Inject] ModerationService ModerationService { get; set; }
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
@@ -610,6 +643,9 @@
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
bool isModeratingPost;
// the staff removed the post: the page takes it out once the dialog closes
bool removedByModerator;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = [];
@@ -1123,6 +1159,26 @@
await OnDeleted.InvokeAsync(Post);
}
// a moderator takes another's post down (/clientapi/moderator/posts/remove): deleted when it was written here, hidden
// here when it is a copy from elsewhere
async Task<WebResult> RemoveAsModerator(ActDialog.Answer answer)
{
var result = await ModerationService.RemovePost(new RemovePostForm { PostId = Status.Id, Note = answer.Note, Warning = answer.Warning });
if (result.IsInvalid)
return result;
removedByModerator = true;
Toast.AddResponseSuccess(Localizer["Post removed."]);
return result;
}
async Task ModerationClosed()
{
isModeratingPost = false;
if (removedByModerator)
await OnDeleted.InvokeAsync(Post);
}
// someone's post on the persona's wall: its owner deletes it there (PrivaPub tells the wall's readers, and Smithereen
// deletes the post)
bool IsOnOwnWall(Status status) => !IsOwn && status.Privapub?.Wall?.Id is { } owner && owner == CascadingState.PersonaId;
@@ -1293,9 +1349,21 @@
"poll" => "ion-md-podium",
"update" or "quoted_update" => "ion-md-create",
"quote" => "ion-md-quote",
"moderation_warning" => "ion-md-warning text-warning",
_ => "ion-md-notifications"
};
// what a warning from the server's staff says they did
string WarningActionText(string action) => action switch
{
"silence" => Localizer["This persona is silenced: its posts stay out of public timelines, hashtags, trends and search."],
"suspend" => Localizer["This persona was suspended."],
"delete_statuses" => Localizer["A post of yours was removed."],
"mark_statuses_as_sensitive" or "sensitive" => Localizer["Your media were marked as sensitive."],
"disable" => Localizer["This login was disabled."],
_ => Localizer["A warning from this server's staff."]
};
string NotificationText(Notification notification)
{
var name = notification.Account?.Name;
@@ -1320,6 +1388,7 @@
"update" => Localizer["{0} edited a post", name],
"quote" => Localizer["{0} quoted your post", name],
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
"moderation_warning" => Localizer["This server's staff warned you"],
_ => Localizer["{0}: {1}", name, notification.Type]
};
}
+58 -4
View File
@@ -1,26 +1,58 @@
@inherits LocalizableComponentBase
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
to one another: no server, PrivaPub's included, shows that they share a root. *@
@* The root's personas (/clientapi/avatar/private): each one used in turn, a new one made, and one deleted with the
login's password, asked first (everything public it had goes, with the groups it owns). One the server's staff
suspended or silenced says so; a suspended one can't be used until the suspension is lifted, but may be deleted.
Personas are never linked to one another: no server, PrivaPub's included, shows that they share a root. *@
<div class="flex flex-col gap-4 w-full" data-personas>
<div class="flex flex-col gap-2">
@if (CascadingState.Personas.Count == 0)
{
<p class="text-sm" data-personas-none>@Localizer["This login has no persona to act as yet: make one below."]</p>
}
@foreach (var persona in CascadingState.Personas)
{
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 min-w-0" data-persona="@persona.UserName" data-persona-state="@StateOf(persona)">
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
@if (persona.AccountState is ViewAvatarState.Suspended)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" title="@Localizer["Suspended by this server's staff: it can't act until that is lifted."]">
@Localizer["Suspended"]
</DBadge>
}
else if (persona.AccountState is ViewAvatarState.Silenced)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" title="@Localizer["Silenced by this server's staff: its posts stay out of public timelines, trends and search."]">
@Localizer["Silenced"]
</DBadge>
}
@if (persona.Id == CascadingState.PersonaId)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null || !CascadingState.CanUse(persona))" OnClick="() => Use(persona)">
@Localizer["Use"]
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => deleting = persona"
title="@Localizer["Delete this persona"]" aria-label="@Localizer["Delete this persona"]" data-persona-delete-own>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span class="hidden sm:inline">@Localizer["Delete"]</span>
</DButton>
</div>
}
</div>
@if (deleting is not null)
{
<ActDialog Question="@Localizer["Delete {0}?", deleting.Name ?? "@" + deleting.UserName]"
Text="@Localizer["Everything public it had goes, here and on the servers that hold it, with the groups it owns. Its name stays taken. It cannot be undone."]"
ConfirmText="@Localizer["Delete for good"]" Danger="true" Act="persona-delete" AskPassword="true" OnConfirm="Delete"
OnClosed="() => deleting = null" />
}
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["A new persona"]</b>
@@ -47,6 +79,15 @@
InsertAvatarForm form = new();
bool isSaving;
string switching;
// the persona whose deletion the dialog asks about
ViewAvatar deleting;
static string StateOf(ViewAvatar persona) => persona.AccountState switch
{
ViewAvatarState.Suspended => "suspended",
ViewAvatarState.Silenced => "silenced",
_ => "normal"
};
protected override void OnInitialized() => IsLoading = false;
@@ -59,6 +100,19 @@
Toast.AddResponseError(result);
}
async Task<WebResult> Delete(ActDialog.Answer answer)
{
var persona = deleting;
var result = await AuthService.DeletePersona(persona.Id, answer.Password);
if (result.IsInvalid)
return result;
// the session drops it, and acts as another persona when it was the current one (none left: make one below)
await CascadingState.PersonaDeleted(persona.Id);
Toast.AddResponseSuccess(Localizer["{0} is deleted.", persona.Name ?? "@" + persona.UserName]);
return result;
}
async Task Create()
{
isSaving = true;