R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s

Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it
follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice.
A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a
filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with
"describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting.
MediaType.Documents goes.

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:02:20 +02:00
1 parent b731d8c8c0
commit b8d7858acb
25 files changed
+1543 -61

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
@inherits LocalizableComponentBase
@* The persona's own followers or the accounts it follows (/api/v1/accounts/:id/followers, /following), 40 a page by the
Link header, each opening its profile. PrivaPub lists them to the persona alone (hide_collections), so only the
persona's own profile opens this. A follower can be removed (remove_from_followers), asked first. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-account-list="@(Followers ? "followers" : "following")">
<Heading><span class="capitalize">@(Followers ? Localizer["followers"] : Localizer["following"])</span></Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (accounts.Count == 0)
{
<p class="text-sm opacity-70 mt-3" data-account-list-empty>@Localizer["Nobody yet."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var account in accounts)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-account="@account.Acct">
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 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 (Followers)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => RemoveFollower(account)"
data-follower-remove>
<i class="ion-md-person text-error" aria-hidden="true"></i><span>@Localizer["Remove from followers"]</span>
</DButton>
}
</div>
}
</div>
@if (maxId is not null)
{
<div class="flex justify-center mt-3">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-account-list-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
</div>
}
</ChildContent>
</DModal>
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>The persona's own account id.</summary>
[Parameter, EditorRequired] public string AccountId { get; set; }
/// <summary>Its followers, rather than the accounts it follows.</summary>
[Parameter] public bool Followers { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>A follower was removed.</summary>
[Parameter] public EventCallback<Account> OnRemoved { get; set; }
List<Account> accounts = [];
HashSet<string> busy = [];
string maxId;
bool isLoadingMore;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
accounts = [];
maxId = null;
await LoadPage();
}
async Task LoadPage()
{
var result = Followers ? await AccountService.Followers(AccountId, maxId) : await AccountService.Following(AccountId, maxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
var known = accounts.Select(a => a.Id).ToHashSet();
accounts.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
maxId = page.MaxId;
}
async Task LoadMore()
{
isLoadingMore = true;
await LoadPage();
isLoadingMore = false;
}
async Task RemoveFollower(Account account)
{
var name = account.Name ?? account.Acct;
if (!await Confirm.Ask(Localizer["Remove {0} from your followers?", name], Localizer["They no longer see your followers-only posts, and may ask to follow you again."],
Localizer["Remove from followers"], true))
return;
busy.Add(account.Id);
var result = await AccountService.RemoveFromFollowers(account.Id);
busy.Remove(account.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
accounts.Remove(account);
Toast.AddResponseSuccess(Localizer["{0} removed from your followers.", name]);
await OnRemoved.InvokeAsync(account);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+58
View File
@@ -0,0 +1,58 @@
@inherits LocalizableComponentBase
@* The federation ledger's events, newest first (ViewInteractionEvent): what was received, delivered or fetched and how
it ended, never who. Channels and outcomes show as the ledger keys them, like the statistics' counters. In the
statistics' Events section and in a server's detail. *@
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-events-table data-scroll-x>
<thead>
<tr>
<th>@Localizer["When"]</th>
@if (ShowHost)
{
<th>@Localizer["server"]</th>
}
<th>@Localizer["Channel"]</th>
<th>@Localizer["Activity"]</th>
<th>@Localizer["Outcome"]</th>
<th>@Localizer["Reason"]</th>
<th class="text-right">HTTP</th>
<th class="text-right">ms</th>
</tr>
</thead>
<tbody>
@foreach (var happened in Events)
{
<tr data-statistics-event="@happened.Channel" data-outcome="@happened.Outcome">
<td class="whitespace-nowrap" title="@NerdRow.Time(happened.At)">@happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</td>
@if (ShowHost)
{
<td class="max-w-40 truncate" title="@happened.Host">@happened.Host</td>
}
<td>@happened.Channel</td>
<td class="max-w-48 truncate" title="@happened.Object">@happened.Activity</td>
<td>
<DBadge Size="DSize.Xs" Color="@OutcomeColor(happened.Outcome)">@happened.Outcome</DBadge>
</td>
<td class="max-w-48 truncate" title="@happened.Reason">@happened.Reason</td>
<td class="text-right tabular-nums">@happened.Status</td>
<td class="text-right tabular-nums">@happened.LatencyMs</td>
</tr>
}
</tbody>
</DTable>
@code {
[Parameter, EditorRequired] public List<ViewInteractionEvent> Events { get; set; } = [];
/// <summary>A column for the server, where the events are of many.</summary>
[Parameter] public bool ShowHost { get; set; } = true;
protected override void OnInitialized() => IsLoading = false;
static DColor OutcomeColor(string outcome) => outcome switch
{
"ok" or "accepted" => DColor.Success,
"queued" or "deferred" or "retry" => DColor.Warning,
"failed" or "dead" or "refused" or "rejected" or "dropped" => DColor.Error,
_ => DColor.None
};
}
+155
View File
@@ -0,0 +1,155 @@
@inherits LocalizableComponentBase
@* One server of the federation statistics (/clientapi/admin/statistics/hosts/{host}), opened from its row: what it says
of itself and what PrivaPub knows of its deliveries (as nerd rows, by their field names), its days with the main
counters (newest first; a live day is today's, or one not rolled up yet), and its latest events. "Describe again"
asks PrivaPub to read its NodeInfo and instance description anew. Counted per server, never per account. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" BoxClass="md:w-11/12 md:max-w-4xl"
data-host-detail="@HostName">
<Heading><span class="break-all">@HostName</span></Heading>
<ChildContent>
<div class="flex flex-col gap-4 mt-3">
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isDescribing)" OnClick="Describe" data-host-describe>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Describe again"]</span>
</DButton>
</div>
@if (IsLoading)
{
<p class="flex items-center gap-2"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (detail is null)
{
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
}
else
{
@if (detail.Instance is { } instance)
{
<NerdRows Rows="InstanceRows(instance)" />
}
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-72 rounded-xl neo-inset neo-2xs" data-host-days data-scroll-x>
<thead>
<tr>
<th>@Localizer["Day"]</th>
<th class="text-right">@Localizer["Received"]</th>
<th class="text-right">@Localizer["In"]</th>
<th class="text-right">@Localizer["Out"]</th>
<th class="text-right">@Localizer["Failures"]</th>
<th class="text-right">@Localizer["Accounts"]</th>
</tr>
</thead>
<tbody>
@foreach (var day in detail.Days.OrderByDescending(d => d.Day))
{
<tr data-host-day="@NerdRow.Time(day.Day)">
<td class="whitespace-nowrap">
@day.Day.ToString(SUtility.dateTimeFormat)
@if (day.Live)
{
<DBadge Size="DSize.Xs" Color="DColor.Info">@Localizer["live"]</DBadge>
}
</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "recv")</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "in")</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "out")</td>
<td class="text-right tabular-nums">@Failures(day.Counters)</td>
<td class="text-right tabular-nums">@day.Accounts</td>
</tr>
}
</tbody>
</DTable>
@if (detail.Events.Count > 0)
{
<b class="text-sm">@Localizer["Last events"]</b>
<EventsTable Events="detail.Events" ShowHost="false" />
}
<p class="text-xs opacity-70">@detail.Attribution</p>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Parameter, EditorRequired] public string HostName { get; set; }
/// <summary>How many days back its days go.</summary>
[Parameter] public int Days { get; set; } = 30;
[Parameter] public EventCallback OnClosed { get; set; }
ViewHostDetail detail;
bool isDescribing;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => detail = await AdminService.Host(HostName, Days);
async Task Describe()
{
isDescribing = true;
var result = await AdminService.Describe(HostName);
isDescribing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["PrivaPub will read what {0} says of itself again.", HostName]);
}
/// <summary>A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent).</summary>
static long Channel(Dictionary<string, long> counters, string channel) =>
counters.Where(c => c.Key.StartsWith(channel + ":", StringComparison.Ordinal)).Sum(c => c.Value);
// the deliveries out that failed, are retried or were given up, as the servers table counts them
static long Failures(Dictionary<string, long> counters) =>
counters.Where(c => c.Key.Split(':') is { Length: >= 4 } parts && parts[0] == "out" && parts[3] is "retry" or "dead" or "failed").Sum(c => c.Value);
static IEnumerable<NerdRow> InstanceRows(ViewHostInstance instance)
{
yield return NerdRow.Of("software", $"{instance.Software} {instance.SoftwareVersion}".Trim());
yield return NerdRow.Of("nodeName", instance.NodeName);
yield return NerdRow.Of("title", instance.Title);
yield return NerdRow.Of("nodeDescription", instance.NodeDescription);
yield return NerdRow.Of("openRegistrations", instance.OpenRegistrations);
yield return NerdRow.Of("registrationMode", instance.RegistrationMode);
yield return NerdRow.Of("usersTotal", instance.UsersTotal);
yield return NerdRow.Of("usersActiveMonth", instance.UsersActiveMonth);
yield return NerdRow.Of("localPosts", instance.LocalPosts);
yield return NerdRow.Of("languages", instance.Languages.Count > 0 ? string.Join(", ", instance.Languages) : null);
yield return NerdRow.Of("seen", instance.Seen);
yield return NerdRow.Of("firstSeenAt", instance.FirstSeenAt);
yield return NerdRow.Of("lastSeenAt", instance.LastSeenAt);
yield return NerdRow.Of("describedAt", instance.DescribedAt);
yield return NerdRow.Of("descriptionError", instance.DescriptionError);
yield return NerdRow.Of("lastSuccessAt", instance.LastSuccessAt);
yield return NerdRow.Of("lastFailureAt", instance.LastFailureAt);
yield return NerdRow.Of("lastError", instance.LastError);
yield return NerdRow.Of("consecutiveFailures", instance.ConsecutiveFailures > 0 ? instance.ConsecutiveFailures : null);
yield return NerdRow.Of("unavailableUntil", instance.UnavailableUntil);
yield return NerdRow.Of("place", string.Join(", ", new[] { instance.City, instance.Country }.Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } place ? place : null);
yield return NerdRow.Of("asn", instance.Asn is { } asn ? $"AS{asn} {instance.AsnOrg}".Trim() : null);
yield return NerdRow.Of("cdn", instance.Cdn);
yield return NerdRow.Of("instanceApi", instance.InstanceApi);
yield return NerdRow.Of("sourceUrl", instance.SourceUrl);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+164 -4
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
(by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@
(by traffic, failures, latency or name, 50 a page; a server's name opens its detail, HostDetailDialog), how far the
crawler got, the ledger's events (by server, channel and outcome, 100 at a time, older ones from the last one's time)
and the server's own days (its client API, the documents it served, its ledger). Counted per server, never per
account. *@
<div class="flex flex-col gap-4 w-full" data-statistics>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
@@ -87,7 +90,10 @@
@foreach (var host in hosts.Hosts)
{
<tr data-statistics-host="@host.Host">
<td class="max-w-48 truncate">@host.Host</td>
<td class="max-w-48">
<button type="button" class="link link-hover block max-w-full truncate text-left pointer-coarse:py-3.5" title="@host.Host"
@onclick="() => openHost = host.Host" data-statistics-host-open>@host.Host</button>
</td>
<td>@host.Software @host.Version</td>
<td class="text-right tabular-nums">@host.In</td>
<td class="text-right tabular-nums">@host.Out</td>
@@ -116,6 +122,11 @@
<p class="text-xs opacity-70">@hosts.Attribution</p>
}
@if (openHost is { } detailHost)
{
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
}
@if (crawler is not null)
{
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
@@ -141,6 +152,99 @@
}
</div>
}
<div class="flex flex-col gap-2" data-statistics-events>
<b class="text-sm">@Localizer["Events"]</b>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["server"]</span>
<DInput TValue="string" Size="DSize.Sm" @bind-Value="eventsHost" @bind-Value:after="ReloadEvents" autocomplete="off" maxlength="253"
placeholder="@Localizer["A server, e.g. example.social"]" data-statistics-events-host />
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Channel"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="eventsChannel" ValueChanged="ChangeChannel" ValueExpression="() => eventsChannel"
data-statistics-events-channel>
<option value="">@Localizer["All"]</option>
@foreach (var channel in EventChannels)
{
<option value="@channel">@channel</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Outcome"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="eventsOutcome" ValueChanged="ChangeOutcome" ValueExpression="() => eventsOutcome"
data-statistics-events-outcome>
<option value="">@Localizer["All"]</option>
@foreach (var outcome in EventOutcomes)
{
<option value="@outcome">@outcome</option>
}
</DSelect>
</label>
</div>
@if (events.Count == 0)
{
<p class="text-xs opacity-70" data-statistics-events-empty>@Localizer["Empty"]</p>
}
else
{
<EventsTable Events="events" />
}
@if (hasMoreEvents)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="MoreEvents" data-statistics-events-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-2" data-statistics-server>
<b class="text-sm">@Localizer["Server days"]</b>
@if (serverDays.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["Empty"]</p>
}
else
{
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-scroll-x>
<thead>
<tr>
<th>@Localizer["Day"]</th>
<th class="text-right">@Localizer["Client API"]</th>
<th class="text-right">4xx</th>
<th class="text-right">5xx</th>
<th class="text-right">@Localizer["Served"]</th>
<th class="text-right" title="@Localizer["written / dropped / failed"]">@Localizer["Ledger"]</th>
<th class="text-right">@Localizer["Accounts"]</th>
<th class="text-right"><span class="capitalize">@Localizer["servers"]</span></th>
</tr>
</thead>
<tbody>
@foreach (var day in Enumerable.Reverse(serverDays))
{
<tr data-statistics-server-day="@NerdRow.Time(day.Day)">
<td class="whitespace-nowrap">@day.Day.ToString(SUtility.dateTimeFormat)</td>
<td class="text-right tabular-nums">@ClientRequests(day, null)</td>
<td class="text-right tabular-nums">@ClientRequests(day, ":4xx")</td>
<td class="text-right tabular-nums">@ClientRequests(day, ":5xx")</td>
<td class="text-right tabular-nums">@day.Served.Values.Sum()</td>
<td class="text-right tabular-nums whitespace-nowrap" title="@Localizer["written / dropped / failed"]">
@day.LedgerWritten / @day.LedgerDropped / @day.LedgerFailed
</td>
<td class="text-right tabular-nums">@day.Accounts</td>
<td class="text-right tabular-nums">@day.Hosts</td>
</tr>
}
</tbody>
</DTable>
}
</div>
}
</div>
@@ -148,11 +252,24 @@
[Inject] AdminService AdminService { get; set; }
const int PageSize = 50;
const int EventsPageSize = 100;
// the ledger's channels and outcomes, as it keys them
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
static readonly int[] Periods = [1, 7, 30, 90];
ViewStatisticsOverview overview;
ViewHostsPage hosts;
ViewCrawler crawler;
List<ViewInteractionEvent> events = [];
List<ViewServerDay> serverDays = [];
// the server whose detail is open
string openHost;
string eventsHost;
string eventsChannel;
string eventsOutcome;
bool hasMoreEvents;
bool isLoadingMore;
int days = 30;
string sort = "traffic";
int page = 1;
@@ -179,10 +296,53 @@
}
async Task LoadData()
{
await LoadDays();
crawler = await AdminService.Crawler();
await ReloadEvents();
}
// what the period chosen shows
async Task LoadDays()
{
overview = await AdminService.Overview(days);
hosts = await AdminService.Hosts(days, sort, page);
crawler = await AdminService.Crawler();
serverDays = await AdminService.ServerDays(days);
}
// the client API's requests of a day, those of one status class (":4xx") or all
static long ClientRequests(ViewServerDay day, string statusClass) =>
day.Client.Where(c => c.Key.EndsWith("xx", StringComparison.Ordinal) && (statusClass is null || c.Key.EndsWith(statusClass, StringComparison.Ordinal)))
.Sum(c => c.Value);
async Task ReloadEvents()
{
events = [];
hasMoreEvents = false;
await LoadEvents();
}
async Task LoadEvents()
{
isLoadingMore = true;
var page = await AdminService.Events(eventsHost?.Trim(), eventsChannel, eventsOutcome, null, events.LastOrDefault()?.At, EventsPageSize);
isLoadingMore = false;
events.AddRange(page);
hasMoreEvents = page.Count == EventsPageSize;
}
async Task MoreEvents() => await LoadEvents();
async Task ChangeChannel(string value)
{
eventsChannel = value;
await ReloadEvents();
}
async Task ChangeOutcome(string value)
{
eventsOutcome = value;
await ReloadEvents();
}
static string Ms(int? value) => value?.ToString() ?? "—";
@@ -191,7 +351,7 @@
{
days = value;
page = 1;
await LoadData();
await LoadDays();
}
async Task ChangeSort(string value)
+9 -3
View File
@@ -124,7 +124,8 @@
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
</div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]"
data-upload-remove>
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
@@ -215,7 +216,7 @@
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<ChildContent>
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
@foreach (var mediaType in Enum.GetValues<MediaType>())
{
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
}
@@ -599,10 +600,15 @@
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
void Retry(UploadMedia media) => Start(media);
void Remove(UploadMedia media)
// a file taken out before posting goes from PrivaPub too (DELETE /api/v1/media/:id), unless a post holds it already: an
// edit's own media stay its post's. A failure is only logged; the upload stays unattached on PrivaPub.
async Task Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
if (media.Posted || media.Id is not { } mediaId)
return;
await MediaService.Discard(mediaId);
}
// the reader chose to publish its pictures without descriptions
+136 -5
View File
@@ -7,8 +7,11 @@
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -49,6 +52,13 @@
}
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
@if (!Compact)
{
<DButton Size="DSize.Xs" class="ml-auto" Disabled="@(IsDefaultDisabled || isActing)" OnClick="DismissNotification" title="@Localizer["Dismiss"]"
aria-label="@Localizer["Dismiss"]" data-notification-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
}
</p>
}
else if (Post.BoostedBy is { } booster)
@@ -283,11 +293,24 @@
<span class="hidden md:inline">@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
favourite (on a phone, in More) *@
@if (CanDownvote(status) && status.Privapub.Votes is { } votes)
{
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="votes.Downvoted == true" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Downvote(status)" title="@(votes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])"
aria-pressed="@(votes.Downvoted == true ? "true" : "false")" data-downvote="@(votes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-lg @(votes.Downvoted == true ? "text-warning" : null)"></i>
@if (votes.Down > 0)
{
<span class="tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>@votes.Down</span>
}
</DButton>
}
else if (status.Privapub?.Votes is { Down: > 0 } ownVotes)
{
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
<i aria-hidden="true" class="ion-md-arrow-down"></i>@ownVotes.Down
</span>
}
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
@@ -335,6 +358,18 @@
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
<span>@Localizer["Bookmark"]</span>
</DButton>
@if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="phoneVotes.Downvoted == true"
Disabled="IsDefaultDisabled || isActing" OnClick="() => Downvote(status)" data-downvote-phone="@(phoneVotes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-base @(phoneVotes.Downvoted == true ? "text-warning" : null)"></i>
<span>@(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])</span>
@if (phoneVotes.Down > 0)
{
<span class="opacity-70 tabular-nums">@phoneVotes.Down</span>
}
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
<i aria-hidden="true" class="ion-md-stats text-base"></i>
<span>@Localizer["Details"]</span>
@@ -376,6 +411,17 @@
}
@if (!IsOwn)
{
@if (QuotesOwnPost(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="RevokeQuote" data-revoke-quote>
<i aria-hidden="true" class="ion-md-quote text-base text-error"></i>
<span>@Localizer["Revoke quote"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isFilteringPost = true)" data-filter-post>
<i aria-hidden="true" class="ion-md-funnel text-base"></i>
<span>@Localizer["Filter this post"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
@@ -398,6 +444,11 @@
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="@(() => isChoosingQuotePolicy = true)" data-quote-policy="@StatusService.QuotePolicyValue(StatusService.QuotePolicyOf(status))">
<i aria-hidden="true" class="ion-md-quote text-base"></i>
<span>@Localizer["Who may quote it"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i>
@@ -461,6 +512,14 @@
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isFilteringPost)
{
<FilterPostDialog StatusId="@status.Id" OnClosed="@(() => isFilteringPost = false)" OnFiltered="Filtered" />
}
@if (isChoosingQuotePolicy)
{
<QuotePolicyDialog Status="status" OnClosed="@(() => isChoosingQuotePolicy = false)" OnChanged="QuotePolicyChanged" />
}
@if (isHistoryOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
@@ -521,6 +580,8 @@
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
/// <summary>Its notification was dismissed: the page takes the item out.</summary>
[Parameter] public EventCallback<FeedPost> OnDismissed { get; set; }
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
[Parameter] public EventCallback<string> OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
@@ -547,6 +608,8 @@
bool isReadingServer;
bool isHistoryOpen;
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = [];
@@ -824,6 +887,74 @@
Post.Status = updated.Reblog ?? updated;
}
// a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card
// offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it
// has downvotes already. Not on the persona's own post.
bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes
&& (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software));
static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb";
// a vote is public, like a reaction: the persona is told so once, before its first
async Task Downvote(Status status)
{
var on = status.Privapub?.Votes?.Downvoted != true;
if (on && !await Notices.Confirm(CascadingState.PersonaId))
return;
await Act(on ? StatusAction.Downvote : StatusAction.Undownvote);
}
// the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss)
async Task DismissNotification()
{
var notification = Post.Notification;
isActing = true;
var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}");
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await OnDismissed.InvokeAsync(Post);
}
// in a filter now: the post is read again, so the filter applies here at once
async Task Filtered()
{
isFilteringPost = false;
if (await StatusService.Get(Status.Id) is { } filtered)
Post.Status = filtered.Reblog ?? filtered;
}
void QuotePolicyChanged(Status updated)
{
isChoosingQuotePolicy = false;
Post.Status = updated.Reblog ?? updated;
}
// someone else's post quoting the persona's own: the persona may take its permission back
bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId;
async Task RevokeQuote()
{
if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true))
return;
isActing = true;
var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (Status)result.Data;
Post.Status = updated.Reblog ?? updated;
Toast.AddResponseSuccess(Localizer["Quote revoked."]);
}
[Inject] AccountService AccountService { get; set; }
[Inject] PublicNoticeService Notices { get; set; }
+88
View File
@@ -0,0 +1,88 @@
@inherits LocalizableComponentBase
@* "Filter this post", from a card's menu: the persona's filters (/api/v2/filters), one of which takes the post (POST
/api/v2/filters/:id/statuses); a filter that holds it already says so. Filters are made and changed on /filters. The
card reads the post again afterwards, so the filter applies to it at once. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-filter-post-dialog>
<Heading>@Localizer["Filter this post"]</Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (filters.Count == 0)
{
<p class="text-sm opacity-70 mt-3">@Localizer["This persona has no filter yet."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var filter in filters)
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="@(IsDefaultDisabled || isSaving || Holds(filter))" OnClick="() => Add(filter)"
data-filter-choice="@filter.Id">
<i class="ion-md-funnel" aria-hidden="true"></i>
<span class="flex-1 min-w-0 truncate text-left">@filter.Title</span>
<span class="text-xs opacity-70">@Description(filter)</span>
</DButton>
}
<a class="btn btn-sm justify-start" href="filters" data-filter-post-manage>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Make a filter"]</span>
</a>
</div>
</ChildContent>
</DModal>
@code {
[Inject] FilterService FilterService { get; set; }
[Parameter, EditorRequired] public string StatusId { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>The post went into a filter.</summary>
[Parameter] public EventCallback OnFiltered { get; set; }
List<Filter> filters = [];
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => filters = await FilterService.All();
bool Holds(Filter filter) => filter.Statuses.Any(s => s.StatusId == StatusId);
string Description(Filter filter) => Holds(filter) ? Localizer["Already in it"]
: !filter.IsActive ? Localizer["Expired"]
: filter.FilterAction switch
{
FilterAction.Hide => Localizer["Hide"],
FilterAction.Blur => Localizer["Blur"],
_ => Localizer["Warn"]
};
async Task Add(Filter filter)
{
isSaving = true;
var result = await FilterService.AddStatus(filter.Id, StatusId);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Added to {0}.", filter.Title]);
await OnFiltered.InvokeAsync();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+147
View File
@@ -0,0 +1,147 @@
@inherits LocalizableComponentBase
@* The accounts waiting for the persona to accept their follow (/api/v1/follow_requests), newest first, each opening its
profile, accepted (authorize) or refused (reject) here; "Load more" follows the Link header's next page when PrivaPub
sends one. On the persona's own profile (hidden while nobody waits) and in a sheet from the Notifications tab. *@
@if (!HideWhenEmpty || IsLoading || requests.Count > 0)
{
<section class="flex flex-none flex-col gap-2 w-full" data-follow-requests>
@if (ShowHeading)
{
<h2 class="font-semibold text-sm inline-flex items-center gap-2"><i class="ion-md-person-add" aria-hidden="true"></i>@Localizer["Follow requests"]</h2>
}
@if (IsLoading)
{
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (requests.Count == 0)
{
<p class="text-sm opacity-70" data-follow-requests-empty>@Localizer["No follow request is waiting."]</p>
}
@foreach (var account in requests)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-follow-request="@account.Acct">
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 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 (answers.TryGetValue(account.Id, out var accepted))
{
<span class="text-xs opacity-70" data-follow-request-answer="@(accepted ? "accepted" : "refused")">
@(accepted ? Localizer["Accepted."] : Localizer["Refused."])
</span>
}
else
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, true)"
data-follow-request-accept>
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, false)"
data-follow-request-reject>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
}
</div>
}
@if (maxId is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-follow-requests-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</section>
}
@code {
[Inject] AccountService AccountService { get; set; }
/// <summary>Renders nothing once read with nobody waiting (the persona's own profile).</summary>
[Parameter] public bool HideWhenEmpty { get; set; }
[Parameter] public bool ShowHeading { get; set; }
/// <summary>A request was answered: true when accepted.</summary>
[Parameter] public EventCallback<bool> OnAnswered { get; set; }
List<Account> requests = [];
// what was answered here, by account id: accepted (true) or refused
Dictionary<string, bool> answers = new();
HashSet<string> busy = [];
string maxId;
string loadedPersonaId;
bool isLoadingMore;
// The persona changed (NavMenu's switcher): its requests are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
requests = [];
answers = new();
maxId = null;
await LoadPage();
IsLoading = false;
}
async Task LoadPage()
{
var result = await AccountService.FollowRequests(maxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
var known = requests.Select(a => a.Id).ToHashSet();
requests.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
maxId = page.MaxId;
}
async Task LoadMore()
{
isLoadingMore = true;
await LoadPage();
isLoadingMore = false;
}
async Task Answer(Account account, bool accept)
{
busy.Add(account.Id);
var result = await AccountService.Answer(account.Id, accept);
busy.Remove(account.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
answers[account.Id] = accept;
await OnAnswered.InvokeAsync(accept);
}
}
+77
View File
@@ -0,0 +1,77 @@
@inherits LocalizableComponentBase
@* Who may quote one of the persona's own public or unlisted posts (PUT /api/v1/statuses/:id/interaction_policy,
quote_approval_policy): anyone, its followers or nobody, from the card's menu. The post states its policy in
quote_approval.automatic; PrivaPub tells the post's readers of the change. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-quote-policy-dialog>
<Heading>@Localizer["Who may quote it"]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 mt-3">
@foreach (var policy in Enum.GetValues<QuotePolicy>())
{
<DButton Size="DSize.Sm" class="justify-start" Active="policy == current" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => Choose(policy)"
aria-pressed="@(policy == current ? "true" : "false")" data-quote-policy-choice="@StatusService.QuotePolicyValue(policy)">
<i class="@PolicyIcon(policy)" aria-hidden="true"></i>
<span class="flex-1 text-left">@PolicyName(policy)</span>
@if (policy == current)
{
<i class="ion-md-checkmark text-primary" aria-hidden="true"></i>
}
</DButton>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] StatusService StatusService { get; set; }
[Parameter, EditorRequired] public Status Status { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>The post as PrivaPub now describes it, its new policy in it.</summary>
[Parameter] public EventCallback<Status> OnChanged { get; set; }
QuotePolicy current = QuotePolicy.Public;
bool isSaving;
protected override void OnInitialized()
{
current = StatusService.QuotePolicyOf(Status);
IsLoading = false;
}
string PolicyName(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => Localizer["Your followers"],
QuotePolicy.Nobody => Localizer["Nobody"],
_ => Localizer["Anyone"]
};
static string PolicyIcon(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => "ion-md-people",
QuotePolicy.Nobody => "ion-md-close-circle",
_ => "ion-md-globe"
};
async Task Choose(QuotePolicy policy)
{
if (policy == current)
return;
isSaving = true;
var result = await StatusService.SetQuotePolicy(Status.Id, policy);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (Status)result.Data;
current = StatusService.QuotePolicyOf(updated);
Toast.AddResponseSuccess(Localizer["Saved."]);
await OnChanged.InvokeAsync(updated);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}