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,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();
|
||||
}
|
||||
@@ -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)]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user