R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s
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:
1 parent
b731d8c8c0
commit
b8d7858acb
25 files changed
+1543
-61
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user