R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About
Build / Build (push) Successful in 1m5s

Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail,
privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to
Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every
save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the
server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements
show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server.

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 04:32:57 +02:00
1 parent b8d7858acb
commit 4c88980374
33 files changed
+3743 -239

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
with it, and every persona's stream hears it go. *@
<div class="flex flex-col gap-3 w-full" data-announcements>
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</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 (announcements.Count == 0)
{
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
}
@foreach (var announcement in announcements)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
<div class="flex flex-wrap items-center gap-2">
@switch (StateOf(announcement))
{
case "shown":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
break;
case "ended":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
break;
}
@if (announcement.AllDay)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
}
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
</span>
<span class="flex-1"></span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</div>
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
{
<p class="text-xs opacity-70">@When(announcement)</p>
}
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
@foreach (var reaction in announcement.Reactions)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
</DBadge>
}
</div>
</article>
}
</div>
@if (editing is not null)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-announcement-dialog>
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
<ChildContent>
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
data-announcement-text />
</label>
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["From"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Until"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
</label>
</div>
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
<ErrorViewer AdditionalError="@error" />
<div class="flex justify-end gap-2">
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
}
@code {
const string LocalInput = "yyyy-MM-ddTHH:mm";
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ViewAnnouncement> announcements = [];
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
AnnouncementForm editing;
string startsAt;
string endsAt;
string error;
bool isSaving;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
string When(ViewAnnouncement announcement)
{
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
return (starts, ends) switch
{
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
({ } from, null) => Localizer["From {0}", from],
_ => Localizer["until {0}", ends]
};
}
void Closed(bool open)
{
if (!open)
editing = null;
}
void New()
{
error = null;
editing = new AnnouncementForm();
startsAt = null;
endsAt = null;
}
void Edit(ViewAnnouncement announcement)
{
error = null;
editing = new AnnouncementForm
{
Id = announcement.Id,
Text = announcement.Text,
StartsAt = announcement.StartsAt,
EndsAt = announcement.EndsAt,
AllDay = announcement.AllDay,
IsPublished = announcement.IsPublished
};
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
}
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
static DateTime? Utc(string local) =>
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
async Task Save()
{
error = null;
editing.StartsAt = Utc(startsAt);
editing.EndsAt = Utc(endsAt);
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
{
error = Localizer["An announcement can't end before it starts."];
return;
}
isSaving = true;
var result = await InstanceAdminService.SaveAnnouncement(editing);
isSaving = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
var saved = (ViewAnnouncement)result.Data;
var at = announcements.FindIndex(a => a.Id == saved.Id);
if (at < 0)
announcements.Insert(0, saved);
else
announcements[at] = saved;
editing = null;
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
}
async Task Delete(ViewAnnouncement announcement)
{
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
return;
busy.Add(announcement.Id);
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
busy.Remove(announcement.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcements.Remove(announcement);
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
}
}
+399
View File
@@ -0,0 +1,399 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown
description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the
local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its
weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts
from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays.
Registrations are the configuration's and only shown. *@
<div class="flex flex-col gap-4 w-full" data-instance>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">@Localizer["Thumbnail"]</span>
<img src="@settings.ThumbnailUrl" alt="@Localizer["Thumbnail"]" class="w-full max-w-sm aspect-[40/21] object-cover rounded-lg neo-inset neo-2xs"
data-instance-thumbnail-image="@(settings.ThumbnailIsDefault ? "default" : "uploaded")" />
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="PickThumbnail" disabled="@(IsDefaultDisabled || isUploading)"
aria-label="@Localizer["Thumbnail"]" data-instance-thumbnail />
@if (isUploading)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
@if (!settings.ThumbnailIsDefault)
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isUploading" OnClick="RemoveThumbnail" data-instance-thumbnail-remove>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Back to the default picture"]</span>
</DButton>
}
</div>
<p class="text-xs opacity-70">@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]</p>
<ErrorViewer AdditionalErrors="thumbnailErrors" />
</div>
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Server name"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.Title" Immediate="true" autocomplete="off" maxlength="64" data-instance-title />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Short description"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="form.ShortDescription" Immediate="true" rows="2" autocomplete="off" maxlength="500"
data-instance-short-description />
<span class="text-xs opacity-70">@Localizer["Plain text, as the instance API, NodeInfo and the server's own actor give it."]</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Description (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.Description" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-description />
@if (settings.DescriptionUpdatedAt is { } described)
{
<span class="text-xs opacity-70" title="@described.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", described.GetPassedTime(Localizer)]</span>
}
</label>
<fieldset class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<legend class="text-sm font-semibold px-1">@Localizer["Contact"]</legend>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Contact address"]</span>
<DInput TValue="string" Type="email" class="w-full" @bind-Value="form.ContactEmail" Immediate="true" autocomplete="off" maxlength="254"
data-instance-email>
<Prefix><i class="ion-md-mail" aria-hidden="true"></i></Prefix>
</DInput>
</label>
<span class="text-sm">@Localizer["Contact persona"]</span>
@if (form.ContactAccountId is { Length: > 0 })
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-raised neo-2xs" data-instance-contact="@form.ContactAccountId">
<i class="ion-md-person text-lg" aria-hidden="true"></i>
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate">@(contactName ?? form.ContactAccountId)</b>
@if (contactHandle is { Length: > 0 })
{
<span class="text-xs opacity-70 truncate">@contactHandle</span>
}
</span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="RemoveContact" data-instance-contact-remove>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["No contact persona"]</span>
</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["None: the server names nobody."]</p>
}
@* its form attribute names no form, so Enter in the search never saves the settings *@
<DInput TValue="string" class="w-full" Value="contactQuery" ValueChanged="SearchContact" ValueExpression="() => contactQuery" Immediate="true"
autocomplete="off" form="instance-contact-search" placeholder="@Localizer["Search this server's personas"]"
aria-label="@Localizer["Search this server's personas"]" data-instance-contact-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<div class="flex flex-wrap gap-2" data-instance-contact-options>
@if (contactQuery is { Length: > 0 })
{
@foreach (var account in contactMatches)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving" OnClick="() => PickContact(account.Id, account.Name, account.Acct)"
data-instance-contact-option="@account.Id">
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</DButton>
}
@if (contactMatches.Count == 0 && !isSearching)
{
<span class="text-xs opacity-70">@Localizer["No persona of this server matches."]</span>
}
}
else
{
@foreach (var persona in CascadingState.Personas)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving"
OnClick="() => PickContact(persona.Id, persona.Name ?? persona.UserName, persona.Handle ?? persona.UserName)" data-instance-contact-option="@persona.Id">
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
</DButton>
}
}
</div>
<p class="text-xs opacity-70">@Localizer["The contact persona is shown to everyone as the one who runs this server."]</p>
</fieldset>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Privacy policy (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.PrivacyPolicy" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-privacy />
@if (settings.PrivacyPolicyUpdatedAt is { } policyAt)
{
<span class="text-xs opacity-70" title="@policyAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", policyAt.GetPassedTime(Localizer)]</span>
}
</label>
<div class="flex flex-col gap-1">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Terms of service (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.TermsOfService" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" placeholder="@Localizer["None"]" data-instance-terms />
</label>
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm">@Localizer["In effect from"]</span>
<DInput TValue="string" Type="date" class="w-auto" @bind-Value="termsDate" autocomplete="off" data-instance-terms-date />
</label>
<span class="text-xs opacity-70">@Localizer["Empty, the server has no terms. Without a date, new terms take effect today."]</span>
</div>
<div class="flex flex-col gap-2">
<DToggle LabelText="@Localizer["Publish the weekly activity"]" @bind-Value="form.PublishActivity" data-instance-publish-activity />
<p class="text-xs opacity-70">@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]</p>
</div>
<p class="text-sm" data-instance-registrations="@settings.RegistrationMode">
<b>@Localizer["Sign-ups"]</b>
@(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"])
<span class="text-xs opacity-70">@Localizer["(set in the server's configuration, Registrations:Mode)"]</span>
</p>
<ErrorViewer />
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (settings.UpdatedAt is { } savedAt)
{
<span class="me-auto text-xs opacity-70" title="@savedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)" data-instance-saved>
@Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy]
</span>
}
<DButton Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-instance-reset>@Localizer["Discard changes"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-instance-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
ViewServerSettings settings;
ServerSettingsForm form = new();
// the terms' date as the date input holds it (yyyy-MM-dd), empty for none
string termsDate;
// the contact persona as shown: what the settings name, or the one just picked
string contactName;
string contactHandle;
string contactQuery;
List<Account> contactMatches = [];
int searching;
bool isSearching;
bool isSaving;
bool isUploading;
List<string> pictureTypes = [];
List<string> thumbnailErrors = [];
string PictureTypes => string.Join(',', pictureTypes);
// what PrivaPub takes for a picture now, within its hard cap
long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0
? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes)
: ServerMediaLimitsForm.HardMaxImageBytes;
string SavedBy => settings?.UpdatedBy switch
{
null => Localizer["someone"],
"cli" => Localizer["the command line"],
var name => name
};
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))];
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset()
{
if (settings is null)
return;
form = InstanceAdminService.FormOf(settings);
termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName;
contactHandle = settings.ContactAccount?.Handle;
contactQuery = null;
contactMatches = [];
}
async Task SearchContact(string query)
{
contactQuery = query;
var lookup = ++searching;
if (string.IsNullOrWhiteSpace(query))
{
contactMatches = [];
isSearching = false;
return;
}
isSearching = true;
await Task.Delay(300);
if (lookup != searching)
return;
var found = await AccountService.Suggest(query.Trim(), false);
if (lookup != searching)
return;
// the server's own personas only: a local account's acct has no host
contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))];
isSearching = false;
}
void PickContact(string accountId, string name, string handle)
{
form.ContactAccountId = accountId;
contactName = name;
contactHandle = handle;
contactQuery = null;
contactMatches = [];
}
void RemoveContact()
{
form.ContactAccountId = null;
contactName = null;
contactHandle = null;
}
async Task PickThumbnail(InputFileChangeEventArgs eventArgs)
{
thumbnailErrors = [];
var file = eventArgs.File;
if (!pictureTypes.Contains(file.ContentType))
{
thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > MaximumThumbnail)
{
thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
MaximumThumbnail.GetFileSize(Localizer)]);
return;
}
isUploading = true;
var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail);
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// the form keeps what is being written; only the picture and the dates come from the answer
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail saved."]);
}
async Task RemoveThumbnail()
{
if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."],
Localizer["Remove"], true))
return;
isUploading = true;
var result = await InstanceAdminService.RemoveThumbnail();
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail removed."]);
}
// the value written here when it differs from the one shown, else the one PrivaPub has now
static string Changed(string written, string shown, string latest) => written == shown ? latest : written;
async Task Save()
{
var effective = default(DateTime?);
if (termsDate is { Length: > 0 })
{
if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var on))
{
Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]);
return;
}
effective = on;
}
// only what was changed here: what another administrator saved meanwhile to the rest stays
var shown = InstanceAdminService.FormOf(settings);
var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest =>
{
latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title);
latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription);
latest.Description = Changed(form.Description, shown.Description, latest.Description);
latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail);
latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId);
latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy);
latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService);
latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt;
latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity;
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The server's settings are saved."]);
}
}
+214
View File
@@ -0,0 +1,214 @@
@inherits LocalizableComponentBase
@* The media limits the administrator sets over the configuration's (Media:*, /clientapi/admin/settings): what a login's
media may take in all, how big a picture and a video may be, and how long a sound or a video may last. Left empty, a
limit is the configuration's; beside each, the configured value and the one in force now, and PrivaPub's hard caps
are the maxima (40 MB, 99 MB, four hours). Saving starts from the settings PrivaPub has now
(InstanceAdminService.SaveSettings), so what Instance or Rules saved stays. *@
<div class="flex flex-col gap-3 w-full" data-media-limits>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload."]</p>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="quota">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Media a login may keep in all"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="quotaMb" Immediate="true" autocomplete="off" min="0" step="1"
placeholder="@Localizer["as configured"]" data-media-quota>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", Quota(settings.Media.ConfiguredQuotaBytesPerRoot)] ·
<span data-media-effective>@Localizer["In force: {0}", Quota(settings.Media.EffectiveQuotaBytesPerRoot)]</span> ·
@Localizer["0: no quota"]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="image">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest picture"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="imageMb" Immediate="true" autocomplete="off" min="1"
max="@(settings.Media.HardMaxImageBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-image>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxImageBytes.GetFileSize(Localizer)] ·
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxImageBytes.GetFileSize(Localizer)]</span> ·
@Localizer["At most {0}", settings.Media.HardMaxImageBytes.GetFileSize(Localizer)]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="video">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest video or sound"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="videoMb" Immediate="true" autocomplete="off" min="1"
max="@(settings.Media.HardMaxVideoBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-video>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxVideoBytes.GetFileSize(Localizer)] ·
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxVideoBytes.GetFileSize(Localizer)]</span> ·
@Localizer["At most {0}", settings.Media.HardMaxVideoBytes.GetFileSize(Localizer)]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="seconds">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Longest video or sound"]</span>
<DInput TValue="int?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="seconds" Immediate="true" autocomplete="off" min="1"
max="@settings.Media.HardMaxSeconds" step="1" placeholder="@Localizer["as configured"]" data-media-seconds>
<Suffix><span class="text-xs opacity-70">@Localizer["seconds"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", Length(settings.Media.ConfiguredMaxSeconds)] ·
<span data-media-effective>@Localizer["In force: {0}", Length(settings.Media.EffectiveMaxSeconds)]</span> ·
@Localizer["At most {0}", Length(settings.Media.HardMaxSeconds)]
</p>
</div>
<ErrorViewer AdditionalErrors="errors" />
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="UseConfigured" data-media-use-configured>
@Localizer["Use the configured values"]
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-media-reset>@Localizer["Discard changes"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" OnClick="Save" data-media-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save the limits"]</span>
</DButton>
</div>
}
</div>
@code {
const long Megabyte = 1024 * 1024;
[Inject] InstanceAdminService InstanceAdminService { get; set; }
ViewServerSettings settings;
// the overrides as being written, in megabytes and seconds; null: the configured value
long? quotaMb;
long? imageMb;
long? videoMb;
int? seconds;
List<string> errors = [];
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset()
{
errors = [];
if (settings is null)
return;
quotaMb = Megabytes(settings.Media.QuotaBytesPerRoot);
imageMb = Megabytes(settings.Media.MaxImageBytes);
videoMb = Megabytes(settings.Media.MaxVideoBytes);
seconds = settings.Media.MaxSeconds;
}
void UseConfigured()
{
errors = [];
quotaMb = null;
imageMb = null;
videoMb = null;
seconds = null;
}
string Quota(long bytes) => bytes <= 0 ? Localizer["no quota"] : bytes.GetFileSize(Localizer);
static string Length(int value) => NerdRow.Duration(TimeSpan.FromSeconds(value));
static long? Megabytes(long? bytes) => bytes is { } value ? (long)Math.Round(value / (double)Megabyte) : null;
// what was set stays to the byte while its megabytes are left as they were shown
static long? Bytes(long? megabytes, long? before) =>
megabytes is not { } value ? null : before is { } kept && Megabytes(kept) == value ? kept : value * Megabyte;
async Task Save()
{
errors = [];
var media = settings.Media;
if (quotaMb is < 0)
errors.Add(Localizer["The quota can't be below 0."]);
if (imageMb is { } image && (image < 1 || image * Megabyte > media.HardMaxImageBytes))
errors.Add(Localizer["A picture's limit goes from 1 MB to {0}.", media.HardMaxImageBytes.GetFileSize(Localizer)]);
if (videoMb is { } video && (video < 1 || video * Megabyte > media.HardMaxVideoBytes))
errors.Add(Localizer["A video's limit goes from 1 MB to {0}.", media.HardMaxVideoBytes.GetFileSize(Localizer)]);
if (seconds is { } length && (length < 1 || length > media.HardMaxSeconds))
errors.Add(Localizer["A length goes from 1 second to {0}.", Length(media.HardMaxSeconds)]);
if (errors.Count > 0)
return;
// only the limits changed here: what another administrator saved meanwhile to the others stays
var quotaChanged = quotaMb != Megabytes(media.QuotaBytesPerRoot);
var imageChanged = imageMb != Megabytes(media.MaxImageBytes);
var videoChanged = videoMb != Megabytes(media.MaxVideoBytes);
var secondsChanged = seconds != media.MaxSeconds;
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest => latest.Media = new ServerMediaLimitsForm
{
QuotaBytesPerRoot = quotaChanged ? Bytes(quotaMb, media.QuotaBytesPerRoot) : latest.Media.QuotaBytesPerRoot,
MaxImageBytes = imageChanged ? Bytes(imageMb, media.MaxImageBytes) : latest.Media.MaxImageBytes,
MaxVideoBytes = videoChanged ? Bytes(videoMb, media.MaxVideoBytes) : latest.Media.MaxVideoBytes,
MaxSeconds = secondsChanged ? seconds : latest.Media.MaxSeconds
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The media limits are saved."]);
}
}
+161
View File
@@ -0,0 +1,161 @@
@inherits LocalizableComponentBase
@* The staff's log (/clientapi/moderator/log), newest first, 50 at a time: who acted (to administrators every login's
name; to a moderator only its own; "the command line" for PrivaPub's CLI; nobody when an act on a persona of the
acting login would tie the two), what was done, to what, and its note. Never an IP address. A moderator who isn't an
administrator reads what isn't about a login. *@
<div class="flex flex-col gap-2 w-full" data-log>
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-log-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)
{
<p class="text-sm text-error">@Localizer["The log could not be read."]</p>
}
else if (entries.Count == 0)
{
<p class="text-sm opacity-70" data-log-empty>@Localizer["Nothing was done yet."]</p>
}
<ol class="flex flex-col gap-2">
@foreach (var entry in entries)
{
<li class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-log-entry="@entry.Id" data-log-action="@entry.Action">
<div class="flex flex-wrap items-center gap-2">
<b class="text-sm">@ActionName(entry.Action)</b>
@if (entry.TargetLabel is { Length: > 0 } label)
{
<span class="text-sm break-all min-w-0" title="@($"{entry.TargetKind} {entry.TargetId}")" data-log-target>@label</span>
}
<span class="flex-1"></span>
<span class="text-xs opacity-70" title="@entry.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@entry.At.GetPassedTime(Localizer)</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span data-log-by="@(entry.ByYou ? "you" : entry.By ?? "none")">@By(entry)</span>
<code class="opacity-60">@entry.Action</code>
@if (entry.ReportId is { Length: > 0 } reportId)
{
<span class="opacity-70">@Localizer["Report: {0}", reportId]</span>
}
</div>
@if (entry.Note is { Length: > 0 } note)
{
<p class="text-xs opacity-80 break-words" data-log-note>@note</p>
}
</li>
}
</ol>
@if (next is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-log-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
@code {
const int PageSize = 50;
[Inject] AdminService AdminService { get; set; }
List<ViewAdminAction> entries = [];
// the next page's `before`; null on the last
string next;
bool failed;
bool isLoadingMore;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
var page = await AdminService.Log(null, PageSize);
failed = page is null;
entries = page?.Items ?? [];
next = page?.Next;
}
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
async Task More()
{
isLoadingMore = true;
var page = await AdminService.Log(next, PageSize);
isLoadingMore = false;
if (page is null)
{
Toast.AddResponseError(Localizer["The log could not be read."]);
return;
}
var known = entries.Select(e => e.Id).ToHashSet();
entries.AddRange(page.Items.Where(e => known.Add(e.Id)));
next = page.Next;
}
string By(ViewAdminAction entry) => entry switch
{
{ ByYou: true } => Localizer["by you"],
{ By: "cli" } => Localizer["by the command line"],
{ By: { Length: > 0 } name } => Localizer["by {0}", name],
_ => Localizer["by someone not named"]
};
string ActionName(string action) => action switch
{
"domain_block.insert" => Localizer["Server blocked"],
"domain_block.delete" => Localizer["Server unblocked"],
"backup.create" => Localizer["Backup made"],
"backup.delete" => Localizer["Backup deleted"],
"backup.restore" => Localizer["Backup restored"],
"settings.update" => Localizer["Settings saved"],
"settings.thumbnail" => Localizer["Thumbnail changed"],
"rule.insert" => Localizer["Rule added"],
"rule.update" => Localizer["Rule changed"],
"rule.delete" => Localizer["Rule removed"],
"announcement.insert" => Localizer["Announcement written"],
"announcement.update" => Localizer["Announcement changed"],
"announcement.delete" => Localizer["Announcement deleted"],
"relay.insert" => Localizer["Relay added"],
"relay.delete" => Localizer["Relay removed"],
"report.resolve" => Localizer["Report resolved"],
"root.promote" => Localizer["Login made administrator"],
"root.demote" => Localizer["Login no longer administrator"],
"root.create" => Localizer["Login made"],
"root.delete" => Localizer["Login deleted"],
"root.reset_totp" => Localizer["Two-factor sign-in reset"],
"persona.move" => Localizer["Persona moved to another login"],
_ => action
};
}
+191
View File
@@ -0,0 +1,191 @@
@inherits LocalizableComponentBase
@* The relays the server's actor subscribes to (/clientapi/admin/relays): the configuration's (Federation:Relays, removed
only there) and the ones added here, each with its subscription's state. PrivaPub answers an addition or a removal at
once (202) and subscribes or unsubscribes in the background, since reaching a relay takes a fetch, so the panel reads
the list again a few seconds later. *@
<div class="flex flex-col gap-3 w-full" data-relays>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DInput TValue="string" class="flex-1 min-w-48" Type="url" @bind-Value="address" Immediate="true" autocomplete="off" maxlength="2048"
placeholder="@Localizer["https://relay.example/actor"]" aria-label="@Localizer["The relay's address"]" data-relay-address>
<Prefix><i class="ion-md-radio" aria-hidden="true"></i></Prefix>
</DInput>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(address)" OnClick="Add" data-relay-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add the relay"]</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here."]</p>
@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 (relays.Count == 0)
{
<p class="text-sm opacity-70" data-relays-empty>@Localizer["The server subscribes to no relay."]</p>
}
@foreach (var relay in relays)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-relay="@relay.Address" data-relay-state="@relay.State"
data-relay-source="@relay.Source">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="flex-1 min-w-0 break-all text-sm">@relay.Address</b>
@switch (relay.State)
{
case "accepted":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Subscribed"]</DBadge>
break;
case "pending":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Waiting for the relay"]</DBadge>
break;
case "rejected":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Refused by the relay"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Not asked yet"]</DBadge>
break;
}
@if (relay.Source == "configuration")
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" title="@Localizer["Named by the configuration (Federation:Relays): removed only there."]">
@Localizer["Configured"]
</DBadge>
}
else
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(relay)" data-relay-remove>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
</DButton>
}
</div>
@if (relay.ActorURI is { Length: > 0 } actor && actor != relay.Address)
{
<p class="text-xs opacity-70 break-all">@Localizer["Its actor: {0}", actor]</p>
}
<p class="text-xs opacity-70">
@if (relay.RequestedAt is { } requested)
{
<span title="@requested.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Asked {0} ago", requested.GetPassedTime(Localizer)]</span>
}
@if (relay.AnsweredAt is { } answered)
{
<span title="@answered.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"> · @Localizer["answered {0} ago", answered.GetPassedTime(Localizer)]</span>
}
</p>
@if (relay.Source == "configuration")
{
<p class="text-xs opacity-70">@Localizer["Named by the configuration (Federation:Relays): removed only there."]</p>
}
</div>
}
</div>
@code {
// when the list is read again after a change: subscribing to a relay is a fetch, then its answer
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
[Inject] InstanceAdminService InstanceAdminService { get; set; }
List<ViewRelay> relays = [];
string address;
bool isSaving;
CancellationTokenSource rereading;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => relays = await InstanceAdminService.Relays();
async Task Add()
{
isSaving = true;
var result = await InstanceAdminService.AddRelay(address);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relays = (List<ViewRelay>)result.Data;
address = null;
Toast.AddResponseSuccess(Localizer["The relay is added: the server subscribes to it now."]);
await ReadAgainSoon();
}
async Task Remove(ViewRelay relay)
{
isSaving = true;
var result = await InstanceAdminService.RemoveRelay(relay.Address);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relays = (List<ViewRelay>)result.Data;
Toast.AddResponseSuccess(Localizer["The relay is removed: the server unsubscribes from it now."]);
await ReadAgainSoon();
}
// the background subscription answers later: the list is read again, the last change's timer replacing the one before
async Task ReadAgainSoon()
{
if (rereading is not null)
{
await rereading.CancelAsync();
rereading.Dispose();
}
rereading = new();
_ = ReadAgain(rereading.Token);
}
async Task ReadAgain(CancellationToken cancellationToken)
{
foreach (var pause in Rereads)
{
try
{
await Task.Delay(pause, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var read = await InstanceAdminService.Relays(cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
// an empty answer to a list that had relays is a failed read: a change here showed its own list at once
if (read.Count == 0 && relays.Count > 0)
continue;
relays = read;
await InvokeAsync(StateHasChanged);
}
}
public override async ValueTask DisposeAsync()
{
if (rereading is not null)
{
await rereading.CancelAsync();
rereading.Dispose();
}
await base.DisposeAsync();
}
}
+27 -1
View File
@@ -1,7 +1,8 @@
@inherits LocalizableComponentBase
@* 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; resolved here. *@
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. *@
<div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@@ -57,6 +58,27 @@
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
@if (report.RuleIds.Count > 0)
{
<div class="flex flex-col gap-1 text-sm" data-report-rules>
<b>@Localizer["Rules it names"]</b>
<ol class="list-decimal ps-5">
@foreach (var (ruleId, text) in RulesOf(report))
{
<li class="break-words" data-report-rule-id="@ruleId">
@if (text is { Length: > 0 })
{
@text
}
else
{
<span class="opacity-70">@Localizer["A rule no longer kept"]</span>
}
</li>
}
</ol>
</div>
}
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
@@ -113,6 +135,10 @@
IsLoading = false;
}
// each rule the report names with its text, in the report's order (an empty text: the server no longer has it)
static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) =>
report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null));
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
+177
View File
@@ -0,0 +1,177 @@
@inherits LocalizableComponentBase
@* The server's rules (/clientapi/admin/settings): written, explained, put in order and removed here, then saved together.
A rule keeps its id while it is listed, so the reports that name it keep their meaning; one removed is kept, greyed
below, as old reports name it, and can come back. Saving starts from the settings PrivaPub has now
(InstanceAdminService.SaveSettings), so what Instance or Media limits saved stays. *@
<div class="flex flex-col gap-3 w-full" data-rules>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["Everyone reads them on the About page, and a report of a violation names the ones broken."]</p>
@if (rows.Count == 0)
{
<p class="text-sm opacity-70" data-rules-empty>@Localizer["The server has no rules."]</p>
}
<ol class="flex flex-col gap-2">
@foreach (var (row, position) in Numbered)
{
<li @key="row" class="flex flex-col gap-2 p-2 rounded-lg neo-raised neo-2xs" data-rule="@position" data-rule-id="@(row.Id ?? "new")">
<div class="flex items-start gap-2 pointer-coarse:gap-2">
<span class="mt-2 w-6 flex-none text-right text-sm font-semibold tabular-nums">@(position + 1).</span>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<DTextarea TValue="string" class="w-full" @bind-Value="row.Text" Immediate="true" rows="2" autocomplete="off" maxlength="500"
placeholder="@Localizer["The rule"]" aria-label="@Localizer["The rule"]" data-rule-text />
<DInput TValue="string" class="w-full" Size="DSize.Sm" @bind-Value="row.Hint" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["What it means, if it needs saying (optional)"]" aria-label="@Localizer["Explanation"]" data-rule-hint />
</div>
<div class="flex flex-col gap-1 pointer-coarse:gap-2 flex-none">
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == 0" OnClick="() => Move(position, -1)"
aria-label="@Localizer["Move up"]" title="@Localizer["Move up"]" data-rule-up>
<i class="ion-md-arrow-up" aria-hidden="true"></i>
</DButton>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == rows.Count - 1" OnClick="() => Move(position, 1)"
aria-label="@Localizer["Move down"]" title="@Localizer["Move down"]" data-rule-down>
<i class="ion-md-arrow-down" aria-hidden="true"></i>
</DButton>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(row)"
aria-label="@Localizer["Remove the rule"]" title="@Localizer["Remove the rule"]" data-rule-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i>
</DButton>
</div>
</div>
</li>
}
</ol>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="Add" data-rule-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add a rule"]</span>
</DButton>
<span class="flex-1"></span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-rule-reset>@Localizer["Discard changes"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || rows.Any(r => string.IsNullOrWhiteSpace(r.Text))" OnClick="Save"
data-rule-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save the rules"]</span>
</DButton>
</div>
@if (rows.Count >= ServerSettingsForm.MaxRules)
{
<p class="text-xs opacity-70">@Localizer["A server has {0} rules at most.", ServerSettingsForm.MaxRules]</p>
}
@if (Removed.Count > 0)
{
<div class="flex flex-col gap-2 mt-2" data-rules-deleted>
<b class="text-sm">@Localizer["Removed rules, kept as old reports name them"]</b>
@foreach (var rule in Removed)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-inset neo-2xs opacity-60" data-rule-deleted="@rule.Id">
<span class="flex flex-col flex-1 min-w-0">
<s class="text-sm break-words">@rule.Text</s>
@if (rule.DeletedAt is { } deletedAt)
{
<span class="text-xs" title="@deletedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Removed {0} ago", deletedAt.GetPassedTime(Localizer)]</span>
}
</span>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="() => Restore(rule)"
data-rule-restore>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Bring it back"]</span>
</DButton>
</div>
}
</div>
}
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
ViewServerSettings settings;
// the rules as being written, in their order: a listed rule keeps its id, a new one has none
List<ServerRuleForm> rows = [];
bool isSaving;
IEnumerable<(ServerRuleForm Row, int Position)> Numbered => rows.Select((row, position) => (row, position));
// the rules the server keeps deleted, unless they are listed again above
List<ViewServerRule> Removed => [.. (settings?.Rules ?? []).Where(r => r.IsDeleted && rows.All(row => row.Id != r.Id))];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset() => rows = settings is null ? [] : InstanceAdminService.FormOf(settings).Rules;
void Add() => rows.Add(new ServerRuleForm());
// gone from the list, it is removed when the rules are saved (kept, marked, for the reports naming it)
void Remove(ServerRuleForm row) => rows.Remove(row);
void Move(int position, int by)
{
var row = rows[position];
rows.RemoveAt(position);
rows.Insert(position + by, row);
}
// listed again with its id, it is the same rule: the reports naming it name it still
void Restore(ViewServerRule rule) => rows.Add(new ServerRuleForm { Id = rule.Id, Text = rule.Text, Hint = rule.Hint });
async Task Save()
{
var rules = rows.Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text?.Trim(), Hint = string.IsNullOrWhiteSpace(r.Hint) ? null : r.Hint.Trim() }).ToList();
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest => latest.Rules = rules);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The rules are saved."]);
}
}
+39 -2
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins.
A block is published (/api/v1/instance/domain_blocks, the About page) only when asked, since that tells the blocked
server and everyone else; published, its name can be partly starred. Blocking a server again changes its block, so
Edit fills the form with it. *@
<div class="flex flex-col gap-3 w-full" data-server-blocks>
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<DataAnnotationsValidator />
@@ -13,13 +16,19 @@
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div>
<div class="flex flex-wrap gap-2 items-center">
<DToggle LabelText="@Localizer["Publish it"]" Value="form.Published" ValueChanged="Publish" ValueExpression="() => form.Published"
data-server-block-published />
<DToggle LabelText="@Localizer["Hide part of its name"]" @bind-Value="form.Obfuscate" disabled="@(!form.Published)" data-server-block-obfuscate />
</div>
<p class="text-xs opacity-70">@Localizer["A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it."]</p>
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for everyone to read"]" />
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for the admins only"]" />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save>
@Localizer["Block server"]
</DButton>
</EditForm>
@@ -48,7 +57,16 @@
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
}
@if (block.Published)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Info" data-server-block-public>
@(block.Obfuscate ? Localizer["Published, partly hidden"] : Localizer["Published"])
</DBadge>
}
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Edit(block)" data-server-block-edit>
@Localizer["Edit"]
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
@Localizer["Unblock"]
</DButton>
@@ -89,6 +107,25 @@
async Task LoadData() => blocks = await AdminService.ServerBlocks();
// a block that isn't published can't be partly hidden either
void Publish(bool published)
{
form.Published = published;
if (!published)
form.Obfuscate = false;
}
void Edit(ViewDomainBlock block) => form = new()
{
Domain = block.Domain,
Suspend = block.Severity == "Suspend",
RejectMedia = block.RejectMedia,
Published = block.Published,
Obfuscate = block.Obfuscate,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment
};
async Task Block()
{
isSaving = true;