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

+78 -7
View File
@@ -160,7 +160,8 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
- No history entries are pushed. - No history entries are pushed.
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in - **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a `MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
group, dropping a scheduled post and signing out. group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
server's thumbnail.
- **On a touch screen:** - **On a touch screen:**
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are - a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
pinned to the globe with a mouse only; pinned to the globe with a mouse only;
@@ -297,8 +298,11 @@ Never redeclare the base's members in a component.
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads, and deleting those no post holds), (`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
`GroupService`, `InstanceService` and `InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
`InstanceAdminController`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
`ProvenanceService`. `ProvenanceService`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
@@ -317,6 +321,14 @@ Never redeclare the base's members in a component.
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original" status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`. (`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from
Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long
description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules
(`data-about-rules`, `data-about-rule`), privacy policy (`data-about-privacy`), terms of service (`data-about-terms`,
none while PrivaPub answers 404) and the domain blocks the administrator published (`data-about-blocks`,
`data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized
HTML in `post-content doc-content` (`Styles/feed.css` spaces their headings). Login's heading names the server
(`data-login-title`, `/api/v2/instance` read without credentials and kept for the session).
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
@@ -324,7 +336,9 @@ Never redeclare the base's members in a component.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile. linking the new account's profile.
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words, Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`). Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
@@ -412,9 +426,56 @@ Never redeclare the base's members in a component.
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked), by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
with progress, a report and Stop. Imported posts are delivered to no one. with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for - **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list `data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty. log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators:
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until
then they have no section.
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
`AboutService` read the instance again.
- **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description
(`data-instance-short-description`), Markdown description (`data-instance-description`), contact address
(`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`;
picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
`data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy
policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the
weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the
configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The
thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`,
asked first) is saved as soon as it is picked, streamed from the file as multipart `file`.
- **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its
`data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`,
`data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
(`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id.
- **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`,
`data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions;
`data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`,
`-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`);
`data-announcement-delete` asks first.
- **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and
`/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars
part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a
block, since blocking a server again changes its block. `data-server-block-save`.
- **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source`
configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from
`Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
- **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows,
inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the
configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`,
`data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
- **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time
(`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
(`data-log-note`). `data-log-reload`.
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
texts, a removed rule's too.
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels' - **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the `Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
ledger's events and the server's own days. Counted per server, never per account. ledger's events and the server's own days. Counted per server, never per account.
@@ -461,6 +522,16 @@ Never redeclare the base's members in a component.
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL) streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible. it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
- **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones
shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`,
`-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to
(`data-announcement-reaction="{emoji}"` toggles the persona's own, `data-announcement-react` offers more,
`data-announcement-react-emoji`; `PUT`/`DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They
come live on the user stream (`announcement`, `announcement.reaction`, `announcement.delete`):
`StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it
alone on a polled feed), and raises what it tells as `Announced`; the user stream's posts and notifications are
the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the
feed's cards.
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only - **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification, when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
+9
View File
@@ -90,6 +90,15 @@ namespace decePubClient;
[JsonSerializable(typeof(ImportPersonaArchiveForm))] [JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))] [JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))] [JsonSerializable(typeof(ViewArchiveUpload))]
[JsonSerializable(typeof(ViewServerSettings))]
[JsonSerializable(typeof(ServerSettingsForm))]
[JsonSerializable(typeof(ViewAnnouncement))]
[JsonSerializable(typeof(List<ViewAnnouncement>))]
[JsonSerializable(typeof(AnnouncementForm))]
[JsonSerializable(typeof(ViewRelay))]
[JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))]
public partial class ClientJsonContext : JsonSerializerContext public partial class ClientJsonContext : JsonSerializerContext
{ {
} }
+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 @inherits LocalizableComponentBase
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another @* 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> <div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" /> <DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@@ -57,6 +58,27 @@
<b>@Localizer["From"]</b> <b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span> <span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p> </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) @if (report.Comment is { Length: > 0 } comment)
{ {
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p> <p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
@@ -113,6 +135,10 @@
IsLoading = false; 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 string CategoryName(string category) => category switch
{ {
"spam" => Localizer["Spam"], "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 @inherits LocalizableComponentBase
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced @* 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> <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"> <EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
@@ -13,13 +16,19 @@
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" /> <DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" /> <DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div> </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" <DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for everyone to read"]" /> placeholder="@Localizer["Why, for everyone to read"]" />
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000" <DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for the admins only"]" /> placeholder="@Localizer["Why, for the admins only"]" />
<ErrorViewer /> <ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" <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"] @Localizer["Block server"]
</DButton> </DButton>
</EditForm> </EditForm>
@@ -48,7 +57,16 @@
{ {
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge> <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> <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)"> <DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
@Localizer["Unblock"] @Localizer["Unblock"]
</DButton> </DButton>
@@ -89,6 +107,25 @@
async Task LoadData() => blocks = await AdminService.ServerBlocks(); 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() async Task Block()
{ {
isSaving = true; isSaving = true;
+207
View File
@@ -0,0 +1,207 @@
@inherits LocalizableComponentBase
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
@if (Current is { } current)
{
<section class="flex-none flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" aria-label="@Localizer["Announcements"]"
data-announcement-banner data-announcement="@current.Id">
<div class="flex items-center gap-2 pointer-coarse:gap-2">
<i class="ion-md-megaphone text-lg text-primary" aria-hidden="true"></i>
<b class="text-sm">@Localizer["Announcement"]</b>
<span class="text-xs opacity-70" title="@current.PublishedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@current.PublishedAt.GetPassedTime(Localizer)
</span>
@if (ShownCount > 1)
{
<span class="inline-flex items-center gap-1 pointer-coarse:gap-2">
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index == 0" OnClick="() => index--" aria-label="@Localizer["Previous announcement"]"
title="@Localizer["Previous announcement"]" data-announcement-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
<span class="text-xs tabular-nums" data-announcement-position>@Localizer["{0} of {1}", index + 1, ShownCount]</span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index >= ShownCount - 1" OnClick="() => index++" aria-label="@Localizer["Next announcement"]"
title="@Localizer["Next announcement"]" data-announcement-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
</span>
}
<span class="flex-1"></span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Circle" Disabled="IsDefaultDisabled || isActing" OnClick="Dismiss"
aria-label="@Localizer["Dismiss"]" title="@Localizer["Dismiss"]" data-announcement-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
</div>
@if (current.StartsAt is not null || current.EndsAt is not null)
{
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
}
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
@foreach (var reaction in current.Reactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
}
else
{
<span>@reaction.Name</span>
}
<span class="tabular-nums">@reaction.Count</span>
</DButton>
}
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
}
</ChildContent>
</DDropdown>
</div>
</section>
}
@code {
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
[Inject] AnnouncementService AnnouncementService { get; set; }
[Inject] StreamingService Streaming { get; set; }
List<Announcement> announcements = [];
int index;
bool isActing;
bool isReactionsOpen;
string loadedPersonaId;
// what the persona has not dismissed, oldest first as the server lists them
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
int ShownCount => announcements.Count(a => a.Read != true);
// the one in view, or none
Announcement Current => Shown is [_, ..] shown ? shown[Math.Clamp(index, 0, shown.Count - 1)] : null;
protected override void OnInitialized() => Streaming.Announced += OnAnnounced;
// another persona reads its own: its dismissals and reactions
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
announcements = await AnnouncementService.List();
index = 0;
await Streaming.HearAnnouncements(true);
}
string When(Announcement 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]
};
}
async Task Dismiss()
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.Dismiss(announcement.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcement.Read = true;
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
}
// the counts are read again afterwards: the stream may have told of this very reaction already
async Task React(string emoji, bool add)
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.React(announcement.Id, emoji, add);
if (result.IsInvalid)
{
isActing = false;
Toast.AddResponseError(result);
return;
}
var position = index;
announcements = await AnnouncementService.List();
index = Math.Clamp(position, 0, Math.Max(0, ShownCount - 1));
isActing = false;
}
Task OnAnnounced(AnnouncementMessage message) => InvokeAsync(() =>
{
switch (message.Event)
{
case "announcement" when message.Announcement is { } changed:
var at = announcements.FindIndex(a => a.Id == changed.Id);
if (at < 0)
announcements.Add(changed);
else
announcements[at] = changed;
break;
case "announcement.delete" when message.DeletedId is { } id:
announcements.RemoveAll(a => a.Id == id);
break;
case "announcement.reaction" when message.Reaction is { } reaction && announcements.Find(a => a.Id == reaction.AnnouncementId) is { } reacted:
var known = reacted.Reactions.Find(r => r.Name == reaction.Name);
reacted.Reactions = reaction.Count <= 0
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
: known is null
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
break;
default:
return;
}
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
StateHasChanged();
});
public override async ValueTask DisposeAsync()
{
Streaming.Announced -= OnAnnounced;
await Streaming.HearAnnouncements(false);
await base.DisposeAsync();
}
}
+60 -4
View File
@@ -1,7 +1,9 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports). @* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@ Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A
violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the
server has rules. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog> <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
<Heading>@Localizer["Report {0}", Account.Name]</Heading> <Heading>@Localizer["Report {0}", Account.Name]</Heading>
<ChildContent> <ChildContent>
@@ -11,8 +13,31 @@
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category> <DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option> <option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option> <option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
@if (choices.Count > 0)
{
<option value="@ReportCategory.Violation">@Localizer[nameof(ReportCategory.Violation)]</option>
}
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option> <option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect> </DSelect>
@if (form.Category is ReportCategory.Violation)
{
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2" data-report-rules>
<legend class="text-sm mb-1">@Localizer["Which of the server's rules does it break?"]</legend>
@foreach (var choice in choices)
{
<label class="flex items-start gap-2 cursor-pointer pointer-coarse:py-2">
<DCheckbox @bind-Value="choice.Picked" class="mt-0.5 flex-none" data-report-rule="@choice.Rule.Id" />
<span class="flex flex-col min-w-0">
<span class="text-sm break-words">@choice.Rule.Text</span>
@if (choice.Rule.Hint is { Length: > 0 } hint)
{
<span class="text-xs opacity-70 break-words">@hint</span>
}
</span>
</label>
}
</fieldset>
}
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength" <DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
placeholder="@Localizer["What is wrong with it?"]" data-report-comment /> placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
@if (Host is { } host) @if (Host is { } host)
@@ -30,6 +55,7 @@
@code { @code {
[Inject] AccountService AccountService { get; set; } [Inject] AccountService AccountService { get; set; }
[Inject] AboutService AboutService { get; set; }
[Parameter, EditorRequired] public Account Account { get; set; } [Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary> /// <summary>The post it was opened from, sent with the report.</summary>
@@ -39,17 +65,41 @@
ReportForm form = new(); ReportForm form = new();
bool isSending; bool isSending;
string error; string error;
// the server's rules, each ticked or not, for a violation
List<RuleChoice> choices = [];
// the account's own server, for a remote account // the account's own server, for a remote account
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null; string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
protected override void OnInitialized() => IsLoading = false; protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => choices = [.. (await AboutService.Rules()).Select(rule => new RuleChoice { Rule = rule })];
async Task Send() async Task Send()
{ {
isSending = true;
error = null; error = null;
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward); var ruleIds = form.Category is ReportCategory.Violation ? choices.Where(c => c.Picked).Select(c => c.Rule.Id).ToList() : [];
if (form.Category is ReportCategory.Violation && ruleIds.Count == 0)
{
error = Localizer["Tick the rules it breaks."];
return;
}
isSending = true;
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, ruleIds,
Host is not null && form.Forward);
isSending = false; isSending = false;
if (result.IsInvalid) if (result.IsInvalid)
{ {
@@ -62,4 +112,10 @@
} }
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync(); Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
sealed class RuleChoice
{
public InstanceRule Rule { get; init; }
public bool Picked { get; set; }
}
} }
+8
View File
@@ -59,6 +59,14 @@ namespace decePubClient;
[JsonSerializable(typeof(GroupedNotifications))] [JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))] [JsonSerializable(typeof(MarkersForm))]
[JsonSerializable(typeof(UnreadCount))] [JsonSerializable(typeof(UnreadCount))]
[JsonSerializable(typeof(InstanceRule))]
[JsonSerializable(typeof(List<InstanceRule>))]
[JsonSerializable(typeof(InstanceDocument))]
[JsonSerializable(typeof(TermsOfService))]
[JsonSerializable(typeof(List<PublicDomainBlock>))]
[JsonSerializable(typeof(Announcement))]
[JsonSerializable(typeof(List<Announcement>))]
[JsonSerializable(typeof(AnnouncementReactionEvent))]
public partial class MastodonJsonContext : JsonSerializerContext public partial class MastodonJsonContext : JsonSerializerContext
{ {
} }
+21
View File
@@ -50,6 +50,18 @@ public static class APIs
public const string POST_DeletePost = "clientapi/post/delete"; public const string POST_DeletePost = "clientapi/post/delete";
public const string GET_ModeratorReports = "clientapi/moderator/reports"; public const string GET_ModeratorReports = "clientapi/moderator/reports";
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve"; public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
public const string GET_ModeratorLog = "clientapi/moderator/log";
public const string GET_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list";
public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert";
public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update";
public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}";
public const string GET_AdminRelays = "clientapi/admin/relays";
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list"; public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert"; public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}"; public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
@@ -111,6 +123,15 @@ public static class APIs
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}"; public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}"; public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public const string GET_InstanceV2 = "api/v2/instance"; public const string GET_InstanceV2 = "api/v2/instance";
public const string GET_InstanceRules = "api/v1/instance/rules";
public const string GET_InstanceExtendedDescription = "api/v1/instance/extended_description";
public const string GET_InstancePrivacyPolicy = "api/v1/instance/privacy_policy";
public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service";
public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks";
public const string GET_Announcements = "api/v1/announcements";
public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss";
public static string M_AnnouncementReaction(string announcementId, string emoji) =>
$"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}";
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar"; public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
public const string DELETE_ProfileHeader = "api/v1/profile/header"; public const string DELETE_ProfileHeader = "api/v1/profile/header";
public const string GET_AccountLookup = "api/v1/accounts/lookup"; public const string GET_AccountLookup = "api/v1/accounts/lookup";
+35
View File
@@ -0,0 +1,35 @@
namespace decePubClient.Models.Mastodon;
/// <summary>/api/v1/announcements: what the server's administrators announce, shown while published and not ended, as
/// the persona sees it (Read and each reaction's Me are the persona's own; signed out, neither is said).</summary>
public class Announcement
{
public string Id { get; set; }
/// <summary>Sanitized HTML, from the administrator's Markdown.</summary>
public string Content { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public bool AllDay { get; set; }
public DateTime PublishedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>Whether the persona dismissed it.</summary>
public bool? Read { get; set; }
public List<AnnouncementReaction> Reactions { get; set; } = [];
}
/// <summary>A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did.</summary>
public class AnnouncementReaction
{
public string Name { get; set; }
public int Count { get; set; }
public bool? Me { get; set; }
public string Url { get; set; }
}
/// <summary>The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which.</summary>
public class AnnouncementReactionEvent
{
public string Name { get; set; }
public int Count { get; set; }
public string AnnouncementId { get; set; }
}
+60 -1
View File
@@ -1,11 +1,39 @@
namespace decePubClient.Models.Mastodon; namespace decePubClient.Models.Mastodon;
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary> /// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
/// its rules, and what an upload may be.</summary>
public class InstanceV2 public class InstanceV2
{ {
public string Domain { get; set; }
public string Title { get; set; }
/// <summary>The short description, as plain text.</summary>
public string Description { get; set; }
public InstanceThumbnail Thumbnail { get; set; } = new();
public InstanceContact Contact { get; set; } = new();
public List<InstanceRule> Rules { get; set; } = [];
public InstanceConfiguration Configuration { get; set; } = new(); public InstanceConfiguration Configuration { get; set; } = new();
} }
public class InstanceThumbnail
{
public string Url { get; set; }
}
/// <summary>Who answers for the server: an address, and the persona its administrator named (null when none).</summary>
public class InstanceContact
{
public string Email { get; set; }
public Account Account { get; set; }
}
/// <summary>One of the server's rules (/api/v1/instance/rules and the instance's own), in their order.</summary>
public class InstanceRule
{
public string Id { get; set; }
public string Text { get; set; }
public string Hint { get; set; }
}
public class InstanceConfiguration public class InstanceConfiguration
{ {
public MediaConfiguration MediaAttachments { get; set; } = new(); public MediaConfiguration MediaAttachments { get; set; } = new();
@@ -21,3 +49,34 @@ public class MediaConfiguration
public long VideoMatrixLimit { get; set; } public long VideoMatrixLimit { get; set; }
public int VideoFrameRateLimit { get; set; } public int VideoFrameRateLimit { get; set; }
} }
/// <summary>/api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it
/// last changed.</summary>
public class InstanceDocument
{
public DateTime? UpdatedAt { get; set; }
public string Content { get; set; }
}
/// <summary>/api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none
/// (404).</summary>
public class TermsOfService
{
/// <summary>yyyy-MM-dd.</summary>
public string EffectiveDate { get; set; }
/// <summary>Whether they apply already (false while their date is ahead).</summary>
public bool Effective { get; set; }
public string Content { get; set; }
public string SucceededBy { get; set; }
}
/// <summary>/api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is
/// obfuscated, its digest the SHA-256 of the whole name.</summary>
public class PublicDomainBlock
{
public string Domain { get; set; }
public string Digest { get; set; }
/// <summary>suspend, silence or noop.</summary>
public string Severity { get; set; }
public string Comment { get; set; }
}
+3 -1
View File
@@ -254,8 +254,10 @@ public class ReportRequest
public string AccountId { get; set; } public string AccountId { get; set; }
public List<string> StatusIds { get; set; } = []; public List<string> StatusIds { get; set; } = [];
public string Comment { get; set; } public string Comment { get; set; }
/// <summary>spam, legal or other.</summary> /// <summary>spam, legal, violation or other.</summary>
public string Category { get; set; } public string Category { get; set; }
/// <summary>The server's rules a violation breaks (only a violation names rules).</summary>
public List<string> RuleIds { get; set; } = [];
public bool Forward { get; set; } public bool Forward { get; set; }
} }
+3 -2
View File
@@ -1,10 +1,11 @@
namespace decePubClient.Models.Types namespace decePubClient.Models.Types
{ {
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary> /// <summary>Why an account is reported (Mastodon's categories). A violation names the server's rules it breaks.</summary>
public enum ReportCategory public enum ReportCategory
{ {
Other = 0, Other = 0,
Spam = 1, Spam = 1,
Legal = 2 Legal = 2,
Violation = 3
} }
} }
+223
View File
@@ -0,0 +1,223 @@
@page "/about"
@inherits LocalizableComponentBase
<Title>@Localizer["About {0}", instance?.Title ?? "PrivaPub"]</Title>
@* What this server says of itself, to anyone, signed in or not (AboutService, the Mastodon instance API without
credentials): its name, short description and thumbnail, its long description, who answers for it, its rules, its
privacy policy and terms of service, and the domain blocks its administrator published (an obfuscated one partly
starred, as the server gives it). The documents are the server's sanitized HTML. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto" data-about>
@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 (instance is null)
{
<div class="flex flex-col items-center gap-2 mt-6">
<p class="text-sm text-error">@Localizer["The server's description could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<article class="flex flex-col w-full max-w-3xl rounded-xl neo-raised neo-sm overflow-hidden" data-about-server>
@if (instance.Thumbnail.Url is { Length: > 0 } thumbnail)
{
@* decorative: the server's name follows *@
<img src="@thumbnail" alt="" class="w-full aspect-[40/21] object-cover" />
}
<div class="flex flex-col gap-2 p-4 md:p-5">
<h1 class="text-2xl font-bold break-words" data-about-title>@instance.Title</h1>
@if (instance.Domain is { Length: > 0 } domain)
{
<p class="text-sm opacity-70 break-all">@domain</p>
}
@if (instance.Description is { Length: > 0 } summary)
{
<p class="text-base break-words" data-about-summary>@summary</p>
}
</div>
</article>
@if (description?.Content is { Length: > 0 } extended)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-description>
<div class="post-content doc-content break-words">@((MarkupString)extended)</div>
@if (description.UpdatedAt is { } describedAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-contact>
<h2 class="text-lg font-bold">@Localizer["Contact"]</h2>
@if (instance.Contact.Account is { } account)
{
<a class="flex items-center gap-3 p-2 rounded-lg neo-raised neo-2xs min-w-0" href="@AccountLink(account)" data-about-contact-account="@account.Id">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
@if (instance.Contact.Email is { Length: > 0 } email)
{
<a class="link inline-flex items-center gap-2 break-all touch-target self-start" href="@($"mailto:{email}")" data-about-contact-email>
<i class="ion-md-mail" aria-hidden="true"></i>@email
</a>
}
</article>
}
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-rules>
<h2 class="text-lg font-bold">@Localizer["Rules"]</h2>
@if (instance.Rules.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["The server has no rules."]</p>
}
else
{
<ol class="flex flex-col gap-2 list-decimal ps-6">
@foreach (var rule in instance.Rules)
{
<li class="break-words" data-about-rule="@rule.Id">
<span>@rule.Text</span>
@if (rule.Hint is { Length: > 0 } hint)
{
<p class="text-sm opacity-70">@hint</p>
}
</li>
}
</ol>
}
</article>
@if (privacy?.Content is { Length: > 0 } policy)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-privacy>
<h2 class="text-lg font-bold">@Localizer["Privacy policy"]</h2>
<div class="post-content doc-content break-words">@((MarkupString)policy)</div>
@if (privacy.UpdatedAt is { } policyAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (terms?.Content is { Length: > 0 } termsText)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-terms>
<h2 class="text-lg font-bold">@Localizer["Terms of service"]</h2>
@if (TermsDate is { } effectiveOn)
{
<p class="text-xs opacity-70">
@(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn])
</p>
}
<div class="post-content doc-content break-words">@((MarkupString)termsText)</div>
</article>
}
@if (blocks.Count > 0)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-blocks>
<h2 class="text-lg font-bold">@Localizer["Moderated servers"]</h2>
<p class="text-sm opacity-70">@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]</p>
<ul class="flex flex-col gap-2">
@foreach (var block in blocks)
{
<li class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs" data-about-block="@block.Domain">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 break-all font-mono text-sm" title="@($"SHA-256 {block.Digest}")">@block.Domain</b>
@switch (block.Severity)
{
case "suspend":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Suspended"]</DBadge>
break;
case "silence":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Silenced"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Limited"]</DBadge>
break;
}
</div>
@if (block.Comment is { Length: > 0 } reason)
{
<p class="text-xs break-words">@reason</p>
}
</li>
}
</ul>
</article>
}
}
</div>
</section>
@code {
[Inject] AboutService AboutService { get; set; }
InstanceV2 instance;
InstanceDocument description;
InstanceDocument privacy;
TermsOfService terms;
List<PublicDomainBlock> blocks = [];
// the terms' date as the reader writes dates
string TermsDate => DateTime.TryParseExact(terms?.EffectiveDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var on) ? on.ToString(SUtility.dateTimeFormat) : null;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
var reading = AboutService.Instance(fresh: true);
var describing = AboutService.ExtendedDescription();
var policing = AboutService.PrivacyPolicy();
var terming = AboutService.Terms();
var blocking = AboutService.DomainBlocks();
await Task.WhenAll(reading, describing, policing, terming, blocking);
instance = reading.Result;
description = describing.Result;
privacy = policing.Result;
terms = terming.Result;
blocks = blocking.Result;
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
// signed in, the contact's profile in the app; signed out, its own page
string AccountLink(Account account) =>
CascadingState.IsSignedIn ? $"account/{Uri.EscapeDataString(account.Id)}" : account.Url;
}
+86 -161
View File
@@ -4,30 +4,15 @@
<Title>@Localizer["Administration"]</Title> <Title>@Localizer["Administration"]</Title>
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back @* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its
up and restore the server. The rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation
other sections wait for PrivaPub endpoints that do not exist yet. *@ statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it.
Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto"> <div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="reports">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Users"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span> <i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
@@ -40,8 +25,60 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-list-box text-xl"></i> <span>@Localizer["Moderation log"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<ModerationLogPanel />
</div>
</ChildContent>
</DCollapse>
<AuthorizeView Policy="@Policies.IsAdmin"> <AuthorizeView Policy="@Policies.IsAdmin">
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-information-circle text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<InstancePanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="rules">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-book text-xl"></i> <span>@Localizer["Rules"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RulesPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="announcements">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-megaphone text-xl"></i> <span>@Localizer["Announcements"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<AnnouncementsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span> <i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
@@ -54,7 +91,33 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="relays">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-radio text-xl"></i> <span>@Localizer["Relays"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RelaysPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="media">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-images text-xl"></i> <span>@Localizer["Media limits"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<MediaLimitsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="statistics">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span> <i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
@@ -67,7 +130,7 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="backups">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span> <i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
@@ -81,144 +144,6 @@
</DCollapse> </DCollapse>
</AuthorizeView> </AuthorizeView>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["ActivityPub?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Authentication"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Emoji?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Frontend"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Link formatter"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Metadata"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Policies"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Upload"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
</div> </div>
</section> </section>
@code {
}
+3 -1
View File
@@ -10,7 +10,7 @@
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
the stream tells is applied at once. *@ the stream tells is applied at once. The administrators' announcements show above the tabs (AnnouncementsBanner). *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full"> <div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@ @* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
@@ -29,6 +29,8 @@
<i class="ion-md-create text-xl" aria-hidden="true"></i> <i class="ion-md-create text-xl" aria-hidden="true"></i>
</DButton> </DButton>
} }
@* the administrators' announcements: a component of its own, outside the virtualized cards *@
<AnnouncementsBanner />
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true" <FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
OnChanged="ChangeFeed" /> OnChanged="ChangeFeed" />
+15 -3
View File
@@ -6,7 +6,9 @@
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). *@ 428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode).
The heading names the server as its administrator calls it (/api/v2/instance, read without credentials); About tells
the rest. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto"> <div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -26,7 +28,7 @@
} }
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm"> <EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1> <h1 class="text-xl font-bold break-words" data-login-title>@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]</h1>
@if (needsCode) @if (needsCode)
{ {
@* The name and the password stay in the form, out of sight, and go again with the code. *@ @* The name and the password stay in the form, out of sight, and go again with the code. *@
@@ -93,6 +95,9 @@
<p class="text-sm text-center"> <p class="text-sm text-center">
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a> @Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
</p> </p>
<p class="text-sm text-center">
<a class="link touch-target" href="about" data-about-offer>@Localizer["About this server"]</a>
</p>
</div> </div>
} }
else if (CascadingState.Personas.Count == 0) else if (CascadingState.Personas.Count == 0)
@@ -134,11 +139,14 @@
@code { @code {
[Inject] AuthService AuthService { get; set; } [Inject] AuthService AuthService { get; set; }
[Inject] AboutService AboutService { get; set; }
readonly LoginForm form = new(); readonly LoginForm form = new();
string error; string error;
bool isWorking; bool isWorking;
bool setupPending; bool setupPending;
// the server's own name, once read
string serverTitle;
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary> /// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
bool needsCode; bool needsCode;
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary> /// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
@@ -160,7 +168,11 @@
IsLoading = false; IsLoading = false;
} }
async Task LoadData() => setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending(); async Task LoadData()
{
serverTitle = (await AboutService.Instance())?.Title;
setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending();
}
async Task SignIn() async Task SignIn()
{ {
+3
View File
@@ -44,6 +44,9 @@ builder.Services.AddOptions()
.AddScoped<AccountService>() .AddScoped<AccountService>()
.AddScoped<PortabilityService>() .AddScoped<PortabilityService>()
.AddScoped<AdminService>() .AddScoped<AdminService>()
.AddScoped<InstanceAdminService>()
.AddScoped<AboutService>()
.AddScoped<AnnouncementService>()
.AddScoped<ArchiveService>() .AddScoped<ArchiveService>()
.AddScoped<ListService>() .AddScoped<ListService>()
.AddScoped<FilterService>() .AddScoped<FilterService>()
+510 -21
View File
@@ -41,9 +41,6 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="ActivityPub?" xml:space="preserve">
<value>ActivityPub?</value>
</data>
<data name="Administration" xml:space="preserve"> <data name="Administration" xml:space="preserve">
<value>Amministrazione</value> <value>Amministrazione</value>
</data> </data>
@@ -53,9 +50,6 @@
<data name="Audio" xml:space="preserve"> <data name="Audio" xml:space="preserve">
<value>Audio</value> <value>Audio</value>
</data> </data>
<data name="Authentication" xml:space="preserve">
<value>Autenticazione</value>
</data>
<data name="Block" xml:space="preserve"> <data name="Block" xml:space="preserve">
<value>Blocca</value> <value>Blocca</value>
</data> </data>
@@ -83,9 +77,6 @@
<data name="Direct" xml:space="preserve"> <data name="Direct" xml:space="preserve">
<value>Diretto</value> <value>Diretto</value>
</data> </data>
<data name="Emoji?" xml:space="preserve">
<value>Emoji?</value>
</data>
<data name="Empty" xml:space="preserve"> <data name="Empty" xml:space="preserve">
<value>Vuoto</value> <value>Vuoto</value>
</data> </data>
@@ -101,9 +92,6 @@
<data name="FollowersOnly" xml:space="preserve"> <data name="FollowersOnly" xml:space="preserve">
<value>Solo follower</value> <value>Solo follower</value>
</data> </data>
<data name="Frontend" xml:space="preserve">
<value>Frontend</value>
</data>
<data name="General" xml:space="preserve"> <data name="General" xml:space="preserve">
<value>Generale</value> <value>Generale</value>
</data> </data>
@@ -125,9 +113,6 @@
<data name="Light theme colour" xml:space="preserve"> <data name="Light theme colour" xml:space="preserve">
<value>Colore del tema chiaro</value> <value>Colore del tema chiaro</value>
</data> </data>
<data name="Link formatter" xml:space="preserve">
<value>Formattazione dei link</value>
</data>
<data name="loading" xml:space="preserve"> <data name="loading" xml:space="preserve">
<value>caricamento</value> <value>caricamento</value>
</data> </data>
@@ -149,9 +134,6 @@
<data name="Message scope type: {0}" xml:space="preserve"> <data name="Message scope type: {0}" xml:space="preserve">
<value>Visibilità del messaggio: {0}</value> <value>Visibilità del messaggio: {0}</value>
</data> </data>
<data name="Metadata" xml:space="preserve">
<value>Metadati</value>
</data>
<data name="Missing content, either message or media" xml:space="preserve"> <data name="Missing content, either message or media" xml:space="preserve">
<value>Manca il contenuto: scrivi un messaggio o allega un file</value> <value>Manca il contenuto: scrivi un messaggio o allega un file</value>
</data> </data>
@@ -167,9 +149,6 @@
<data name="Other" xml:space="preserve"> <data name="Other" xml:space="preserve">
<value>Altro</value> <value>Altro</value>
</data> </data>
<data name="Policies" xml:space="preserve">
<value>Regole</value>
</data>
<data name="Post" xml:space="preserve"> <data name="Post" xml:space="preserve">
<value>Pubblica</value> <value>Pubblica</value>
</data> </data>
@@ -2597,4 +2576,514 @@
<data name="written / dropped / failed" xml:space="preserve"> <data name="written / dropped / failed" xml:space="preserve">
<value>scritti / scartati / falliti</value> <value>scritti / scartati / falliti</value>
</data> </data>
<data name="&quot;{0}&quot; is not a date." xml:space="preserve">
<value>"{0}" non è una data.</value>
</data>
<data name="(set in the server's configuration, Registrations:Mode)" xml:space="preserve">
<value>(nella configurazione del server, Registrations:Mode)</value>
</data>
<data name="0: no quota" xml:space="preserve">
<value>0: nessuna quota</value>
</data>
<data name="A length goes from 1 second to {0}." xml:space="preserve">
<value>Una durata va da 1 secondo a {0}.</value>
</data>
<data name="A picture's limit goes from 1 MB to {0}." xml:space="preserve">
<value>Il limite di un'immagine va da 1 MB a {0}.</value>
</data>
<data name="A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it." xml:space="preserve">
<value>Un blocco pubblicato compare nella pagina Informazioni e nell'API dell'istanza: il server bloccato, e chiunque, può leggerlo.</value>
</data>
<data name="A rule no longer kept" xml:space="preserve">
<value>Una regola non più conservata</value>
</data>
<data name="A server has {0} rules at most." xml:space="preserve">
<value>Un server ha al massimo {0} regole.</value>
</data>
<data name="A video's limit goes from 1 MB to {0}." xml:space="preserve">
<value>Il limite di un video va da 1 MB a {0}.</value>
</data>
<data name="About this server" xml:space="preserve">
<value>Informazioni sul server</value>
</data>
<data name="About {0}" xml:space="preserve">
<value>Informazioni su {0}</value>
</data>
<data name="Add a rule" xml:space="preserve">
<value>Aggiungi una regola</value>
</data>
<data name="Add the relay" xml:space="preserve">
<value>Aggiungi il relay</value>
</data>
<data name="All day" xml:space="preserve">
<value>Tutto il giorno</value>
</data>
<data name="An announcement can't end before it starts." xml:space="preserve">
<value>Un annuncio non può finire prima di cominciare.</value>
</data>
<data name="Announcement" xml:space="preserve">
<value>Annuncio</value>
</data>
<data name="Announcement changed" xml:space="preserve">
<value>Annuncio modificato</value>
</data>
<data name="Announcement deleted" xml:space="preserve">
<value>Annuncio eliminato</value>
</data>
<data name="Announcement written" xml:space="preserve">
<value>Annuncio scritto</value>
</data>
<data name="Announcements" xml:space="preserve">
<value>Annunci</value>
</data>
<data name="Asked {0} ago" xml:space="preserve">
<value>Chiesto {0} fa</value>
</data>
<data name="At most {0}" xml:space="preserve">
<value>Al massimo {0}</value>
</data>
<data name="Back to the default picture" xml:space="preserve">
<value>Torna all'immagine predefinita</value>
</data>
<data name="Backup deleted" xml:space="preserve">
<value>Backup eliminato</value>
</data>
<data name="Backup made" xml:space="preserve">
<value>Backup fatto</value>
</data>
<data name="Backup restored" xml:space="preserve">
<value>Backup ripristinato</value>
</data>
<data name="Both are optional: once its end is past, it is no longer shown." xml:space="preserve">
<value>Entrambi facoltativi: passata la fine, non è più mostrato.</value>
</data>
<data name="Bring it back" xml:space="preserve">
<value>Ripristinala</value>
</data>
<data name="Changed {0} ago" xml:space="preserve">
<value>Modificato {0} fa</value>
</data>
<data name="Configured" xml:space="preserve">
<value>Configurato</value>
</data>
<data name="Configured: {0}" xml:space="preserve">
<value>Configurato: {0}</value>
</data>
<data name="Contact" xml:space="preserve">
<value>Contatto</value>
</data>
<data name="Contact address" xml:space="preserve">
<value>Indirizzo di contatto</value>
</data>
<data name="Contact persona" xml:space="preserve">
<value>Persona di contatto</value>
</data>
<data name="Delete the announcement?" xml:space="preserve">
<value>Eliminare l'annuncio?</value>
</data>
<data name="Description (Markdown)" xml:space="preserve">
<value>Descrizione (Markdown)</value>
</data>
<data name="Discard changes" xml:space="preserve">
<value>Annulla le modifiche</value>
</data>
<data name="Dismissed by {0}" xml:space="preserve">
<value>Ignorato da {0}</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Bozza</value>
</data>
<data name="Edit the announcement" xml:space="preserve">
<value>Modifica l'annuncio</value>
</data>
<data name="Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload." xml:space="preserve">
<value>Vuoto, un limite è quello della configurazione del server. Ogni modifica vale subito, dal prossimo caricamento.</value>
</data>
<data name="Empty, the server has no terms. Without a date, new terms take effect today." xml:space="preserve">
<value>Vuoti, il server non ha termini. Senza una data, i nuovi termini valgono da oggi.</value>
</data>
<data name="Ended" xml:space="preserve">
<value>Concluso</value>
</data>
<data name="Everyone reads them on the About page, and a report of a violation names the ones broken." xml:space="preserve">
<value>Chiunque le legge nella pagina Informazioni, e una segnalazione di violazione indica quelle infrante.</value>
</data>
<data name="Explanation" xml:space="preserve">
<value>Spiegazione</value>
</data>
<data name="From {0}" xml:space="preserve">
<value>Dal {0}</value>
</data>
<data name="From {0} to {1}" xml:space="preserve">
<value>Dal {0} al {1}</value>
</data>
<data name="Hide part of its name" xml:space="preserve">
<value>Nascondi parte del nome</value>
</data>
<data name="In effect from" xml:space="preserve">
<value>In vigore dal</value>
</data>
<data name="In effect from {0}" xml:space="preserve">
<value>In vigore dal {0}</value>
</data>
<data name="In effect since {0}" xml:space="preserve">
<value>In vigore dal {0}</value>
</data>
<data name="In force: {0}" xml:space="preserve">
<value>In vigore: {0}</value>
</data>
<data name="It disappears for everyone, with its reactions." xml:space="preserve">
<value>Sparisce per tutti, con le sue reazioni.</value>
</data>
<data name="Its actor: {0}" xml:space="preserve">
<value>Il suo attore: {0}</value>
</data>
<data name="Largest picture" xml:space="preserve">
<value>Immagine più grande</value>
</data>
<data name="Largest video or sound" xml:space="preserve">
<value>Video o audio più grande</value>
</data>
<data name="Last changed on {0}" xml:space="preserve">
<value>Modificato il {0}</value>
</data>
<data name="Limited" xml:space="preserve">
<value>Limitato</value>
</data>
<data name="Login deleted" xml:space="preserve">
<value>Login eliminato</value>
</data>
<data name="Login made" xml:space="preserve">
<value>Login creato</value>
</data>
<data name="Login made administrator" xml:space="preserve">
<value>Login reso amministratore</value>
</data>
<data name="Login no longer administrator" xml:space="preserve">
<value>Login non più amministratore</value>
</data>
<data name="Longest video or sound" xml:space="preserve">
<value>Video o audio più lungo</value>
</data>
<data name="MB" xml:space="preserve">
<value>MB</value>
</data>
<data name="Media a login may keep in all" xml:space="preserve">
<value>Media che un login può tenere in tutto</value>
</data>
<data name="Media limits" xml:space="preserve">
<value>Limiti dei media</value>
</data>
<data name="Moderated servers" xml:space="preserve">
<value>Server moderati</value>
</data>
<data name="Moderation log" xml:space="preserve">
<value>Registro di moderazione</value>
</data>
<data name="Move down" xml:space="preserve">
<value>Sposta giù</value>
</data>
<data name="Move up" xml:space="preserve">
<value>Sposta su</value>
</data>
<data name="Named by the configuration (Federation:Relays): removed only there." xml:space="preserve">
<value>Indicato dalla configurazione (Federation:Relays): si toglie solo lì.</value>
</data>
<data name="New announcement" xml:space="preserve">
<value>Nuovo annuncio</value>
</data>
<data name="Next announcement" xml:space="preserve">
<value>Annuncio successivo</value>
</data>
<data name="Previous announcement" xml:space="preserve">
<value>Annuncio precedente</value>
</data>
<data name="No announcement yet." xml:space="preserve">
<value>Ancora nessun annuncio.</value>
</data>
<data name="No contact persona" xml:space="preserve">
<value>Nessuna persona di contatto</value>
</data>
<data name="No persona of this server matches." xml:space="preserve">
<value>Nessuna persona di questo server corrisponde.</value>
</data>
<data name="None" xml:space="preserve">
<value>Nessuno</value>
</data>
<data name="None: the server names nobody." xml:space="preserve">
<value>Nessuna: il server non indica nessuno.</value>
</data>
<data name="Not asked yet" xml:space="preserve">
<value>Non ancora chiesto</value>
</data>
<data name="Nothing was done yet." xml:space="preserve">
<value>Non è stato ancora fatto nulla.</value>
</data>
<data name="Persona moved to another login" xml:space="preserve">
<value>Persona spostata a un altro login</value>
</data>
<data name="Plain text, as the instance API, NodeInfo and the server's own actor give it." xml:space="preserve">
<value>Testo semplice, come lo danno l'API dell'istanza, NodeInfo e l'attore del server.</value>
</data>
<data name="Privacy policy" xml:space="preserve">
<value>Informativa sulla privacy</value>
</data>
<data name="Privacy policy (Markdown)" xml:space="preserve">
<value>Informativa sulla privacy (Markdown)</value>
</data>
<data name="Publish it" xml:space="preserve">
<value>Pubblicalo</value>
</data>
<data name="Publish the weekly activity" xml:space="preserve">
<value>Pubblica l'attività settimanale</value>
</data>
<data name="Published" xml:space="preserve">
<value>Pubblicato</value>
</data>
<data name="Published, partly hidden" xml:space="preserve">
<value>Pubblicato, in parte nascosto</value>
</data>
<data name="Published: shown to every persona" xml:space="preserve">
<value>Pubblicato: mostrato a ogni persona</value>
</data>
<data name="Read again" xml:space="preserve">
<value>Rileggi</value>
</data>
<data name="Refused by the relay" xml:space="preserve">
<value>Rifiutato dal relay</value>
</data>
<data name="Relay added" xml:space="preserve">
<value>Relay aggiunto</value>
</data>
<data name="Relay removed" xml:space="preserve">
<value>Relay tolto</value>
</data>
<data name="Relays" xml:space="preserve">
<value>Relay</value>
</data>
<data name="Remove the rule" xml:space="preserve">
<value>Togli la regola</value>
</data>
<data name="Remove the thumbnail?" xml:space="preserve">
<value>Togliere l'anteprima?</value>
</data>
<data name="Removed rules, kept as old reports name them" xml:space="preserve">
<value>Regole tolte, conservate perché le vecchie segnalazioni le indicano</value>
</data>
<data name="Removed {0} ago" xml:space="preserve">
<value>Tolta {0} fa</value>
</data>
<data name="Report resolved" xml:space="preserve">
<value>Segnalazione risolta</value>
</data>
<data name="Report: {0}" xml:space="preserve">
<value>Segnalazione: {0}</value>
</data>
<data name="Rule added" xml:space="preserve">
<value>Regola aggiunta</value>
</data>
<data name="Rule changed" xml:space="preserve">
<value>Regola modificata</value>
</data>
<data name="Rule removed" xml:space="preserve">
<value>Regola tolta</value>
</data>
<data name="Rules" xml:space="preserve">
<value>Regole</value>
</data>
<data name="Rules it names" xml:space="preserve">
<value>Regole indicate</value>
</data>
<data name="Save the limits" xml:space="preserve">
<value>Salva i limiti</value>
</data>
<data name="Save the rules" xml:space="preserve">
<value>Salva le regole</value>
</data>
<data name="Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata." xml:space="preserve">
<value>Salvata appena scelta: al massimo {0}, adattata a 1200×630 e privata dei metadati.</value>
</data>
<data name="Saved {0} ago by {1}" xml:space="preserve">
<value>Salvato {0} fa da {1}</value>
</data>
<data name="Search this server's personas" xml:space="preserve">
<value>Cerca le persone di questo server</value>
</data>
<data name="Server blocked" xml:space="preserve">
<value>Server bloccato</value>
</data>
<data name="Server name" xml:space="preserve">
<value>Nome del server</value>
</data>
<data name="Server unblocked" xml:space="preserve">
<value>Server sbloccato</value>
</data>
<data name="Settings saved" xml:space="preserve">
<value>Impostazioni salvate</value>
</data>
<data name="Short description" xml:space="preserve">
<value>Descrizione breve</value>
</data>
<data name="Shown now" xml:space="preserve">
<value>Mostrato ora</value>
</data>
<data name="Sign in to {0}" xml:space="preserve">
<value>Accedi a {0}</value>
</data>
<data name="Sign-ups" xml:space="preserve">
<value>Iscrizioni</value>
</data>
<data name="Subscribed" xml:space="preserve">
<value>Iscritto</value>
</data>
<data name="Terms of service" xml:space="preserve">
<value>Termini di servizio</value>
</data>
<data name="Terms of service (Markdown)" xml:space="preserve">
<value>Termini di servizio (Markdown)</value>
</data>
<data name="The announcement is deleted." xml:space="preserve">
<value>L'annuncio è eliminato.</value>
</data>
<data name="The announcement is saved." xml:space="preserve">
<value>L'annuncio è salvato.</value>
</data>
<data name="The contact persona is shown to everyone as the one who runs this server." xml:space="preserve">
<value>La persona di contatto è mostrata a tutti come chi gestisce questo server.</value>
</data>
<data name="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." xml:space="preserve">
<value>L'indirizzo https dell'attore del relay, o della sua inbox. I post pubblici di questo server gli arrivano, e i suoi arrivano qui.</value>
</data>
<data name="The log could not be read." xml:space="preserve">
<value>Non è stato possibile leggere il registro.</value>
</data>
<data name="The media limits are saved." xml:space="preserve">
<value>I limiti dei media sono salvati.</value>
</data>
<data name="The picture uploaded is deleted, and the server shows its default picture again." xml:space="preserve">
<value>L'immagine caricata viene eliminata, e il server torna a mostrare quella predefinita.</value>
</data>
<data name="The quota can't be below 0." xml:space="preserve">
<value>La quota non può essere sotto 0.</value>
</data>
<data name="The relay is added: the server subscribes to it now." xml:space="preserve">
<value>Il relay è aggiunto: il server ora si iscrive.</value>
</data>
<data name="The relay is removed: the server unsubscribes from it now." xml:space="preserve">
<value>Il relay è tolto: il server ora annulla l'iscrizione.</value>
</data>
<data name="The relay's address" xml:space="preserve">
<value>L'indirizzo del relay</value>
</data>
<data name="The rule" xml:space="preserve">
<value>La regola</value>
</data>
<data name="The rules are saved." xml:space="preserve">
<value>Le regole sono salvate.</value>
</data>
<data name="The server has no rules." xml:space="preserve">
<value>Il server non ha regole.</value>
</data>
<data name="The server subscribes to no relay." xml:space="preserve">
<value>Il server non è iscritto a nessun relay.</value>
</data>
<data name="The server's description could not be read." xml:space="preserve">
<value>Non è stato possibile leggere la descrizione del server.</value>
</data>
<data name="The server's settings are saved." xml:space="preserve">
<value>Le impostazioni del server sono salvate.</value>
</data>
<data name="The server's settings could not be read." xml:space="preserve">
<value>Non è stato possibile leggere le impostazioni del server.</value>
</data>
<data name="This server does not exchange with these, or keeps them out of its public feeds." xml:space="preserve">
<value>Questo server non scambia con questi, o li tiene fuori dai suoi feed pubblici.</value>
</data>
<data name="Thumbnail" xml:space="preserve">
<value>Anteprima</value>
</data>
<data name="Thumbnail changed" xml:space="preserve">
<value>Anteprima cambiata</value>
</data>
<data name="Thumbnail removed." xml:space="preserve">
<value>Anteprima tolta.</value>
</data>
<data name="Thumbnail saved." xml:space="preserve">
<value>Anteprima salvata.</value>
</data>
<data name="Tick the rules it breaks." xml:space="preserve">
<value>Spunta le regole che infrange.</value>
</data>
<data name="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." xml:space="preserve">
<value>Dodici settimane di post locali e di nuovi login, leggibili da chiunque. Su un server piccolo quei conteggi dicono chi si è iscritto e quando si scrive: di norma è spento.</value>
</data>
<data name="Two-factor sign-in reset" xml:space="preserve">
<value>Accesso a due fattori azzerato</value>
</data>
<data name="Until" xml:space="preserve">
<value>Fino al</value>
</data>
<data name="Use the configured values" xml:space="preserve">
<value>Usa i valori configurati</value>
</data>
<data name="Violation" xml:space="preserve">
<value>Viola le regole del server</value>
</data>
<data name="Waiting for the relay" xml:space="preserve">
<value>In attesa del relay</value>
</data>
<data name="What it means, if it needs saying (optional)" xml:space="preserve">
<value>Cosa significa, se serve dirlo (facoltativo)</value>
</data>
<data name="What it says (Markdown)" xml:space="preserve">
<value>Cosa dice (Markdown)</value>
</data>
<data name="Which of the server's rules does it break?" xml:space="preserve">
<value>Quali regole del server infrange?</value>
</data>
<data name="Write an announcement" xml:space="preserve">
<value>Scrivi un annuncio</value>
</data>
<data name="Written {0} ago" xml:space="preserve">
<value>Scritto {0} fa</value>
</data>
<data name="answered {0} ago" xml:space="preserve">
<value>risposto {0} fa</value>
</data>
<data name="by invitation only" xml:space="preserve">
<value>solo su invito</value>
</data>
<data name="by someone not named" xml:space="preserve">
<value>da qualcuno non nominato</value>
</data>
<data name="by the command line" xml:space="preserve">
<value>dalla riga di comando</value>
</data>
<data name="by you" xml:space="preserve">
<value>da te</value>
</data>
<data name="by {0}" xml:space="preserve">
<value>da {0}</value>
</data>
<data name="as configured" xml:space="preserve">
<value>come configurato</value>
</data>
<data name="https://relay.example/actor" xml:space="preserve">
<value>https://relay.example/actor</value>
</data>
<data name="no quota" xml:space="preserve">
<value>nessuna quota</value>
</data>
<data name="open to anyone" xml:space="preserve">
<value>aperte a chiunque</value>
</data>
<data name="seconds" xml:space="preserve">
<value>secondi</value>
</data>
<data name="someone" xml:space="preserve">
<value>qualcuno</value>
</data>
<data name="the command line" xml:space="preserve">
<value>la riga di comando</value>
</data>
</root> </root>
+510 -21
View File
@@ -41,9 +41,6 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="ActivityPub?" xml:space="preserve">
<value>ActivityPub?</value>
</data>
<data name="Administration" xml:space="preserve"> <data name="Administration" xml:space="preserve">
<value>Administration</value> <value>Administration</value>
</data> </data>
@@ -53,9 +50,6 @@
<data name="Audio" xml:space="preserve"> <data name="Audio" xml:space="preserve">
<value>Audio</value> <value>Audio</value>
</data> </data>
<data name="Authentication" xml:space="preserve">
<value>Authentication</value>
</data>
<data name="Block" xml:space="preserve"> <data name="Block" xml:space="preserve">
<value>Block</value> <value>Block</value>
</data> </data>
@@ -83,9 +77,6 @@
<data name="Direct" xml:space="preserve"> <data name="Direct" xml:space="preserve">
<value>Direct</value> <value>Direct</value>
</data> </data>
<data name="Emoji?" xml:space="preserve">
<value>Emoji?</value>
</data>
<data name="Empty" xml:space="preserve"> <data name="Empty" xml:space="preserve">
<value>Empty</value> <value>Empty</value>
</data> </data>
@@ -101,9 +92,6 @@
<data name="FollowersOnly" xml:space="preserve"> <data name="FollowersOnly" xml:space="preserve">
<value>Followers only</value> <value>Followers only</value>
</data> </data>
<data name="Frontend" xml:space="preserve">
<value>Frontend</value>
</data>
<data name="General" xml:space="preserve"> <data name="General" xml:space="preserve">
<value>General</value> <value>General</value>
</data> </data>
@@ -125,9 +113,6 @@
<data name="Light theme colour" xml:space="preserve"> <data name="Light theme colour" xml:space="preserve">
<value>Light theme colour</value> <value>Light theme colour</value>
</data> </data>
<data name="Link formatter" xml:space="preserve">
<value>Link formatter</value>
</data>
<data name="loading" xml:space="preserve"> <data name="loading" xml:space="preserve">
<value>loading</value> <value>loading</value>
</data> </data>
@@ -149,9 +134,6 @@
<data name="Message scope type: {0}" xml:space="preserve"> <data name="Message scope type: {0}" xml:space="preserve">
<value>Message scope type: {0}</value> <value>Message scope type: {0}</value>
</data> </data>
<data name="Metadata" xml:space="preserve">
<value>Metadata</value>
</data>
<data name="Missing content, either message or media" xml:space="preserve"> <data name="Missing content, either message or media" xml:space="preserve">
<value>Missing content, either message or media</value> <value>Missing content, either message or media</value>
</data> </data>
@@ -167,9 +149,6 @@
<data name="Other" xml:space="preserve"> <data name="Other" xml:space="preserve">
<value>Other</value> <value>Other</value>
</data> </data>
<data name="Policies" xml:space="preserve">
<value>Policies</value>
</data>
<data name="Post" xml:space="preserve"> <data name="Post" xml:space="preserve">
<value>Post</value> <value>Post</value>
</data> </data>
@@ -2597,4 +2576,514 @@
<data name="written / dropped / failed" xml:space="preserve"> <data name="written / dropped / failed" xml:space="preserve">
<value>written / dropped / failed</value> <value>written / dropped / failed</value>
</data> </data>
<data name="&quot;{0}&quot; is not a date." xml:space="preserve">
<value>"{0}" is not a date.</value>
</data>
<data name="(set in the server's configuration, Registrations:Mode)" xml:space="preserve">
<value>(set in the server's configuration, Registrations:Mode)</value>
</data>
<data name="0: no quota" xml:space="preserve">
<value>0: no quota</value>
</data>
<data name="A length goes from 1 second to {0}." xml:space="preserve">
<value>A length goes from 1 second to {0}.</value>
</data>
<data name="A picture's limit goes from 1 MB to {0}." xml:space="preserve">
<value>A picture's limit goes from 1 MB to {0}.</value>
</data>
<data name="A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it." xml:space="preserve">
<value>A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it.</value>
</data>
<data name="A rule no longer kept" xml:space="preserve">
<value>A rule no longer kept</value>
</data>
<data name="A server has {0} rules at most." xml:space="preserve">
<value>A server has {0} rules at most.</value>
</data>
<data name="A video's limit goes from 1 MB to {0}." xml:space="preserve">
<value>A video's limit goes from 1 MB to {0}.</value>
</data>
<data name="About this server" xml:space="preserve">
<value>About this server</value>
</data>
<data name="About {0}" xml:space="preserve">
<value>About {0}</value>
</data>
<data name="Add a rule" xml:space="preserve">
<value>Add a rule</value>
</data>
<data name="Add the relay" xml:space="preserve">
<value>Add the relay</value>
</data>
<data name="All day" xml:space="preserve">
<value>All day</value>
</data>
<data name="An announcement can't end before it starts." xml:space="preserve">
<value>An announcement can't end before it starts.</value>
</data>
<data name="Announcement" xml:space="preserve">
<value>Announcement</value>
</data>
<data name="Announcement changed" xml:space="preserve">
<value>Announcement changed</value>
</data>
<data name="Announcement deleted" xml:space="preserve">
<value>Announcement deleted</value>
</data>
<data name="Announcement written" xml:space="preserve">
<value>Announcement written</value>
</data>
<data name="Announcements" xml:space="preserve">
<value>Announcements</value>
</data>
<data name="Asked {0} ago" xml:space="preserve">
<value>Asked {0} ago</value>
</data>
<data name="At most {0}" xml:space="preserve">
<value>At most {0}</value>
</data>
<data name="Back to the default picture" xml:space="preserve">
<value>Back to the default picture</value>
</data>
<data name="Backup deleted" xml:space="preserve">
<value>Backup deleted</value>
</data>
<data name="Backup made" xml:space="preserve">
<value>Backup made</value>
</data>
<data name="Backup restored" xml:space="preserve">
<value>Backup restored</value>
</data>
<data name="Both are optional: once its end is past, it is no longer shown." xml:space="preserve">
<value>Both are optional: once its end is past, it is no longer shown.</value>
</data>
<data name="Bring it back" xml:space="preserve">
<value>Bring it back</value>
</data>
<data name="Changed {0} ago" xml:space="preserve">
<value>Changed {0} ago</value>
</data>
<data name="Configured" xml:space="preserve">
<value>Configured</value>
</data>
<data name="Configured: {0}" xml:space="preserve">
<value>Configured: {0}</value>
</data>
<data name="Contact" xml:space="preserve">
<value>Contact</value>
</data>
<data name="Contact address" xml:space="preserve">
<value>Contact address</value>
</data>
<data name="Contact persona" xml:space="preserve">
<value>Contact persona</value>
</data>
<data name="Delete the announcement?" xml:space="preserve">
<value>Delete the announcement?</value>
</data>
<data name="Description (Markdown)" xml:space="preserve">
<value>Description (Markdown)</value>
</data>
<data name="Discard changes" xml:space="preserve">
<value>Discard changes</value>
</data>
<data name="Dismissed by {0}" xml:space="preserve">
<value>Dismissed by {0}</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Draft</value>
</data>
<data name="Edit the announcement" xml:space="preserve">
<value>Edit the announcement</value>
</data>
<data name="Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload." xml:space="preserve">
<value>Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload.</value>
</data>
<data name="Empty, the server has no terms. Without a date, new terms take effect today." xml:space="preserve">
<value>Empty, the server has no terms. Without a date, new terms take effect today.</value>
</data>
<data name="Ended" xml:space="preserve">
<value>Ended</value>
</data>
<data name="Everyone reads them on the About page, and a report of a violation names the ones broken." xml:space="preserve">
<value>Everyone reads them on the About page, and a report of a violation names the ones broken.</value>
</data>
<data name="Explanation" xml:space="preserve">
<value>Explanation</value>
</data>
<data name="From {0}" xml:space="preserve">
<value>From {0}</value>
</data>
<data name="From {0} to {1}" xml:space="preserve">
<value>From {0} to {1}</value>
</data>
<data name="Hide part of its name" xml:space="preserve">
<value>Hide part of its name</value>
</data>
<data name="In effect from" xml:space="preserve">
<value>In effect from</value>
</data>
<data name="In effect from {0}" xml:space="preserve">
<value>In effect from {0}</value>
</data>
<data name="In effect since {0}" xml:space="preserve">
<value>In effect since {0}</value>
</data>
<data name="In force: {0}" xml:space="preserve">
<value>In force: {0}</value>
</data>
<data name="It disappears for everyone, with its reactions." xml:space="preserve">
<value>It disappears for everyone, with its reactions.</value>
</data>
<data name="Its actor: {0}" xml:space="preserve">
<value>Its actor: {0}</value>
</data>
<data name="Largest picture" xml:space="preserve">
<value>Largest picture</value>
</data>
<data name="Largest video or sound" xml:space="preserve">
<value>Largest video or sound</value>
</data>
<data name="Last changed on {0}" xml:space="preserve">
<value>Last changed on {0}</value>
</data>
<data name="Limited" xml:space="preserve">
<value>Limited</value>
</data>
<data name="Login deleted" xml:space="preserve">
<value>Login deleted</value>
</data>
<data name="Login made" xml:space="preserve">
<value>Login made</value>
</data>
<data name="Login made administrator" xml:space="preserve">
<value>Login made administrator</value>
</data>
<data name="Login no longer administrator" xml:space="preserve">
<value>Login no longer administrator</value>
</data>
<data name="Longest video or sound" xml:space="preserve">
<value>Longest video or sound</value>
</data>
<data name="MB" xml:space="preserve">
<value>MB</value>
</data>
<data name="Media a login may keep in all" xml:space="preserve">
<value>Media a login may keep in all</value>
</data>
<data name="Media limits" xml:space="preserve">
<value>Media limits</value>
</data>
<data name="Moderated servers" xml:space="preserve">
<value>Moderated servers</value>
</data>
<data name="Moderation log" xml:space="preserve">
<value>Moderation log</value>
</data>
<data name="Move down" xml:space="preserve">
<value>Move down</value>
</data>
<data name="Move up" xml:space="preserve">
<value>Move up</value>
</data>
<data name="Named by the configuration (Federation:Relays): removed only there." xml:space="preserve">
<value>Named by the configuration (Federation:Relays): removed only there.</value>
</data>
<data name="New announcement" xml:space="preserve">
<value>New announcement</value>
</data>
<data name="Next announcement" xml:space="preserve">
<value>Next announcement</value>
</data>
<data name="Previous announcement" xml:space="preserve">
<value>Previous announcement</value>
</data>
<data name="No announcement yet." xml:space="preserve">
<value>No announcement yet.</value>
</data>
<data name="No contact persona" xml:space="preserve">
<value>No contact persona</value>
</data>
<data name="No persona of this server matches." xml:space="preserve">
<value>No persona of this server matches.</value>
</data>
<data name="None" xml:space="preserve">
<value>None</value>
</data>
<data name="None: the server names nobody." xml:space="preserve">
<value>None: the server names nobody.</value>
</data>
<data name="Not asked yet" xml:space="preserve">
<value>Not asked yet</value>
</data>
<data name="Nothing was done yet." xml:space="preserve">
<value>Nothing was done yet.</value>
</data>
<data name="Persona moved to another login" xml:space="preserve">
<value>Persona moved to another login</value>
</data>
<data name="Plain text, as the instance API, NodeInfo and the server's own actor give it." xml:space="preserve">
<value>Plain text, as the instance API, NodeInfo and the server's own actor give it.</value>
</data>
<data name="Privacy policy" xml:space="preserve">
<value>Privacy policy</value>
</data>
<data name="Privacy policy (Markdown)" xml:space="preserve">
<value>Privacy policy (Markdown)</value>
</data>
<data name="Publish it" xml:space="preserve">
<value>Publish it</value>
</data>
<data name="Publish the weekly activity" xml:space="preserve">
<value>Publish the weekly activity</value>
</data>
<data name="Published" xml:space="preserve">
<value>Published</value>
</data>
<data name="Published, partly hidden" xml:space="preserve">
<value>Published, partly hidden</value>
</data>
<data name="Published: shown to every persona" xml:space="preserve">
<value>Published: shown to every persona</value>
</data>
<data name="Read again" xml:space="preserve">
<value>Read again</value>
</data>
<data name="Refused by the relay" xml:space="preserve">
<value>Refused by the relay</value>
</data>
<data name="Relay added" xml:space="preserve">
<value>Relay added</value>
</data>
<data name="Relay removed" xml:space="preserve">
<value>Relay removed</value>
</data>
<data name="Relays" xml:space="preserve">
<value>Relays</value>
</data>
<data name="Remove the rule" xml:space="preserve">
<value>Remove the rule</value>
</data>
<data name="Remove the thumbnail?" xml:space="preserve">
<value>Remove the thumbnail?</value>
</data>
<data name="Removed rules, kept as old reports name them" xml:space="preserve">
<value>Removed rules, kept as old reports name them</value>
</data>
<data name="Removed {0} ago" xml:space="preserve">
<value>Removed {0} ago</value>
</data>
<data name="Report resolved" xml:space="preserve">
<value>Report resolved</value>
</data>
<data name="Report: {0}" xml:space="preserve">
<value>Report: {0}</value>
</data>
<data name="Rule added" xml:space="preserve">
<value>Rule added</value>
</data>
<data name="Rule changed" xml:space="preserve">
<value>Rule changed</value>
</data>
<data name="Rule removed" xml:space="preserve">
<value>Rule removed</value>
</data>
<data name="Rules" xml:space="preserve">
<value>Rules</value>
</data>
<data name="Rules it names" xml:space="preserve">
<value>Rules it names</value>
</data>
<data name="Save the limits" xml:space="preserve">
<value>Save the limits</value>
</data>
<data name="Save the rules" xml:space="preserve">
<value>Save the rules</value>
</data>
<data name="Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata." xml:space="preserve">
<value>Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.</value>
</data>
<data name="Saved {0} ago by {1}" xml:space="preserve">
<value>Saved {0} ago by {1}</value>
</data>
<data name="Search this server's personas" xml:space="preserve">
<value>Search this server's personas</value>
</data>
<data name="Server blocked" xml:space="preserve">
<value>Server blocked</value>
</data>
<data name="Server name" xml:space="preserve">
<value>Server name</value>
</data>
<data name="Server unblocked" xml:space="preserve">
<value>Server unblocked</value>
</data>
<data name="Settings saved" xml:space="preserve">
<value>Settings saved</value>
</data>
<data name="Short description" xml:space="preserve">
<value>Short description</value>
</data>
<data name="Shown now" xml:space="preserve">
<value>Shown now</value>
</data>
<data name="Sign in to {0}" xml:space="preserve">
<value>Sign in to {0}</value>
</data>
<data name="Sign-ups" xml:space="preserve">
<value>Sign-ups</value>
</data>
<data name="Subscribed" xml:space="preserve">
<value>Subscribed</value>
</data>
<data name="Terms of service" xml:space="preserve">
<value>Terms of service</value>
</data>
<data name="Terms of service (Markdown)" xml:space="preserve">
<value>Terms of service (Markdown)</value>
</data>
<data name="The announcement is deleted." xml:space="preserve">
<value>The announcement is deleted.</value>
</data>
<data name="The announcement is saved." xml:space="preserve">
<value>The announcement is saved.</value>
</data>
<data name="The contact persona is shown to everyone as the one who runs this server." xml:space="preserve">
<value>The contact persona is shown to everyone as the one who runs this server.</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="The log could not be read." xml:space="preserve">
<value>The log could not be read.</value>
</data>
<data name="The media limits are saved." xml:space="preserve">
<value>The media limits are saved.</value>
</data>
<data name="The picture uploaded is deleted, and the server shows its default picture again." xml:space="preserve">
<value>The picture uploaded is deleted, and the server shows its default picture again.</value>
</data>
<data name="The quota can't be below 0." xml:space="preserve">
<value>The quota can't be below 0.</value>
</data>
<data name="The relay is added: the server subscribes to it now." xml:space="preserve">
<value>The relay is added: the server subscribes to it now.</value>
</data>
<data name="The relay is removed: the server unsubscribes from it now." xml:space="preserve">
<value>The relay is removed: the server unsubscribes from it now.</value>
</data>
<data name="The relay's address" xml:space="preserve">
<value>The relay's address</value>
</data>
<data name="The rule" xml:space="preserve">
<value>The rule</value>
</data>
<data name="The rules are saved." xml:space="preserve">
<value>The rules are saved.</value>
</data>
<data name="The server has no rules." xml:space="preserve">
<value>The server has no rules.</value>
</data>
<data name="The server subscribes to no relay." xml:space="preserve">
<value>The server subscribes to no relay.</value>
</data>
<data name="The server's description could not be read." xml:space="preserve">
<value>The server's description could not be read.</value>
</data>
<data name="The server's settings are saved." xml:space="preserve">
<value>The server's settings are saved.</value>
</data>
<data name="The server's settings could not be read." xml:space="preserve">
<value>The server's settings could not be read.</value>
</data>
<data name="This server does not exchange with these, or keeps them out of its public feeds." xml:space="preserve">
<value>This server does not exchange with these, or keeps them out of its public feeds.</value>
</data>
<data name="Thumbnail" xml:space="preserve">
<value>Thumbnail</value>
</data>
<data name="Thumbnail changed" xml:space="preserve">
<value>Thumbnail changed</value>
</data>
<data name="Thumbnail removed." xml:space="preserve">
<value>Thumbnail removed.</value>
</data>
<data name="Thumbnail saved." xml:space="preserve">
<value>Thumbnail saved.</value>
</data>
<data name="Tick the rules it breaks." xml:space="preserve">
<value>Tick the rules it breaks.</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="Two-factor sign-in reset" xml:space="preserve">
<value>Two-factor sign-in reset</value>
</data>
<data name="Until" xml:space="preserve">
<value>Until</value>
</data>
<data name="Use the configured values" xml:space="preserve">
<value>Use the configured values</value>
</data>
<data name="Violation" xml:space="preserve">
<value>Breaks the server's rules</value>
</data>
<data name="Waiting for the relay" xml:space="preserve">
<value>Waiting for the relay</value>
</data>
<data name="What it means, if it needs saying (optional)" xml:space="preserve">
<value>What it means, if it needs saying (optional)</value>
</data>
<data name="What it says (Markdown)" xml:space="preserve">
<value>What it says (Markdown)</value>
</data>
<data name="Which of the server's rules does it break?" xml:space="preserve">
<value>Which of the server's rules does it break?</value>
</data>
<data name="Write an announcement" xml:space="preserve">
<value>Write an announcement</value>
</data>
<data name="Written {0} ago" xml:space="preserve">
<value>Written {0} ago</value>
</data>
<data name="answered {0} ago" xml:space="preserve">
<value>answered {0} ago</value>
</data>
<data name="by invitation only" xml:space="preserve">
<value>by invitation only</value>
</data>
<data name="by someone not named" xml:space="preserve">
<value>by someone not named</value>
</data>
<data name="by the command line" xml:space="preserve">
<value>by the command line</value>
</data>
<data name="by you" xml:space="preserve">
<value>by you</value>
</data>
<data name="by {0}" xml:space="preserve">
<value>by {0}</value>
</data>
<data name="as configured" xml:space="preserve">
<value>as configured</value>
</data>
<data name="https://relay.example/actor" xml:space="preserve">
<value>https://relay.example/actor</value>
</data>
<data name="no quota" xml:space="preserve">
<value>no quota</value>
</data>
<data name="open to anyone" xml:space="preserve">
<value>open to anyone</value>
</data>
<data name="seconds" xml:space="preserve">
<value>seconds</value>
</data>
<data name="someone" xml:space="preserve">
<value>someone</value>
</data>
<data name="the command line" xml:space="preserve">
<value>the command line</value>
</data>
</root> </root>
+96
View File
@@ -0,0 +1,96 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// What this server says of itself to anyone, through the Mastodon instance API, never with credentials: its title,
/// descriptions, contact and rules (/api/v2/instance, read once a session and forgotten when the administrator saves),
/// its long description, privacy policy and terms of service (sanitized HTML from the administrator's Markdown), and
/// the domain blocks it publishes. Values come back null and lists empty on failure; nothing throws.
/// </summary>
public class AboutService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
InstanceV2 instance;
/// <summary>The instance's description, kept for the session; read again when `fresh`.</summary>
public async Task<InstanceV2> Instance(bool fresh = false, CancellationToken cancellationToken = default)
{
if (instance is not null && !fresh)
return instance;
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Instance), cancellationToken);
return instance;
}
return instance = await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) ?? instance;
}
/// <summary>Drops what was read, after the administrator changed it.</summary>
public void Forget() => instance = null;
/// <summary>The server's rules in their order (not the ones its administrator removed).</summary>
public async Task<List<InstanceRule>> Rules(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceRules, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<InstanceRule>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Rules), cancellationToken);
return [];
}
public Task<InstanceDocument> ExtendedDescription(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstanceExtendedDescription, nameof(ExtendedDescription), cancellationToken);
public Task<InstanceDocument> PrivacyPolicy(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstancePrivacyPolicy, nameof(PrivacyPolicy), cancellationToken);
/// <summary>The terms of service, or null while the server has none (its 404).</summary>
public async Task<TermsOfService> Terms(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceTermsOfService, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<TermsOfService>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Terms), cancellationToken);
return null;
}
/// <summary>The blocks the administrator published, as the server shows them (an obfuscated one partly starred).</summary>
public async Task<List<PublicDomainBlock>> DomainBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceDomainBlocks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<PublicDomainBlock>>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
async Task<InstanceDocument> Document(string route, string caller, CancellationToken cancellationToken)
{
using var response = await httpService.GetTotallyAnon(route, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<InstanceDocument>(cancellationToken);
await LogFailure(response, caller, cancellationToken);
return null;
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AboutService)}.{caller}", cancellationToken);
}
}
}
+6 -4
View File
@@ -286,10 +286,11 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
} }
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server /// <summary>Reports an account, with the posts named, to the persona's server's moderators (a violation with the
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary> /// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward, /// the persona.</summary>
CancellationToken cancellationToken = default) public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, List<string> ruleIds,
bool forward, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
{ {
@@ -297,6 +298,7 @@ namespace decePubClient.Services
StatusIds = statusIds, StatusIds = statusIds,
Comment = comment?.Trim(), Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(), Category = category.ToString().ToLowerInvariant(),
RuleIds = category is ReportCategory.Violation ? ruleIds : [],
Forward = forward Forward = forward
}, cancellationToken: cancellationToken); }, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+20 -4
View File
@@ -9,10 +9,11 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary> /// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked /// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
/// for the whole instance, the federation statistics (the overview, the servers, one server's days and events, the /// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
/// ledger's events, the server's own days) and the backups (/clientapi/admin). PrivaPub checks the root's role on every /// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings,
/// call. Lists come back empty and values null on failure; nothing throws. /// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back
/// empty and values null on failure; nothing throws.
/// </summary> /// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{ {
@@ -34,6 +35,21 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
} }
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (before is { Length: > 0 })
parameters.Add($"before={Uri.EscapeDataString(before)}");
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorLog, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminActionsPage>(cancellationToken);
await LogFailure(response, nameof(Log), cancellationToken);
return null;
}
/// <summary>The servers blocked for everyone here, by name.</summary> /// <summary>The servers blocked for everyone here, by name.</summary>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default) public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{ {
+48
View File
@@ -0,0 +1,48 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now,
/// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and
/// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones
/// (StreamingService.Announced). The administrators write them through InstanceAdminService.
/// </summary>
public class AnnouncementService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The announcements shown now, with whether the persona dismissed each.</summary>
public async Task<List<Announcement>> List(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Announcements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Announcement>>(cancellationToken) ?? [];
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AnnouncementService)}.{nameof(List)}", cancellationToken);
return [];
}
public async Task<WebResult> Dismiss(string announcementId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissAnnouncement(announcementId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422).</summary>
public async Task<WebResult> React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default)
{
var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji);
using var response = add
? await httpService.Put(route, cancellationToken: cancellationToken)
: await httpService.Delete(route, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
}
}
+178
View File
@@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http.Headers;
using decePubClient.Extensions;
using decePubClient.Models;
using Microsoft.AspNetCore.Components.Forms;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// The server's own settings from the administrator's page (PrivaPub's InstanceAdminController, /clientapi/admin): what
/// it says of itself, its rules, its thumbnail, its media limits, whether its weekly activity is published, its
/// announcements and the relays it subscribes to. PrivaPub checks the administrator against its database on every call.
/// A save replaces everything the form carries (a rule left out is removed), so SaveSettings always starts from the
/// settings as PrivaPub has them now and changes only what the caller changes. Lists come back empty and values null on
/// failure; nothing throws.
/// </summary>
public class InstanceAdminService(IHttpService httpService, AboutService aboutService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The settings as the page shows them, deleted rules too; null on failure.</summary>
public async Task<ViewServerSettings> Settings(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken);
await LogFailure(response, nameof(Settings), cancellationToken);
return null;
}
/// <summary>The form that saves the settings exactly as they are: the rules not deleted in their order, the media
/// overrides, every text.</summary>
public static ServerSettingsForm FormOf(ViewServerSettings settings) => new()
{
Title = settings.Title,
ShortDescription = settings.ShortDescription,
Description = settings.Description,
ContactEmail = settings.ContactEmail,
ContactAccountId = settings.ContactAccountId,
PrivacyPolicy = settings.PrivacyPolicy,
TermsOfService = settings.TermsOfService,
TermsEffectiveAt = settings.TermsEffectiveAt,
Rules = [.. settings.Rules.Where(r => !r.IsDeleted).OrderBy(r => r.Position).Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text, Hint = r.Hint })],
Media = new()
{
QuotaBytesPerRoot = settings.Media.QuotaBytesPerRoot,
MaxImageBytes = settings.Media.MaxImageBytes,
MaxVideoBytes = settings.Media.MaxVideoBytes,
MaxSeconds = settings.Media.MaxSeconds
},
PublishActivity = settings.PublishActivity
};
/// <summary>
/// Reads the settings as PrivaPub has them now, lets `change` edit the form made of them, and saves the whole form;
/// Data is the ViewServerSettings saved. What another panel or another administrator saved meanwhile stays.
/// </summary>
public async Task<WebResult> SaveSettings(Action<ServerSettingsForm> change, CancellationToken cancellationToken = default)
{
using var current = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
if (!current.IsSuccessStatusCode)
return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken);
if (await current.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) is not { } latest)
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
var form = FormOf(latest);
change(form);
using var response = await httpService.Post(APIs.ClientApi.POST_AdminSettings, form, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>A new thumbnail, streamed from the browser's file; PrivaPub checks it, strips it and fits it within
/// 1200×630. Data is the ViewServerSettings.</summary>
public async Task<WebResult> SaveThumbnail(IBrowserFile file, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminThumbnail, (Func<HttpContent>)(() =>
{
var picture = new StreamContent(file.OpenReadStream(maximumSize, cancellationToken));
picture.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
return new MultipartFormDataContent { { picture, "file", file.Name } };
}), cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>Back to the default picture; the uploaded one is trashed. Data is the ViewServerSettings.</summary>
public async Task<WebResult> RemoveThumbnail(CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminThumbnail, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>Every announcement, published or not, with its reads and reactions.</summary>
public async Task<List<ViewAnnouncement>> Announcements(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminAnnouncements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewAnnouncement>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Announcements), cancellationToken);
return [];
}
/// <summary>Writes a new announcement (no Id) or changes one; Data is the ViewAnnouncement.</summary>
public async Task<WebResult> SaveAnnouncement(AnnouncementForm form, CancellationToken cancellationToken = default)
{
var route = form.Id is { Length: > 0 } ? APIs.ClientApi.POST_AdminAnnouncementUpdate : APIs.ClientApi.POST_AdminAnnouncementInsert;
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAnnouncement>(cancellationToken) };
}
/// <summary>Deletes an announcement, its reads and reactions with it; every persona's stream hears it go.</summary>
public async Task<WebResult> DeleteAnnouncement(string id, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminAnnouncementDelete(id), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The relays the server subscribes to: the configuration's, then the settings'.</summary>
public async Task<List<ViewRelay>> Relays(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRelays, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Relays), cancellationToken);
return [];
}
/// <summary>Subscribes to a relay by its actor's or inbox's https address. PrivaPub answers 202 and subscribes in the
/// background, so the list it answers with (Data) shows the relay before its state does; read it again shortly.</summary>
public async Task<WebResult> AddRelay(string address, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminRelayInsert, new RelayForm { Address = address?.Trim() },
cancellationToken: cancellationToken);
return await RelayList(response, cancellationToken);
}
/// <summary>Unsubscribes from a relay the settings named (a configured one is refused with 409); answered 202 like
/// AddRelay.</summary>
public async Task<WebResult> RemoveRelay(string address, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRelayDelete(address), cancellationToken: cancellationToken);
return await RelayList(response, cancellationToken);
}
async Task<WebResult> Saved(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
// the title and the rules everyone reads have changed
aboutService.Forget();
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) };
}
async Task<WebResult> RelayList(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [] };
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(InstanceAdminService)}.{caller}", cancellationToken);
}
}
}
+66 -6
View File
@@ -16,24 +16,37 @@ namespace decePubClient.Services
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary> /// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default); public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
/// <summary>What the user stream told of the administrators' announcements: one new or changed (announcement), one
/// gone (announcement.delete, its id), or a reaction's count now (announcement.reaction).</summary>
public sealed record AnnouncementMessage(string Event, Announcement Announcement = default, string DeletedId = default,
AnnouncementReactionEvent Reaction = default);
/// <summary> /// <summary>
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the /// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as /// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the /// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
/// page to poll meanwhile. Nothing throws. /// page to poll meanwhile. Nothing throws.
/// The announcements come on the user stream, which HearAnnouncements keeps subscribed whatever feed is followed; what
/// else it tells is raised only while the feed followed is the user stream itself.
/// </summary> /// </summary>
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration, public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
ILoggingService logger) : IAsyncDisposable ILoggingService logger) : IAsyncDisposable
{ {
const string UserStream = "user";
ClientWebSocket socket; ClientWebSocket socket;
CancellationTokenSource running; CancellationTokenSource running;
StreamSubscription current; StreamSubscription current;
bool announcements;
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null; public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
/// <summary>Raised for each message of the stream subscribed now.</summary> /// <summary>Raised for each message of the stream subscribed now.</summary>
public event Func<StreamMessage, Task> Received; public event Func<StreamMessage, Task> Received;
/// <summary>Raised for each announcement event, while HearAnnouncements is on.</summary>
public event Func<AnnouncementMessage, Task> Announced;
Uri Endpoint Uri Endpoint
{ {
get get
@@ -47,23 +60,49 @@ namespace decePubClient.Services
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary> /// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default) public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
{ {
if (subscription == current && (running is not null || subscription is null)) if (subscription == current && (running is not null || subscription is null && !announcements))
return; return;
var previous = current; var previous = current;
current = subscription; current = subscription;
if (socket?.State == WebSocketState.Open) if (socket?.State == WebSocketState.Open)
{ {
if (previous is not null) // the user stream stays while the announcements are heard on it
if (previous is not null && !(announcements && previous.Stream == UserStream))
await Say("unsubscribe", previous, cancellationToken); await Say("unsubscribe", previous, cancellationToken);
if (subscription is not null) if (subscription is not null)
await Say("subscribe", subscription, cancellationToken); await Say("subscribe", subscription, cancellationToken);
return; return;
} }
if (subscription is not null && running is null) Start();
}
/// <summary>Hears the announcements (the user stream) or stops hearing them; the socket opens for them alone when no
/// feed is followed.</summary>
public async Task HearAnnouncements(bool hear, CancellationToken cancellationToken = default)
{
if (hear == announcements)
{ {
running = new(); if (hear)
_ = Run(running.Token); Start();
return;
} }
announcements = hear;
if (socket?.State == WebSocketState.Open)
{
if (current?.Stream != UserStream)
await Say(hear ? "subscribe" : "unsubscribe", new StreamSubscription(UserStream), cancellationToken);
return;
}
if (hear)
Start();
}
void Start()
{
if (running is not null || current is null && !announcements)
return;
running = new();
_ = Run(running.Token);
} }
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken) async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
@@ -132,6 +171,8 @@ namespace decePubClient.Services
await socket.ConnectAsync(Endpoint, cancellationToken); await socket.ConnectAsync(Endpoint, cancellationToken);
if (current is not null) if (current is not null)
await Say("subscribe", current, cancellationToken); await Say("subscribe", current, cancellationToken);
if (announcements && current?.Stream != UserStream)
await Say("subscribe", new StreamSubscription(UserStream), cancellationToken);
} }
async Task Read(CancellationToken cancellationToken) async Task Read(CancellationToken cancellationToken)
@@ -152,6 +193,24 @@ namespace decePubClient.Services
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|| message["payload"]?.GetValue<string>() is not { } payload) || message["payload"]?.GetValue<string>() is not { } payload)
continue; continue;
if (name.StartsWith("announcement", StringComparison.Ordinal))
{
var announced = name switch
{
"announcement" => new AnnouncementMessage(name, Announcement: JsonSerializer.Deserialize<Announcement>(payload, SUtility.MastodonSerializer)),
"announcement.delete" => new AnnouncementMessage(name, DeletedId: payload),
"announcement.reaction" => new AnnouncementMessage(name,
Reaction: JsonSerializer.Deserialize<AnnouncementReactionEvent>(payload, SUtility.MastodonSerializer)),
_ => null
};
if (announced is not null && announcements && Announced is { } listeners)
await listeners(announced);
continue;
}
// the user stream held for the announcements tells the home feed's posts and notifications too: they are the
// feed's only while it is the one followed
if ((message["stream"] as JsonArray)?.FirstOrDefault()?.GetValue<string>() == UserStream && current?.Stream != UserStream)
continue;
var told = name switch var told = name switch
{ {
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)), "update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
@@ -164,7 +223,8 @@ namespace decePubClient.Services
} }
} }
/// <summary>Closes the socket; the page polls from then on.</summary> /// <summary>Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens
/// the socket for them too.</summary>
public async Task Stop() public async Task Stop()
{ {
current = null; current = null;
+4
View File
@@ -81,6 +81,10 @@
<i class="ion-md-settings"></i> <i class="ion-md-settings"></i>
<span>@Localizer["Settings"]</span> <span>@Localizer["Settings"]</span>
</NavLink> </NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="about" data-nav-about>
<i class="ion-md-information-circle"></i>
<span>@Localizer["About this server"]</span>
</NavLink>
<AuthorizeView Policy="@Policies.IsModerator"> <AuthorizeView Policy="@Policies.IsModerator">
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
<i class="ion-md-switch text-error"></i> <i class="ion-md-switch text-error"></i>
+38
View File
@@ -62,6 +62,44 @@
padding-inline-start: 1.25rem; padding-inline-start: 1.25rem;
} }
/* the server's own documents (About, announcements), rendered from its administrator's Markdown: headings and
lists spaced like a page, which a post never needs */
.doc-content h1,
.doc-content h2,
.doc-content h3,
.doc-content h4 {
font-weight: 700;
line-height: 1.25;
margin-block: 1em 0.5em;
}
.doc-content h1 {
font-size: 1.35em;
}
.doc-content h2 {
font-size: 1.2em;
}
.doc-content h3,
.doc-content h4 {
font-size: 1.05em;
}
.doc-content > :first-child {
margin-top: 0;
}
.doc-content ul,
.doc-content ol {
margin-block: 0 0.5em;
}
.doc-content hr {
margin-block: 1em;
border-color: var(--neo-strong);
}
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI /* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI
only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen
it never shows: a tap opens the rows in a sheet instead (NerdTip). */ it never shows: a tap opens the rows in a sheet instead (NerdTip). */