Settings, Administration and conversations; threads that fill in
Settings, which held only the theme, now belong to the persona too: - its profile (update_credentials, a new PATCH in IHttpService): pictures, name, biography and fields as written, locked, bot, discoverable, indexable, and how its posts start (visibility, language, sensitive media, who may quote them); the composer starts at that visibility; - the root's personas, each used in turn, and a new one made; - the accounts it muted or blocked and the servers it blocked, each undone there, a server blocked from a pasted address or handle; - its follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes (PortabilityService): an import resolves each row through PrivaPub, one at a time, and can stop. Administration opens to moderators: reports, resolved there; and for administrators the servers blocked for everyone and the federation statistics (traffic by server, delivery success and latency, the crawler). The Direct tab pages conversations, badges the unread ones, and a card marks one read or takes it off the list; opening one reads it. A remote thread is read again 4 and 15 seconds after it opens, since PrivaPub then fetches the replies its servers hold. A thread its community locked shows a lock instead of a reply button, reactions stay visible below a content warning, and a searched handle opens its account's profile whatever else matched. The e2e tests cover each: the profile saved and read back, a persona made, servers blocked and imported, follows exported, an instance block and the statistics as the pasture's admin, a conversation read and removed, and a reply only Mastodon held appearing in its thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
03f4f2ad53
commit
d7d836ba39
40 files changed
+3155
-62
No files matched your search
@@ -112,6 +112,7 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
- picking a hue leaves grey.
|
- picking a hue leaves grey.
|
||||||
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
||||||
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
||||||
|
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
|
||||||
- `Styles/neo.css` holds the neumorphic layer:
|
- `Styles/neo.css` holds the neumorphic layer:
|
||||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||||
old `neomorph is-nxsmall` classes);
|
old `neomorph is-nxsmall` classes);
|
||||||
@@ -187,9 +188,9 @@ supplies:
|
|||||||
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
||||||
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
||||||
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
||||||
- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` /
|
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||||
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub
|
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||||
redirects it).
|
PrivaPub redirects it).
|
||||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||||
@@ -213,7 +214,9 @@ supplies:
|
|||||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
||||||
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
|
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
|
||||||
following them, trends, the directory), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`.
|
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
|
||||||
|
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and
|
||||||
|
`ProvenanceService`.
|
||||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
||||||
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||||
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice.
|
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice.
|
||||||
@@ -231,6 +234,18 @@ supplies:
|
|||||||
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
||||||
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
|
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
|
||||||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
||||||
|
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
||||||
|
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
|
||||||
|
and adds what was found, in the thread's order.
|
||||||
|
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the persona's profile (`ProfileEditor`:
|
||||||
|
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
|
||||||
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||||
|
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
|
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
|
||||||
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||||
|
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||||||
|
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||||
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||||
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||||
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
using PrivaPub.ClientModels.Group;
|
using PrivaPub.ClientModels.Group;
|
||||||
using PrivaPub.ClientModels.Post;
|
using PrivaPub.ClientModels.Post;
|
||||||
using PrivaPub.ClientModels.User;
|
using PrivaPub.ClientModels.User;
|
||||||
@@ -34,11 +35,20 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(JwtUser))]
|
[JsonSerializable(typeof(JwtUser))]
|
||||||
[JsonSerializable(typeof(ViewAvatar))]
|
[JsonSerializable(typeof(ViewAvatar))]
|
||||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||||
|
[JsonSerializable(typeof(InsertAvatarForm))]
|
||||||
[JsonSerializable(typeof(ViewGroup))]
|
[JsonSerializable(typeof(ViewGroup))]
|
||||||
[JsonSerializable(typeof(List<ViewGroup>))]
|
[JsonSerializable(typeof(List<ViewGroup>))]
|
||||||
[JsonSerializable(typeof(ViewPost))]
|
[JsonSerializable(typeof(ViewPost))]
|
||||||
[JsonSerializable(typeof(List<ViewPost>))]
|
[JsonSerializable(typeof(List<ViewPost>))]
|
||||||
[JsonSerializable(typeof(InsertPostForm))]
|
[JsonSerializable(typeof(InsertPostForm))]
|
||||||
|
[JsonSerializable(typeof(ViewReport))]
|
||||||
|
[JsonSerializable(typeof(List<ViewReport>))]
|
||||||
|
[JsonSerializable(typeof(DomainBlockForm))]
|
||||||
|
[JsonSerializable(typeof(ViewDomainBlock))]
|
||||||
|
[JsonSerializable(typeof(List<ViewDomainBlock>))]
|
||||||
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
|
[JsonSerializable(typeof(ViewCrawler))]
|
||||||
public partial class ClientJsonContext : JsonSerializerContext
|
public partial class ClientJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
|
||||||
|
server, about an account and some of its posts; resolved here. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-reports>
|
||||||
|
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (reports.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var report in reports)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
|
||||||
|
@if (report.Forwarded)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
|
||||||
|
@report.CreatedAt.GetPassedTime(Localizer)
|
||||||
|
</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
@if (!report.IsResolved)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
|
||||||
|
data-report-resolve>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else if (report.ResolvedAt is { } resolvedAt)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["About"]</b>
|
||||||
|
@if (report.TargetAccountId is { Length: > 0 } targetId)
|
||||||
|
{
|
||||||
|
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="break-all">@report.TargetActorURI</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<b>@Localizer["From"]</b>
|
||||||
|
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
|
||||||
|
</p>
|
||||||
|
@if (report.Comment is { Length: > 0 } comment)
|
||||||
|
{
|
||||||
|
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
|
||||||
|
}
|
||||||
|
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
@foreach (var postId in report.PostIds)
|
||||||
|
{
|
||||||
|
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
|
||||||
|
}
|
||||||
|
@* a report from another server names posts by their ids there when we hold none of them *@
|
||||||
|
@if (report.PostIds.Count == 0)
|
||||||
|
{
|
||||||
|
@foreach (var uri in report.ObjectURIs)
|
||||||
|
{
|
||||||
|
<span class="break-all opacity-70">@uri</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewReport> reports = [];
|
||||||
|
bool resolved;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => reports = await AdminService.Reports(resolved);
|
||||||
|
|
||||||
|
async Task ShowResolved(bool value)
|
||||||
|
{
|
||||||
|
resolved = value;
|
||||||
|
IsLoading = true;
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string CategoryName(string category) => category switch
|
||||||
|
{
|
||||||
|
"spam" => Localizer["Spam"],
|
||||||
|
"legal" => Localizer["Illegal"],
|
||||||
|
"violation" => Localizer["Breaks the rules"],
|
||||||
|
_ => Localizer["Other"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Resolve(ViewReport report)
|
||||||
|
{
|
||||||
|
busy.Add(report.ID);
|
||||||
|
var result = await AdminService.Resolve(report.ID);
|
||||||
|
busy.Remove(report.ID);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.Remove(report);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report resolved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
|
||||||
|
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-server-blocks>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-server-block-domain>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
|
||||||
|
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
|
||||||
|
</div>
|
||||||
|
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
|
placeholder="@Localizer["Why, for everyone to read"]" />
|
||||||
|
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
|
||||||
|
placeholder="@Localizer["Why, for the admins only"]" />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
|
||||||
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-server-block="@block.Domain">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@block.Domain</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@(block.Severity == "Suspend" ? DColor.Error : DColor.Warning)">
|
||||||
|
@(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"])
|
||||||
|
</DBadge>
|
||||||
|
@if (block.RejectMedia)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (block.PublicComment is { Length: > 0 } publicComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs">@publicComment</p>
|
||||||
|
}
|
||||||
|
@if (block.PrivateComment is { Length: > 0 } privateComment)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70"><i class="ion-md-lock"></i> @privateComment</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
List<ViewDomainBlock> blocks = [];
|
||||||
|
DomainBlockForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => blocks = await AdminService.ServerBlocks();
|
||||||
|
|
||||||
|
async Task Block()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AdminService.BlockServer(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = (ViewDomainBlock)result.Data;
|
||||||
|
blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)];
|
||||||
|
form = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Unblock(ViewDomainBlock block)
|
||||||
|
{
|
||||||
|
busy.Add(block.Domain);
|
||||||
|
var result = await AdminService.UnblockServer(block.Domain);
|
||||||
|
busy.Remove(block.Domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.Remove(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
@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. *@
|
||||||
|
<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">
|
||||||
|
<span>@Localizer["Period"]</span>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
|
||||||
|
@foreach (var period in Periods)
|
||||||
|
{
|
||||||
|
<option value="@period">@Localizer["{0} days", period]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Servers by"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
|
||||||
|
<option value="traffic">@Localizer["Traffic"]</option>
|
||||||
|
<option value="failures">@Localizer["Failures"]</option>
|
||||||
|
<option value="latency">@Localizer["Latency"]</option>
|
||||||
|
<option value="host">@Localizer["Name"]</option>
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (overview is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview>
|
||||||
|
<DStat>
|
||||||
|
<Heading>@Localizer["Deliveries that arrived"]</Heading>
|
||||||
|
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
|
||||||
|
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
|
||||||
|
<ChildContent>@overview.Hosts.Active</ChildContent>
|
||||||
|
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
|
||||||
|
</DStat>
|
||||||
|
<DStat>
|
||||||
|
<Heading>@Localizer["Accounts seen"]</Heading>
|
||||||
|
<ChildContent>@overview.Accounts</ChildContent>
|
||||||
|
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
|
||||||
|
</DStat>
|
||||||
|
</DStats>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-xs">
|
||||||
|
@foreach (var (title, counts) in Outcomes)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 min-w-40">
|
||||||
|
<b class="text-sm">@title</b>
|
||||||
|
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
|
||||||
|
{
|
||||||
|
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hosts is not null)
|
||||||
|
{
|
||||||
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@Localizer["server"]</th>
|
||||||
|
<th>@Localizer["software"]</th>
|
||||||
|
<th class="text-right">@Localizer["In"]</th>
|
||||||
|
<th class="text-right">@Localizer["Out"]</th>
|
||||||
|
<th class="text-right">@Localizer["Refused"]</th>
|
||||||
|
<th class="text-right">@Localizer["Failures"]</th>
|
||||||
|
<th class="text-right">p50</th>
|
||||||
|
<th>@Localizer["Delivery"]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var host in hosts.Hosts)
|
||||||
|
{
|
||||||
|
<tr data-statistics-host="@host.Host">
|
||||||
|
<td class="max-w-48 truncate">@host.Host</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>
|
||||||
|
<td class="text-right tabular-nums">@host.Refused</td>
|
||||||
|
<td class="text-right tabular-nums">@host.Failures</td>
|
||||||
|
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
|
||||||
|
<td>
|
||||||
|
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (host.Delivery?.ConsecutiveFailures > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</DTable>
|
||||||
|
@if (hosts.Total > PageSize)
|
||||||
|
{
|
||||||
|
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
|
||||||
|
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (crawler is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
||||||
|
<b class="text-sm">@Localizer["Crawler"]</b>
|
||||||
|
<span>
|
||||||
|
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
|
||||||
|
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
|
||||||
|
</span>
|
||||||
|
@foreach (var host in crawler.Recent)
|
||||||
|
{
|
||||||
|
<span class="flex flex-wrap gap-2">
|
||||||
|
<span class="truncate max-w-48">@host.Host</span>
|
||||||
|
<span class="opacity-70">@host.Software</span>
|
||||||
|
@if (host.LastCrawledAt is { } crawled)
|
||||||
|
{
|
||||||
|
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
|
||||||
|
}
|
||||||
|
@if (host.CrawlError is { Length: > 0 } crawlError)
|
||||||
|
{
|
||||||
|
<span class="text-error">@crawlError</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
|
||||||
|
const int PageSize = 50;
|
||||||
|
static readonly int[] Periods = [1, 7, 30, 90];
|
||||||
|
|
||||||
|
ViewStatisticsOverview overview;
|
||||||
|
ViewHostsPage hosts;
|
||||||
|
ViewCrawler crawler;
|
||||||
|
int days = 30;
|
||||||
|
string sort = "traffic";
|
||||||
|
int page = 1;
|
||||||
|
|
||||||
|
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
|
||||||
|
[
|
||||||
|
(Localizer["Received"], overview.Inbound),
|
||||||
|
(Localizer["Delivered"], overview.Outbound),
|
||||||
|
(Localizer["Channels"], overview.Channels)
|
||||||
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
overview = await AdminService.Overview(days);
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
crawler = await AdminService.Crawler();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
||||||
|
|
||||||
|
async Task ChangeDays(int value)
|
||||||
|
{
|
||||||
|
days = value;
|
||||||
|
page = 1;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangeSort(string value)
|
||||||
|
{
|
||||||
|
sort = value;
|
||||||
|
page = 1;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ChangePage(int value)
|
||||||
|
{
|
||||||
|
page = value;
|
||||||
|
hosts = await AdminService.Hosts(days, sort, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -258,9 +258,18 @@
|
|||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// a new post starts with the visibility the persona chose in Settings
|
||||||
|
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => MessageType.FollowersOnly,
|
||||||
|
"unlisted" => MessageType.Unlisted,
|
||||||
|
_ => MessageType.Public
|
||||||
|
};
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
|
form.MessageType = DefaultVisibility;
|
||||||
form.QuotedStatusId = Quoting?.Id;
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
if (Editing is not null)
|
if (Editing is not null)
|
||||||
{
|
{
|
||||||
@@ -421,14 +430,14 @@
|
|||||||
if (result.Data is ScheduledStatus scheduled)
|
if (result.Data is ScheduledStatus scheduled)
|
||||||
{
|
{
|
||||||
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||||
form = new() { GroupId = GroupId };
|
form = new() { GroupId = GroupId, MessageType = DefaultVisibility };
|
||||||
await OnPublished.InvokeAsync(null);
|
await OnPublished.InvokeAsync(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||||
var published = result.Data as FeedPost;
|
var published = result.Data as FeedPost;
|
||||||
form = new() { GroupId = GroupId };
|
form = new() { GroupId = GroupId, MessageType = DefaultVisibility };
|
||||||
await OnPublished.InvokeAsync(published);
|
await OnPublished.InvokeAsync(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
-31
@@ -60,9 +60,19 @@
|
|||||||
}
|
}
|
||||||
else if (Post.Conversation is { } conversation)
|
else if (Post.Conversation is { } conversation)
|
||||||
{
|
{
|
||||||
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
|
<p class="flex flex-wrap items-center gap-2 min-w-0 text-xs" data-conversation-id="@conversation.Id">
|
||||||
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
|
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
|
||||||
<span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
|
<span class="flex-1 min-w-0 truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
|
||||||
|
@if (conversation.Unread)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" data-conversation-unread>@Localizer["unread"]</DBadge>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="ReadConversation" title="@Localizer["Mark as read"]" data-conversation-read>
|
||||||
|
<i class="ion-md-checkmark"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="RemoveConversation" title="@Localizer["Remove from the list"]" data-conversation-remove>
|
||||||
|
<i class="ion-md-close"></i>
|
||||||
|
</DButton>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +149,28 @@
|
|||||||
@Body(status)
|
@Body(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* the reactions stand outside the content warning: they are not what it hides *@
|
||||||
|
@if (status.EmojiReactions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-1" data-reactions>
|
||||||
|
@foreach (var reaction in status.EmojiReactions)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
||||||
|
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
|
||||||
|
@if (reaction.Url is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@reaction.Name</span>
|
||||||
|
}
|
||||||
|
<span class="tabular-nums">@reaction.Count</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (Post.Located)
|
@if (Post.Located)
|
||||||
{
|
{
|
||||||
<p class="inline-flex gap-2 items-center text-xs opacity-70">
|
<p class="inline-flex gap-2 items-center text-xs opacity-70">
|
||||||
@@ -151,13 +183,27 @@
|
|||||||
{
|
{
|
||||||
<footer class="flex gap-2 mt-1 justify-between">
|
<footer class="flex gap-2 mt-1 justify-between">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
@if (status.Privapub?.Locked == true)
|
||||||
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
|
{
|
||||||
@if (status.RepliesCount > 0)
|
@* its community's moderators locked the thread: no reply is taken *@
|
||||||
{
|
<span class="btn btn-sm btn-disabled" title="@Localizer["Locked by its community's moderators"]" data-locked>
|
||||||
<span>@status.RepliesCount</span>
|
<i aria-hidden="true" class="ion-md-lock text-lg"></i>
|
||||||
}
|
@if (status.RepliesCount > 0)
|
||||||
</DButton>
|
{
|
||||||
|
<span>@status.RepliesCount</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||||
|
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
|
||||||
|
@if (status.RepliesCount > 0)
|
||||||
|
{
|
||||||
|
<span>@status.RepliesCount</span>
|
||||||
|
}
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||||
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
|
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
|
||||||
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
|
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
|
||||||
@@ -243,7 +289,8 @@
|
|||||||
</DDropdown>
|
</DDropdown>
|
||||||
@if (IncludeExpand)
|
@if (IncludeExpand)
|
||||||
{
|
{
|
||||||
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
|
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
|
||||||
|
@onclick="OpenConversation">
|
||||||
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
@@ -294,6 +341,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] ProvenanceService ProvenanceService { get; set; }
|
[Inject] ProvenanceService ProvenanceService { get; set; }
|
||||||
|
|
||||||
@@ -443,27 +491,6 @@
|
|||||||
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
|
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (status.EmojiReactions.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="flex flex-wrap gap-1" data-reactions>
|
|
||||||
@foreach (var reaction in status.EmojiReactions)
|
|
||||||
{
|
|
||||||
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
|
||||||
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
|
|
||||||
@if (reaction.Url is { Length: > 0 } image)
|
|
||||||
{
|
|
||||||
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>@reaction.Name</span>
|
|
||||||
}
|
|
||||||
<span class="tabular-nums">@reaction.Count</span>
|
|
||||||
</DButton>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (status.Tags.Count > 0)
|
@if (status.Tags.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -576,6 +603,41 @@
|
|||||||
Toast.AddResponseSuccess(Localizer["Voted."]);
|
Toast.AddResponseSuccess(Localizer["Voted."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a conversation opened is read
|
||||||
|
async Task OpenConversation()
|
||||||
|
{
|
||||||
|
if (Post.Conversation is not { Unread: true } conversation)
|
||||||
|
return;
|
||||||
|
var result = await TimelineService.ReadConversation(conversation.Id);
|
||||||
|
if (!result.IsInvalid)
|
||||||
|
conversation.Unread = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ReadConversation()
|
||||||
|
{
|
||||||
|
var result = await TimelineService.ReadConversation(Post.Conversation.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Post.Conversation.Unread = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// off the persona's list until a newer message; the messages stay
|
||||||
|
async Task RemoveConversation()
|
||||||
|
{
|
||||||
|
var result = await TimelineService.RemoveConversation(Post.Conversation.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnDeleted.InvokeAsync(Post);
|
||||||
|
}
|
||||||
|
|
||||||
async Task Delete()
|
async Task Delete()
|
||||||
{
|
{
|
||||||
isActing = true;
|
isActing = true;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
{
|
{
|
||||||
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
||||||
}
|
}
|
||||||
|
else if (type is TimelineType.Direct && UnreadConversations > 0)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadConversations]" data-unread-conversations>@UnreadConversations</DBadge>
|
||||||
|
}
|
||||||
</Header>
|
</Header>
|
||||||
</DTab>
|
</DTab>
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,8 @@
|
|||||||
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
||||||
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
|
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
|
||||||
[Parameter] public int UnreadNotifications { get; set; }
|
[Parameter] public int UnreadNotifications { get; set; }
|
||||||
|
/// <summary>How many of the newest direct conversations hold a message the persona has not read.</summary>
|
||||||
|
[Parameter] public int UnreadConversations { get; set; }
|
||||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||||
[Parameter] public bool IsComposing { get; set; }
|
[Parameter] public bool IsComposing { get; set; }
|
||||||
[Parameter] public EventCallback OnCompose { get; set; }
|
[Parameter] public EventCallback OnCompose { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The persona's follows, lists, blocks, mutes, blocked servers and bookmarks, exported and imported as the CSV files
|
||||||
|
Mastodon writes, which most fediverse servers read too: a persona moves here from another server, or away, with what
|
||||||
|
it gathered there. An import acts one row at a time, through PrivaPub, and can be stopped. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Export"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Files in Mastodon's format, which most fediverse servers import."]</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || exporting is not null || importing is not null)" OnClick="() => Export(data)"
|
||||||
|
data-export-kind="@data">
|
||||||
|
@if (exporting == data)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-download"></i>
|
||||||
|
}
|
||||||
|
<span>@Name(data)</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Import"]</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 items-end">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["What the file holds"]</span>
|
||||||
|
<DSelect TValue="PortableData" Size="DSize.Sm" @bind-Value="importKind" disabled="@(importing is not null)" data-import-kind>
|
||||||
|
@foreach (var data in Kinds)
|
||||||
|
{
|
||||||
|
<option value="@data">@Name(data)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".csv,text/csv" OnChange="Pick" disabled="@(importing is not null)" data-import-file />
|
||||||
|
@if (importing is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || file is null || exporting is not null)" OnClick="Import"
|
||||||
|
data-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i><span>@Localizer["Import"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" OnClick="Stop">@Localizer["Stop"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (report is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-import-report>
|
||||||
|
<DProgress class="w-full max-w-md" Value="@(report.Done + report.Failed.Count)" Max="@Math.Max(1, report.Total)" />
|
||||||
|
<span class="text-sm tabular-nums">
|
||||||
|
@Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]
|
||||||
|
</span>
|
||||||
|
<ErrorViewer AdditionalErrors="report.Failed" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] PortabilityService PortabilityService { get; set; }
|
||||||
|
[Inject] IBlazorDownloadFileService DownloadFile { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
// an export file of 5000 rows stays well under this
|
||||||
|
const long MaximumFileSize = 4 * 1024 * 1024;
|
||||||
|
static readonly PortableData[] Kinds = Enum.GetValues<PortableData>();
|
||||||
|
|
||||||
|
PortableData? exporting;
|
||||||
|
PortableData? importing;
|
||||||
|
PortableData importKind = PortableData.Follows;
|
||||||
|
string file;
|
||||||
|
ImportReport report;
|
||||||
|
CancellationTokenSource stopping;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
string Name(PortableData data) => data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => Localizer["Follows"],
|
||||||
|
PortableData.Lists => Localizer["Lists"],
|
||||||
|
PortableData.Blocks => Localizer["Blocked accounts"],
|
||||||
|
PortableData.Mutes => Localizer["Muted accounts"],
|
||||||
|
PortableData.ServerBlocks => Localizer["Blocked servers"],
|
||||||
|
_ => Localizer["Bookmarks"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Export(PortableData data)
|
||||||
|
{
|
||||||
|
exporting = data;
|
||||||
|
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
|
exporting = null;
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
file = null;
|
||||||
|
report = null;
|
||||||
|
if (eventArgs.File.Size > MaximumFileSize)
|
||||||
|
{
|
||||||
|
Toast.AddWarning(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", eventArgs.File.Name,
|
||||||
|
eventArgs.File.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(eventArgs.File.OpenReadStream(MaximumFileSize));
|
||||||
|
file = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessError(ex, $"{nameof(DataTransfer)}.{nameof(Pick)}:{eventArgs.File.Name}");
|
||||||
|
Toast.AddWarning(Localizer["The file \"{0}\" could not be read.", eventArgs.File.Name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
importing = importKind;
|
||||||
|
stopping = new();
|
||||||
|
report = new();
|
||||||
|
var progress = new Progress<ImportReport>(step =>
|
||||||
|
{
|
||||||
|
report = step;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
report = await PortabilityService.Import(importKind, file, progress, stopping.Token);
|
||||||
|
importing = null;
|
||||||
|
stopping.Dispose();
|
||||||
|
stopping = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Stop() => stopping?.Cancel();
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
stopping?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The accounts the current persona muted or blocked (/api/v1/mutes, /api/v1/blocks), and the servers it blocked
|
||||||
|
(/api/v1/domain_blocks), each one undone here. A server blocked hides everything from it and removes the persona's
|
||||||
|
followers there. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-5 w-full">
|
||||||
|
<section class="flex flex-col gap-2" data-mutes>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Muted accounts"]</h3>
|
||||||
|
@if (mutes.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is muted."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in mutes)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unmute)">
|
||||||
|
@Localizer["Unmute"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (mutesMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreMutes">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked accounts"]</h3>
|
||||||
|
@if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in blocks)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
|
||||||
|
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unblock)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (blocksMaxId is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreBlocks">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-domain-blocks>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Blocked servers"]</h3>
|
||||||
|
<EditForm Model="draft" OnValidSubmit="BlockDomain" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Domain" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["A server, e.g. example.social"]" data-domain-new>
|
||||||
|
<Prefix><i class="ion-md-globe"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || string.IsNullOrWhiteSpace(draft.Domain) || busy.Contains(Domain(draft.Domain)))">
|
||||||
|
@Localizer["Block server"]
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
@if (domains.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var domain in domains)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-domain="@domain">
|
||||||
|
<i class="ion-md-globe opacity-70"></i>
|
||||||
|
<span class="flex-1 min-w-0 truncate">@domain</span>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(domain))" OnClick="() => UnblockDomain(domain)">
|
||||||
|
@Localizer["Unblock"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
sealed class DomainDraft
|
||||||
|
{
|
||||||
|
public string Domain { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Account> mutes = [];
|
||||||
|
List<Account> blocks = [];
|
||||||
|
List<string> domains = [];
|
||||||
|
string mutesMaxId;
|
||||||
|
string blocksMaxId;
|
||||||
|
DomainDraft draft = new();
|
||||||
|
// the accounts and servers whose undo is on its way
|
||||||
|
HashSet<string> busy = [];
|
||||||
|
string loadedPersonaId;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
mutes = [];
|
||||||
|
blocks = [];
|
||||||
|
mutesMaxId = null;
|
||||||
|
blocksMaxId = null;
|
||||||
|
await Task.WhenAll(MoreMutes(), MoreBlocks());
|
||||||
|
domains = await AccountService.DomainBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreMutes()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Mutes(mutesMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
mutes.AddRange(page.Accounts.Where(a => mutes.All(m => m.Id != a.Id)));
|
||||||
|
mutesMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task MoreBlocks()
|
||||||
|
{
|
||||||
|
var result = await AccountService.Blocks(blocksMaxId);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = (AccountPage)result.Data;
|
||||||
|
blocks.AddRange(page.Accounts.Where(a => blocks.All(b => b.Id != a.Id)));
|
||||||
|
blocksMaxId = page.MaxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Undo(Account account, AccountAction action)
|
||||||
|
{
|
||||||
|
busy.Add(account.Id);
|
||||||
|
var result = await AccountService.Act(account.Id, action);
|
||||||
|
busy.Remove(account.Id);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action is AccountAction.Unmute)
|
||||||
|
mutes.RemoveAll(m => m.Id == account.Id);
|
||||||
|
else
|
||||||
|
blocks.RemoveAll(b => b.Id == account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// what a person types as a server (a URL, a handle's host, any case) as the bare host PrivaPub keys it by
|
||||||
|
static string Domain(string typed)
|
||||||
|
{
|
||||||
|
var text = typed?.Trim().TrimStart('@') ?? "";
|
||||||
|
if (Uri.TryCreate(text.Contains("://") ? text : "https://" + text, UriKind.Absolute, out var uri))
|
||||||
|
text = uri.Host;
|
||||||
|
return text.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task BlockDomain()
|
||||||
|
{
|
||||||
|
var domain = Domain(draft.Domain);
|
||||||
|
if (domain.Length == 0 || domains.Contains(domain))
|
||||||
|
{
|
||||||
|
draft = new();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.BlockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft = new();
|
||||||
|
domains = [.. domains.Append(domain).Order()];
|
||||||
|
Toast.AddResponseSuccess(Localizer["{0} is blocked.", domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UnblockDomain(string domain)
|
||||||
|
{
|
||||||
|
busy.Add(domain);
|
||||||
|
var result = await AccountService.UnblockDomain(domain);
|
||||||
|
busy.Remove(domain);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domains.Remove(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
|
||||||
|
to one another: no server, PrivaPub's included, shows that they share a root. *@
|
||||||
|
<div class="flex flex-col gap-4 w-full" data-personas>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var persona in CascadingState.Personas)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
|
||||||
|
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
|
||||||
|
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
|
||||||
|
@if (persona.Id == CascadingState.PersonaId)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<b class="text-sm">@Localizer["A new persona"]</b>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.UserName" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-persona-username>
|
||||||
|
<Prefix>@@</Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["Display name"]" data-persona-name />
|
||||||
|
</div>
|
||||||
|
<DTextarea @bind-Value="form.Biography" Immediate="true" rows="3" autocomplete="off" maxlength="@ProfileForm.NoteLength"
|
||||||
|
placeholder="@Localizer["Biography"]" data-persona-biography />
|
||||||
|
<ErrorViewer />
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-persona-create>
|
||||||
|
<i class="ion-md-person-add"></i><span>@Localizer["Make the persona"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
|
||||||
|
InsertAvatarForm form = new();
|
||||||
|
bool isSaving;
|
||||||
|
string switching;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
async Task Use(ViewAvatar persona)
|
||||||
|
{
|
||||||
|
switching = persona.Id;
|
||||||
|
var result = await CascadingState.SwitchPersona(persona.Id);
|
||||||
|
switching = null;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
form.UserName = form.UserName?.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
var result = await AuthService.CreatePersona(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form = new();
|
||||||
|
await CascadingState.ReloadPersonas();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Persona made."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* The current persona's profile (/api/v1/accounts/update_credentials): its pictures, name, biography and fields as it
|
||||||
|
wrote them, whether it approves followers by hand, is a bot, is listed and searchable, and how its new posts start.
|
||||||
|
PrivaPub tells the persona's followers' servers of the change. *@
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (account is null)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||||
|
<div class="relative w-full">
|
||||||
|
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||||
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
|
||||||
|
alt="@Localizer["Avatar"]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Avatar"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Header"]</span>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 max-w-md">
|
||||||
|
<span class="text-sm">@Localizer["Display name"]</span>
|
||||||
|
<DInput TValue="string" @bind-Value="form.DisplayName" Immediate="true" autocomplete="off" maxlength="@ProfileForm.DisplayNameLength"
|
||||||
|
data-profile-name />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">@Localizer["Biography"]</span>
|
||||||
|
<DTextarea @bind-Value="form.Note" Immediate="true" rows="4" autocomplete="off" maxlength="@ProfileForm.NoteLength" data-profile-note />
|
||||||
|
<span class="text-xs opacity-70 tabular-nums self-end">@(form.Note?.Length ?? 0)/@ProfileForm.NoteLength</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Profile fields"]</legend>
|
||||||
|
@foreach (var field in form.Fields)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap gap-2" data-profile-field="@form.Fields.IndexOf(field)">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-36" @bind-Value="field.Name" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Label"]" />
|
||||||
|
<DInput TValue="string" class="flex-[2] min-w-48" @bind-Value="field.Value" Immediate="true" autocomplete="off"
|
||||||
|
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Content"]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<DToggle LabelText="@Localizer["Approve followers by hand"]" @bind-Value="form.Locked" data-profile-locked />
|
||||||
|
<DToggle LabelText="@Localizer["This persona is automated (a bot)"]" @bind-Value="form.Bot" />
|
||||||
|
<DToggle LabelText="@Localizer["List it in the directory and suggest it to others"]" @bind-Value="form.Discoverable" />
|
||||||
|
<DToggle LabelText="@Localizer["Let its public posts be found by search"]" @bind-Value="form.Indexable" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-4">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Visibility"]</span>
|
||||||
|
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||||
|
@foreach (var type in Visibilities)
|
||||||
|
{
|
||||||
|
<option value="@type">@Localizer[type.ToString()]</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Language"]</span>
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="form.Language">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach (var (languageCode, languageName) in Languages.All)
|
||||||
|
{
|
||||||
|
<option value="@languageCode">@languageName</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span>@Localizer["Who may quote them"]</span>
|
||||||
|
<DSelect TValue="QuotePolicy" Size="DSize.Sm" @bind-Value="form.QuotePolicy">
|
||||||
|
@foreach (var policy in Enum.GetValues<QuotePolicy>())
|
||||||
|
{
|
||||||
|
<option value="@policy">@QuotePolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</DSelect>
|
||||||
|
</label>
|
||||||
|
<DToggle class="self-end" LabelText="@Localizer["Mark media as sensitive"]" @bind-Value="form.Sensitive" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<ErrorViewer AdditionalErrors="pictureErrors" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-end">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving)">@Localizer["Reset"]</DButton>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-profile-save>
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] AuthService AuthService { get; set; }
|
||||||
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
|
// PrivaPub keeps pictures up to 20 MB and resizes them
|
||||||
|
const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||||
|
|
||||||
|
Account account;
|
||||||
|
ProfileForm form = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
List<string> pictureErrors = [];
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
// the account as the persona sees itself, with its Source
|
||||||
|
account = await AuthService.CurrentAccount();
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
form = account is null ? new() : ProfileForm.Of(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
string QuotePolicyName(QuotePolicy policy) => policy switch
|
||||||
|
{
|
||||||
|
QuotePolicy.Followers => Localizer["Its followers"],
|
||||||
|
QuotePolicy.Nobody => Localizer["Nobody"],
|
||||||
|
_ => Localizer["Anyone"]
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Pick(InputFileChangeEventArgs eventArgs, bool isAvatar)
|
||||||
|
{
|
||||||
|
pictureErrors = [];
|
||||||
|
var file = eventArgs.File;
|
||||||
|
if (file.Size > MaximumPictureSize)
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
|
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
|
||||||
|
await fileStream.CopyToAsync(memoryStream);
|
||||||
|
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||||
|
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
|
||||||
|
if (isAvatar)
|
||||||
|
form.Avatar = picture;
|
||||||
|
else
|
||||||
|
form.Header = picture;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
|
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.UpdateProfile(form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Reset();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Profile saved."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace decePubClient.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>The languages a post can be marked as written in: ISO 639-1 codes and each language's own name.</summary>
|
||||||
|
public static class Languages
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<(string Code, string Name)> All =
|
||||||
|
[
|
||||||
|
("ar", "العربية"), ("bg", "Български"), ("ca", "Català"), ("cs", "Čeština"), ("cy", "Cymraeg"), ("da", "Dansk"),
|
||||||
|
("de", "Deutsch"), ("el", "Ελληνικά"), ("en", "English"), ("eo", "Esperanto"), ("es", "Español"), ("et", "Eesti"),
|
||||||
|
("eu", "Euskara"), ("fa", "فارسی"), ("fi", "Suomi"), ("fr", "Français"), ("ga", "Gaeilge"), ("gl", "Galego"),
|
||||||
|
("he", "עברית"), ("hi", "हिन्दी"), ("hr", "Hrvatski"), ("hu", "Magyar"), ("id", "Bahasa Indonesia"), ("is", "Íslenska"),
|
||||||
|
("it", "Italiano"), ("ja", "日本語"), ("ko", "한국어"), ("lt", "Lietuvių"), ("lv", "Latviešu"), ("nl", "Nederlands"),
|
||||||
|
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"), ("ru", "Русский"), ("sk", "Slovenčina"),
|
||||||
|
("sl", "Slovenščina"), ("sr", "Српски"), ("sv", "Svenska"), ("th", "ไทย"), ("tr", "Türkçe"), ("uk", "Українська"),
|
||||||
|
("vi", "Tiếng Việt"), ("zh", "中文")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,29 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the root's personas again, after one was made.</summary>
|
||||||
|
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Personas = await AuthService.Personas(cancellationToken);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
|
||||||
|
/// chooser shows the new name and picture too.</summary>
|
||||||
|
public void PersonaChanged(Account account)
|
||||||
|
{
|
||||||
|
if (account is null || PersonaId is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Persona = account;
|
||||||
|
if (Personas.FirstOrDefault(p => p.Id == PersonaId) is { } persona)
|
||||||
|
{
|
||||||
|
persona.Name = account.DisplayName;
|
||||||
|
persona.PictureURL = account.Avatar;
|
||||||
|
}
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
||||||
public async Task SignOut(CancellationToken cancellationToken = default)
|
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(List<Account>))]
|
[JsonSerializable(typeof(List<Account>))]
|
||||||
[JsonSerializable(typeof(Relationship))]
|
[JsonSerializable(typeof(Relationship))]
|
||||||
[JsonSerializable(typeof(List<Relationship>))]
|
[JsonSerializable(typeof(List<Relationship>))]
|
||||||
|
[JsonSerializable(typeof(List<string>))]
|
||||||
[JsonSerializable(typeof(SearchResults))]
|
[JsonSerializable(typeof(SearchResults))]
|
||||||
[JsonSerializable(typeof(MediaAttachment))]
|
[JsonSerializable(typeof(MediaAttachment))]
|
||||||
[JsonSerializable(typeof(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ public static class APIs
|
|||||||
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
||||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
||||||
public const string GET_Personas = "clientapi/avatar/private/list";
|
public const string GET_Personas = "clientapi/avatar/private/list";
|
||||||
|
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||||
public const string GET_Groups = "clientapi/group/list";
|
public const string GET_Groups = "clientapi/group/list";
|
||||||
public const string GET_Posts = "clientapi/post/list";
|
public const string GET_Posts = "clientapi/post/list";
|
||||||
public const string GET_Nearby = "clientapi/post/nearby";
|
public const string GET_Nearby = "clientapi/post/nearby";
|
||||||
public const string POST_InsertPost = "clientapi/post/insert";
|
public const string POST_InsertPost = "clientapi/post/insert";
|
||||||
|
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||||
|
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
||||||
|
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
|
||||||
|
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
|
||||||
|
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
|
||||||
|
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||||
|
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||||
|
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class OAuth
|
public static class OAuth
|
||||||
@@ -36,6 +45,12 @@ public static class APIs
|
|||||||
public static class Mastodon
|
public static class Mastodon
|
||||||
{
|
{
|
||||||
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
|
||||||
|
public const string PATCH_UpdateCredentials = "api/v1/accounts/update_credentials";
|
||||||
|
public const string GET_Mutes = "api/v1/mutes";
|
||||||
|
public const string GET_Blocks = "api/v1/blocks";
|
||||||
|
public const string GET_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string POST_DomainBlocks = "api/v1/domain_blocks";
|
||||||
|
public const string DELETE_DomainBlocks = "api/v1/domain_blocks";
|
||||||
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
public const string GET_HomeTimeline = "api/v1/timelines/home";
|
||||||
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
public const string GET_PublicTimeline = "api/v1/timelines/public";
|
||||||
public const string GET_Statuses = "api/v1/statuses";
|
public const string GET_Statuses = "api/v1/statuses";
|
||||||
@@ -43,6 +58,8 @@ public static class APIs
|
|||||||
public const string GET_Favourites = "api/v1/favourites";
|
public const string GET_Favourites = "api/v1/favourites";
|
||||||
public const string GET_Bookmarks = "api/v1/bookmarks";
|
public const string GET_Bookmarks = "api/v1/bookmarks";
|
||||||
public const string GET_Conversations = "api/v1/conversations";
|
public const string GET_Conversations = "api/v1/conversations";
|
||||||
|
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
|
||||||
|
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||||
public const string GET_Notifications = "api/v1/notifications";
|
public const string GET_Notifications = "api/v1/notifications";
|
||||||
public const string POST_Media = "api/v2/media";
|
public const string POST_Media = "api/v2/media";
|
||||||
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
@@ -53,6 +70,7 @@ public static class APIs
|
|||||||
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
|
||||||
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
|
||||||
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
|
||||||
|
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
|
||||||
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
|
||||||
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ public class Account
|
|||||||
public string LastStatusAt { get; set; }
|
public string LastStatusAt { get; set; }
|
||||||
public List<CustomEmoji> Emojis { get; set; } = [];
|
public List<CustomEmoji> Emojis { get; set; } = [];
|
||||||
public List<AccountField> Fields { get; set; } = [];
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>Whether it is listed in the directory and suggested; null when its server does not say.</summary>
|
||||||
|
public bool? Discoverable { get; set; }
|
||||||
|
/// <summary>Whether its public posts may be found by full-text search.</summary>
|
||||||
|
public bool? Indexable { get; set; }
|
||||||
|
/// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its
|
||||||
|
/// posting defaults.</summary>
|
||||||
|
public AccountSource Source { get; set; }
|
||||||
|
|
||||||
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
/// <summary>The account's server: the host of its ActivityPub id.</summary>
|
||||||
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
|
||||||
@@ -33,6 +40,27 @@ public class Account
|
|||||||
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AccountSource
|
||||||
|
{
|
||||||
|
/// <summary>The biography as written, before PrivaPub renders it.</summary>
|
||||||
|
public string Note { get; set; }
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
/// <summary>The default visibility of a new post: public, unlisted or private.</summary>
|
||||||
|
public string Privacy { get; set; }
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
/// <summary>Who may quote the persona's posts: public, followers or nobody.</summary>
|
||||||
|
public string QuotePolicy { get; set; }
|
||||||
|
public int FollowRequestsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page of accounts (mutes, blocks), and the cursor of the next one: null at the end.</summary>
|
||||||
|
public class AccountPage
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public string MaxId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class AccountField
|
public class AccountField
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@@ -55,6 +83,9 @@ public class Relationship
|
|||||||
public bool Blocking { get; set; }
|
public bool Blocking { get; set; }
|
||||||
public bool BlockedBy { get; set; }
|
public bool BlockedBy { get; set; }
|
||||||
public bool Muting { get; set; }
|
public bool Muting { get; set; }
|
||||||
|
public bool MutingNotifications { get; set; }
|
||||||
|
public bool ShowingReblogs { get; set; }
|
||||||
|
public bool Notifying { get; set; }
|
||||||
public bool Requested { get; set; }
|
public bool Requested { get; set; }
|
||||||
public bool RequestedBy { get; set; }
|
public bool RequestedBy { get; set; }
|
||||||
public bool DomainBlocking { get; set; }
|
public bool DomainBlocking { get; set; }
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public class PrivaPubStatus
|
|||||||
{
|
{
|
||||||
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
|
||||||
public string ObjectType { get; set; }
|
public string ObjectType { get; set; }
|
||||||
|
/// <summary>Its community's moderators locked it: PrivaPub refuses a reply.</summary>
|
||||||
|
public bool Locked { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Excerpt { get; set; }
|
public string Excerpt { get; set; }
|
||||||
public string Cover { get; set; }
|
public string Cover { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
namespace decePubClient.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The persona's profile as Settings edits it, which is what update_credentials takes: the words as written (its
|
||||||
|
/// account's Source), the flags, the posting defaults, and a new avatar or header when one was picked. Fields always
|
||||||
|
/// holds FieldCount rows; a row without a name is dropped.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfileForm
|
||||||
|
{
|
||||||
|
public const int FieldCount = 4;
|
||||||
|
public const int DisplayNameLength = 30;
|
||||||
|
public const int NoteLength = 500;
|
||||||
|
public const int FieldLength = 255;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
public string Note { get; set; }
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
public bool Bot { get; set; }
|
||||||
|
public bool Discoverable { get; set; }
|
||||||
|
public bool Indexable { get; set; }
|
||||||
|
public MessageType Privacy { get; set; } = MessageType.Public;
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
||||||
|
public List<AccountField> Fields { get; set; } = [];
|
||||||
|
public UploadMedia Avatar { get; set; }
|
||||||
|
public UploadMedia Header { get; set; }
|
||||||
|
|
||||||
|
public static ProfileForm Of(Account account)
|
||||||
|
{
|
||||||
|
var source = account.Source ?? new();
|
||||||
|
var fields = source.Fields.Count > 0 ? source.Fields : account.Fields;
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
DisplayName = account.DisplayName,
|
||||||
|
Note = source.Note,
|
||||||
|
Locked = account.Locked,
|
||||||
|
Bot = account.Bot,
|
||||||
|
Discoverable = account.Discoverable == true,
|
||||||
|
Indexable = account.Indexable == true,
|
||||||
|
Privacy = source.Privacy switch
|
||||||
|
{
|
||||||
|
"private" => MessageType.FollowersOnly,
|
||||||
|
"unlisted" => MessageType.Unlisted,
|
||||||
|
_ => MessageType.Public
|
||||||
|
},
|
||||||
|
Sensitive = source.Sensitive,
|
||||||
|
Language = source.Language,
|
||||||
|
QuotePolicy = source.QuotePolicy switch
|
||||||
|
{
|
||||||
|
"followers" => QuotePolicy.Followers,
|
||||||
|
"nobody" => QuotePolicy.Nobody,
|
||||||
|
_ => QuotePolicy.Public
|
||||||
|
},
|
||||||
|
Fields = [.. fields.Take(FieldCount).Select(f => new AccountField { Name = f.Name, Value = f.Value }),
|
||||||
|
.. Enumerable.Range(0, Math.Max(0, FieldCount - fields.Count)).Select(_ => new AccountField())]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form as update_credentials reads it, with the pictures as files.</summary>
|
||||||
|
public MultipartFormDataContent Content()
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ new StringContent(DisplayName?.Trim() ?? ""), "display_name" },
|
||||||
|
{ new StringContent(Note ?? ""), "note" },
|
||||||
|
{ new StringContent(Flag(Locked)), "locked" },
|
||||||
|
{ new StringContent(Flag(Bot)), "bot" },
|
||||||
|
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
||||||
|
{ new StringContent(Flag(Indexable)), "indexable" },
|
||||||
|
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||||
|
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
||||||
|
{ new StringContent(Language ?? ""), "source[language]" },
|
||||||
|
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
||||||
|
};
|
||||||
|
// every row is sent, so a row emptied here is removed there
|
||||||
|
for (var i = 0; i < Fields.Count; i++)
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(Fields[i].Name?.Trim() ?? ""), $"fields_attributes[{i}][name]");
|
||||||
|
content.Add(new StringContent(Fields[i].Value?.Trim() ?? ""), $"fields_attributes[{i}][value]");
|
||||||
|
}
|
||||||
|
if (Avatar is not null)
|
||||||
|
content.Add(Picture(Avatar), "avatar", Avatar.FileName);
|
||||||
|
if (Header is not null)
|
||||||
|
content.Add(Picture(Header), "header", Header.FileName);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
static ByteArrayContent Picture(UploadMedia media)
|
||||||
|
{
|
||||||
|
var picture = new ByteArrayContent(media.Blob);
|
||||||
|
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Who may quote a persona's posts (Mastodon's quote_policy).</summary>
|
||||||
|
public enum QuotePolicy
|
||||||
|
{
|
||||||
|
Public = 1,
|
||||||
|
Followers,
|
||||||
|
Nobody
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
@page "/administration"
|
@page "/administration"
|
||||||
|
@attribute [Authorize(Policy = Policies.IsModerator)]
|
||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
<Title>@Localizer["Settings"]</Title>
|
<Title>@Localizer["Administration"]</Title>
|
||||||
|
|
||||||
|
@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. The
|
||||||
|
other sections wait for PrivaPub endpoints that do not exist yet. *@
|
||||||
|
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
|
||||||
@@ -25,18 +29,44 @@
|
|||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Reports"]</span>
|
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
|
||||||
</p>
|
</p>
|
||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
<ReportsPanel />
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<AuthorizeView Policy="@Policies.IsAdmin">
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<ServerBlocksPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<StatisticsPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
||||||
The cards do their own actions; a reply or a deletion updates the thread here. The persona's thread filters apply
|
The cards do their own actions; a reply or a deletion updates the thread here. The persona's thread filters apply
|
||||||
to the posts around the one it opened, never to that one. *@
|
to the posts around the one it opened, never to that one. Opening a remote thread makes PrivaPub read its replies
|
||||||
|
from their servers, so the thread is read twice more, a few seconds later, and what was found joins it. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
@@ -54,10 +55,14 @@
|
|||||||
|
|
||||||
[Parameter] public string StatusId { get; set; }
|
[Parameter] public string StatusId { get; set; }
|
||||||
|
|
||||||
|
// when the thread is read again, after PrivaPub has had time to fetch the replies held on other servers
|
||||||
|
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
|
||||||
|
|
||||||
FeedPost current;
|
FeedPost current;
|
||||||
List<FeedPost> ancestors = [];
|
List<FeedPost> ancestors = [];
|
||||||
List<FeedPost> descendants = [];
|
List<FeedPost> descendants = [];
|
||||||
string loadedId;
|
string loadedId;
|
||||||
|
CancellationTokenSource rereading;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -78,6 +83,9 @@
|
|||||||
|
|
||||||
async Task LoadData()
|
async Task LoadData()
|
||||||
{
|
{
|
||||||
|
rereading?.Cancel();
|
||||||
|
rereading?.Dispose();
|
||||||
|
rereading = null;
|
||||||
IsLoading = true;
|
IsLoading = true;
|
||||||
var status = await StatusService.Get(StatusId);
|
var status = await StatusService.Get(StatusId);
|
||||||
if (status is null)
|
if (status is null)
|
||||||
@@ -96,6 +104,42 @@
|
|||||||
var all = ancestors.Append(current).Concat(descendants).ToList();
|
var all = ancestors.Append(current).Concat(descendants).ToList();
|
||||||
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
|
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
InstanceService.Locate(all);
|
InstanceService.Locate(all);
|
||||||
|
|
||||||
|
if (new[] { current, ancestors.FirstOrDefault() }.Any(p => p is not null && p.Host != InstanceService.SelfHost))
|
||||||
|
{
|
||||||
|
rereading = new();
|
||||||
|
_ = Reread(status.Id, rereading.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the answers PrivaPub found since, in the thread's order; the cards already shown stay as they are, and one the
|
||||||
|
// thread no longer lists (a reply just made, not yet in it) stays at the end
|
||||||
|
async Task Reread(string statusId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var pause in Rereads)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(pause, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var context = await StatusService.Context(statusId, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested || context is null)
|
||||||
|
return;
|
||||||
|
var shown = descendants.ToDictionary(p => p.Key);
|
||||||
|
var found = context.Descendants.Where(s => !shown.ContainsKey(s.Id)).Select(FeedPost.Of).ToList();
|
||||||
|
if (found.Count == 0)
|
||||||
|
continue;
|
||||||
|
var listed = context.Descendants.Select(s => s.Id).ToHashSet();
|
||||||
|
descendants = [.. context.Descendants.Select(s => shown.GetValueOrDefault(s.Id) ?? found.First(p => p.Key == s.Id)),
|
||||||
|
.. descendants.Where(p => !listed.Contains(p.Key))];
|
||||||
|
await InstanceService.Resolve(found.Select(p => p.Host));
|
||||||
|
InstanceService.Locate(found);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Remove(FeedPost post)
|
void Remove(FeedPost post)
|
||||||
@@ -128,4 +172,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Task Back() => await JS.InvokeVoidAsync("history.back");
|
async Task Back() => await JS.InvokeVoidAsync("history.back");
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (rereading is not null)
|
||||||
|
{
|
||||||
|
await rereading.CancelAsync();
|
||||||
|
rereading.Dispose();
|
||||||
|
}
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+18
-3
@@ -18,7 +18,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
||||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||||
OnChanged="ChangeFeed" />
|
OnChanged="ChangeFeed" />
|
||||||
|
|
||||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
List<ViewGroup> groups = [];
|
List<ViewGroup> groups = [];
|
||||||
List<MastodonList> lists = [];
|
List<MastodonList> lists = [];
|
||||||
int unreadNotifications;
|
int unreadNotifications;
|
||||||
|
int unreadConversations;
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
string loadError;
|
string loadError;
|
||||||
@@ -299,7 +300,14 @@
|
|||||||
{
|
{
|
||||||
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
if (IsLoading || CascadingState.IsOffline || minId is null || Streaming.IsOpen || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|
// no stream tells of direct messages: their tab's badge is counted again on every tick
|
||||||
|
if (!IsLoading && CascadingState.IsOnline && Filter.TimelineType is not TimelineType.Direct
|
||||||
|
&& await TimelineService.UnreadConversations(cancellationToken) is var unread && unread != unreadConversations)
|
||||||
|
{
|
||||||
|
unreadConversations = unread;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
if (IsLoading || CascadingState.IsOffline || minId is null || Streaming.IsOpen || Filter.TimelineType is TimelineType.Nearby
|
||||||
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
||||||
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
||||||
continue;
|
continue;
|
||||||
@@ -311,9 +319,15 @@
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
||||||
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
|
var page = ((FeedPage)result.Data).Posts;
|
||||||
|
// a conversation with a newer message is the same item, moved up with its new message
|
||||||
|
var newer = Filter.TimelineType is TimelineType.Direct
|
||||||
|
? page.Where(p => posts.Concat(pending).All(shown => shown.Key != p.Key || shown.CursorId != p.CursorId)).ToList()
|
||||||
|
: page.Where(p => known.Add(p.Key)).ToList();
|
||||||
if (newer.Count == 0)
|
if (newer.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
posts.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
||||||
|
pending.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
||||||
|
|
||||||
pending.InsertRange(0, newer);
|
pending.InsertRange(0, newer);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -335,6 +349,7 @@
|
|||||||
/// counts what is unread.</summary>
|
/// counts what is unread.</summary>
|
||||||
async Task ReadNotifications(CancellationToken cancellationToken)
|
async Task ReadNotifications(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
|
||||||
if (Filter.TimelineType is not TimelineType.Notifications)
|
if (Filter.TimelineType is not TimelineType.Notifications)
|
||||||
{
|
{
|
||||||
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
|
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
|
||||||
|
|||||||
+6
-1
@@ -112,7 +112,12 @@
|
|||||||
isSearching = false;
|
isSearching = false;
|
||||||
await InstanceService.Resolve(statuses.Select(p => p.Host).Append(InstanceService.SelfHost));
|
await InstanceService.Resolve(statuses.Select(p => p.Host).Append(InstanceService.SelfHost));
|
||||||
InstanceService.Locate(statuses);
|
InstanceService.Locate(statuses);
|
||||||
if (results.Accounts.Count == 1 && results.Statuses.Count == 0 && form.Query.Contains('@'))
|
// a handle opens its account's profile, whatever else matched
|
||||||
|
var handle = form.Query.Trim().TrimStart('@');
|
||||||
|
if (form.Query.Contains('@') && results.Accounts.FirstOrDefault(a => string.Equals(a.Acct, handle, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals($"{a.Acct}@{InstanceService.SelfHost}", handle, StringComparison.OrdinalIgnoreCase)) is { } exact)
|
||||||
|
Navigation.NavigateTo($"account/{Uri.EscapeDataString(exact.Id)}");
|
||||||
|
else if (results.Accounts.Count == 1 && results.Statuses.Count == 0 && form.Query.Contains('@'))
|
||||||
Navigation.NavigateTo($"account/{Uri.EscapeDataString(results.Accounts[0].Id)}");
|
Navigation.NavigateTo($"account/{Uri.EscapeDataString(results.Accounts[0].Id)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-9
@@ -60,6 +60,26 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-people text-xl"></i> <span>@Localizer["Personas"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
@if (CascadingState.IsSignedIn)
|
||||||
|
{
|
||||||
|
<PersonasEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
@@ -68,9 +88,14 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
</p>
|
<ProfileEditor />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
@@ -83,9 +108,14 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
</p>
|
<DataTransfer />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
@@ -98,9 +128,14 @@
|
|||||||
</Header>
|
</Header>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
<p class="w-full text-center text-lg">
|
@if (CascadingState.IsSignedIn)
|
||||||
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
{
|
||||||
</p>
|
<MutesAndBlocks />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<TimelineService>()
|
.AddScoped<TimelineService>()
|
||||||
.AddScoped<StatusService>()
|
.AddScoped<StatusService>()
|
||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
|
.AddScoped<PortabilityService>()
|
||||||
|
.AddScoped<AdminService>()
|
||||||
.AddScoped<ListService>()
|
.AddScoped<ListService>()
|
||||||
.AddScoped<FilterService>()
|
.AddScoped<FilterService>()
|
||||||
.AddScoped<DiscoveryService>()
|
.AddScoped<DiscoveryService>()
|
||||||
|
|||||||
@@ -1268,4 +1268,286 @@
|
|||||||
<data name="Saved." xml:space="preserve">
|
<data name="Saved." xml:space="preserve">
|
||||||
<value>Salvato.</value>
|
<value>Salvato.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Header" xml:space="preserve">
|
||||||
|
<value>Intestazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Avatar" xml:space="preserve">
|
||||||
|
<value>Avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Display name" xml:space="preserve">
|
||||||
|
<value>Nome visualizzato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Biography" xml:space="preserve">
|
||||||
|
<value>Biografia</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile fields" xml:space="preserve">
|
||||||
|
<value>Campi del profilo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Label" xml:space="preserve">
|
||||||
|
<value>Etichetta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Content" xml:space="preserve">
|
||||||
|
<value>Contenuto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Approve followers by hand" xml:space="preserve">
|
||||||
|
<value>Approva i follower a mano</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona is automated (a bot)" xml:space="preserve">
|
||||||
|
<value>Questa persona è automatizzata (un bot)</value>
|
||||||
|
</data>
|
||||||
|
<data name="List it in the directory and suggest it to others" xml:space="preserve">
|
||||||
|
<value>Elencala nella directory e suggeriscila agli altri</value>
|
||||||
|
</data>
|
||||||
|
<data name="Let its public posts be found by search" xml:space="preserve">
|
||||||
|
<value>Lascia che i suoi post pubblici si trovino con la ricerca</value>
|
||||||
|
</data>
|
||||||
|
<data name="New posts" xml:space="preserve">
|
||||||
|
<value>Nuovi post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Visibility" xml:space="preserve">
|
||||||
|
<value>Visibilità</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote them" xml:space="preserve">
|
||||||
|
<value>Chi può citarli</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mark media as sensitive" xml:space="preserve">
|
||||||
|
<value>Segna i media come sensibili</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its followers" xml:space="preserve">
|
||||||
|
<value>I suoi follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody" xml:space="preserve">
|
||||||
|
<value>Nessuno</value>
|
||||||
|
</data>
|
||||||
|
<data name="Anyone" xml:space="preserve">
|
||||||
|
<value>Chiunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile saved." xml:space="preserve">
|
||||||
|
<value>Profilo salvato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Muted accounts" xml:space="preserve">
|
||||||
|
<value>Account silenziati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is muted." xml:space="preserve">
|
||||||
|
<value>Nessuno è silenziato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocked accounts" xml:space="preserve">
|
||||||
|
<value>Account bloccati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is blocked." xml:space="preserve">
|
||||||
|
<value>Nessuno è bloccato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocked servers" xml:space="preserve">
|
||||||
|
<value>Server bloccati</value>
|
||||||
|
</data>
|
||||||
|
<data name="A server, e.g. example.social" xml:space="preserve">
|
||||||
|
<value>Un server, ad es. example.social</value>
|
||||||
|
</data>
|
||||||
|
<data name="Block server" xml:space="preserve">
|
||||||
|
<value>Blocca il server</value>
|
||||||
|
</data>
|
||||||
|
<data name="No server is blocked." xml:space="preserve">
|
||||||
|
<value>Nessun server è bloccato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} is blocked." xml:space="preserve">
|
||||||
|
<value>{0} è bloccato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export" xml:space="preserve">
|
||||||
|
<value>Esporta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Files in Mastodon's format, which most fediverse servers import." xml:space="preserve">
|
||||||
|
<value>File nel formato di Mastodon, che la maggior parte dei server del fediverso importa.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Import" xml:space="preserve">
|
||||||
|
<value>Importa</value>
|
||||||
|
</data>
|
||||||
|
<data name="What the file holds" xml:space="preserve">
|
||||||
|
<value>Cosa contiene il file</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stop" xml:space="preserve">
|
||||||
|
<value>Ferma</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of {1} done, {2} could not be." xml:space="preserve">
|
||||||
|
<value>{0} di {1} fatti, {2} non è stato possibile.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follows" xml:space="preserve">
|
||||||
|
<value>Account seguiti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show the resolved ones" xml:space="preserve">
|
||||||
|
<value>Mostra quelle risolte</value>
|
||||||
|
</data>
|
||||||
|
<data name="No resolved report." xml:space="preserve">
|
||||||
|
<value>Nessuna segnalazione risolta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No open report." xml:space="preserve">
|
||||||
|
<value>Nessuna segnalazione aperta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Forwarded" xml:space="preserve">
|
||||||
|
<value>Inoltrata</value>
|
||||||
|
</data>
|
||||||
|
<data name="Resolve" xml:space="preserve">
|
||||||
|
<value>Risolvi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Resolved {0}" xml:space="preserve">
|
||||||
|
<value>Risolta {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="About" xml:space="preserve">
|
||||||
|
<value>Riguardo a</value>
|
||||||
|
</data>
|
||||||
|
<data name="From" xml:space="preserve">
|
||||||
|
<value>Da</value>
|
||||||
|
</data>
|
||||||
|
<data name="someone here" xml:space="preserve">
|
||||||
|
<value>qualcuno di qui</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post {0}" xml:space="preserve">
|
||||||
|
<value>Post {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Spam" xml:space="preserve">
|
||||||
|
<value>Spam</value>
|
||||||
|
</data>
|
||||||
|
<data name="Illegal" xml:space="preserve">
|
||||||
|
<value>Illegale</value>
|
||||||
|
</data>
|
||||||
|
<data name="Breaks the rules" xml:space="preserve">
|
||||||
|
<value>Viola le regole</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report resolved." xml:space="preserve">
|
||||||
|
<value>Segnalazione risolta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Suspend (otherwise silence)" xml:space="preserve">
|
||||||
|
<value>Sospendi (altrimenti silenzia)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refuse its media" xml:space="preserve">
|
||||||
|
<value>Rifiuta i suoi media</value>
|
||||||
|
</data>
|
||||||
|
<data name="Why, for everyone to read" xml:space="preserve">
|
||||||
|
<value>Perché, visibile a tutti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Why, for the admins only" xml:space="preserve">
|
||||||
|
<value>Perché, solo per gli amministratori</value>
|
||||||
|
</data>
|
||||||
|
<data name="Suspended" xml:space="preserve">
|
||||||
|
<value>Sospeso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Silenced" xml:space="preserve">
|
||||||
|
<value>Silenziato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media refused" xml:space="preserve">
|
||||||
|
<value>Media rifiutati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Period" xml:space="preserve">
|
||||||
|
<value>Periodo</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} days" xml:space="preserve">
|
||||||
|
<value>{0} giorni</value>
|
||||||
|
</data>
|
||||||
|
<data name="Servers by" xml:space="preserve">
|
||||||
|
<value>Server per</value>
|
||||||
|
</data>
|
||||||
|
<data name="Traffic" xml:space="preserve">
|
||||||
|
<value>Traffico</value>
|
||||||
|
</data>
|
||||||
|
<data name="Failures" xml:space="preserve">
|
||||||
|
<value>Errori</value>
|
||||||
|
</data>
|
||||||
|
<data name="Latency" xml:space="preserve">
|
||||||
|
<value>Latenza</value>
|
||||||
|
</data>
|
||||||
|
<data name="Name" xml:space="preserve">
|
||||||
|
<value>Nome</value>
|
||||||
|
</data>
|
||||||
|
<data name="The statistics could not be read." xml:space="preserve">
|
||||||
|
<value>Non è stato possibile leggere le statistiche.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Deliveries that arrived" xml:space="preserve">
|
||||||
|
<value>Consegne arrivate</value>
|
||||||
|
</data>
|
||||||
|
<data name="p50 {0} ms, p95 {1} ms" xml:space="preserve">
|
||||||
|
<value>p50 {0} ms, p95 {1} ms</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} known" xml:space="preserve">
|
||||||
|
<value>{0} conosciuti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Accounts seen" xml:space="preserve">
|
||||||
|
<value>Account visti</value>
|
||||||
|
</data>
|
||||||
|
<data name="ledger {0} written, {1} dropped" xml:space="preserve">
|
||||||
|
<value>registro: {0} scritti, {1} scartati</value>
|
||||||
|
</data>
|
||||||
|
<data name="In" xml:space="preserve">
|
||||||
|
<value>Ricevuti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Out" xml:space="preserve">
|
||||||
|
<value>Inviati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refused" xml:space="preserve">
|
||||||
|
<value>Rifiutati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delivery" xml:space="preserve">
|
||||||
|
<value>Consegna</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} failing" xml:space="preserve">
|
||||||
|
<value>{0} falliti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Previous page" xml:space="preserve">
|
||||||
|
<value>Pagina precedente</value>
|
||||||
|
</data>
|
||||||
|
<data name="Next page" xml:space="preserve">
|
||||||
|
<value>Pagina successiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Crawler" xml:space="preserve">
|
||||||
|
<value>Crawler</value>
|
||||||
|
</data>
|
||||||
|
<data name="on" xml:space="preserve">
|
||||||
|
<value>attivo</value>
|
||||||
|
</data>
|
||||||
|
<data name="off" xml:space="preserve">
|
||||||
|
<value>spento</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} servers touched, {1} only crawled, {2} due, {3} refuse robots" xml:space="preserve">
|
||||||
|
<value>{0} server contattati, {1} solo esplorati, {2} da rivisitare, {3} rifiutano i robot</value>
|
||||||
|
</data>
|
||||||
|
<data name="Received" xml:space="preserve">
|
||||||
|
<value>Ricevute</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delivered" xml:space="preserve">
|
||||||
|
<value>Consegnate</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channels" xml:space="preserve">
|
||||||
|
<value>Canali</value>
|
||||||
|
</data>
|
||||||
|
<data name="Statistics" xml:space="preserve">
|
||||||
|
<value>Statistiche</value>
|
||||||
|
</data>
|
||||||
|
<data name="unread" xml:space="preserve">
|
||||||
|
<value>non letto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mark as read" xml:space="preserve">
|
||||||
|
<value>Segna come letto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Personas" xml:space="preserve">
|
||||||
|
<value>Persone</value>
|
||||||
|
</data>
|
||||||
|
<data name="in use" xml:space="preserve">
|
||||||
|
<value>in uso</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use" xml:space="preserve">
|
||||||
|
<value>Usa</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new persona" xml:space="preserve">
|
||||||
|
<value>Una nuova persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="user name (a-z, 0-9, _)" xml:space="preserve">
|
||||||
|
<value>nome utente (a-z, 0-9, _)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make the persona" xml:space="preserve">
|
||||||
|
<value>Crea la persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Persona made." xml:space="preserve">
|
||||||
|
<value>Persona creata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Locked by its community's moderators" xml:space="preserve">
|
||||||
|
<value>Bloccato dai moderatori della sua comunità</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -1268,4 +1268,286 @@
|
|||||||
<data name="Saved." xml:space="preserve">
|
<data name="Saved." xml:space="preserve">
|
||||||
<value>Saved.</value>
|
<value>Saved.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Header" xml:space="preserve">
|
||||||
|
<value>Header</value>
|
||||||
|
</data>
|
||||||
|
<data name="Avatar" xml:space="preserve">
|
||||||
|
<value>Avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Display name" xml:space="preserve">
|
||||||
|
<value>Display name</value>
|
||||||
|
</data>
|
||||||
|
<data name="Biography" xml:space="preserve">
|
||||||
|
<value>Biography</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile fields" xml:space="preserve">
|
||||||
|
<value>Profile fields</value>
|
||||||
|
</data>
|
||||||
|
<data name="Label" xml:space="preserve">
|
||||||
|
<value>Label</value>
|
||||||
|
</data>
|
||||||
|
<data name="Content" xml:space="preserve">
|
||||||
|
<value>Content</value>
|
||||||
|
</data>
|
||||||
|
<data name="Approve followers by hand" xml:space="preserve">
|
||||||
|
<value>Approve followers by hand</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona is automated (a bot)" xml:space="preserve">
|
||||||
|
<value>This persona is automated (a bot)</value>
|
||||||
|
</data>
|
||||||
|
<data name="List it in the directory and suggest it to others" xml:space="preserve">
|
||||||
|
<value>List it in the directory and suggest it to others</value>
|
||||||
|
</data>
|
||||||
|
<data name="Let its public posts be found by search" xml:space="preserve">
|
||||||
|
<value>Let its public posts be found by search</value>
|
||||||
|
</data>
|
||||||
|
<data name="New posts" xml:space="preserve">
|
||||||
|
<value>New posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Visibility" xml:space="preserve">
|
||||||
|
<value>Visibility</value>
|
||||||
|
</data>
|
||||||
|
<data name="Who may quote them" xml:space="preserve">
|
||||||
|
<value>Who may quote them</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mark media as sensitive" xml:space="preserve">
|
||||||
|
<value>Mark media as sensitive</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its followers" xml:space="preserve">
|
||||||
|
<value>Its followers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody" xml:space="preserve">
|
||||||
|
<value>Nobody</value>
|
||||||
|
</data>
|
||||||
|
<data name="Anyone" xml:space="preserve">
|
||||||
|
<value>Anyone</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile saved." xml:space="preserve">
|
||||||
|
<value>Profile saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Muted accounts" xml:space="preserve">
|
||||||
|
<value>Muted accounts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is muted." xml:space="preserve">
|
||||||
|
<value>Nobody is muted.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocked accounts" xml:space="preserve">
|
||||||
|
<value>Blocked accounts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is blocked." xml:space="preserve">
|
||||||
|
<value>Nobody is blocked.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocked servers" xml:space="preserve">
|
||||||
|
<value>Blocked servers</value>
|
||||||
|
</data>
|
||||||
|
<data name="A server, e.g. example.social" xml:space="preserve">
|
||||||
|
<value>A server, e.g. example.social</value>
|
||||||
|
</data>
|
||||||
|
<data name="Block server" xml:space="preserve">
|
||||||
|
<value>Block server</value>
|
||||||
|
</data>
|
||||||
|
<data name="No server is blocked." xml:space="preserve">
|
||||||
|
<value>No server is blocked.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} is blocked." xml:space="preserve">
|
||||||
|
<value>{0} is blocked.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export" xml:space="preserve">
|
||||||
|
<value>Export</value>
|
||||||
|
</data>
|
||||||
|
<data name="Files in Mastodon's format, which most fediverse servers import." xml:space="preserve">
|
||||||
|
<value>Files in Mastodon's format, which most fediverse servers import.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Import" xml:space="preserve">
|
||||||
|
<value>Import</value>
|
||||||
|
</data>
|
||||||
|
<data name="What the file holds" xml:space="preserve">
|
||||||
|
<value>What the file holds</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stop" xml:space="preserve">
|
||||||
|
<value>Stop</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of {1} done, {2} could not be." xml:space="preserve">
|
||||||
|
<value>{0} of {1} done, {2} could not be.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follows" xml:space="preserve">
|
||||||
|
<value>Follows</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show the resolved ones" xml:space="preserve">
|
||||||
|
<value>Show the resolved ones</value>
|
||||||
|
</data>
|
||||||
|
<data name="No resolved report." xml:space="preserve">
|
||||||
|
<value>No resolved report.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No open report." xml:space="preserve">
|
||||||
|
<value>No open report.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Forwarded" xml:space="preserve">
|
||||||
|
<value>Forwarded</value>
|
||||||
|
</data>
|
||||||
|
<data name="Resolve" xml:space="preserve">
|
||||||
|
<value>Resolve</value>
|
||||||
|
</data>
|
||||||
|
<data name="Resolved {0}" xml:space="preserve">
|
||||||
|
<value>Resolved {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="About" xml:space="preserve">
|
||||||
|
<value>About</value>
|
||||||
|
</data>
|
||||||
|
<data name="From" xml:space="preserve">
|
||||||
|
<value>From</value>
|
||||||
|
</data>
|
||||||
|
<data name="someone here" xml:space="preserve">
|
||||||
|
<value>someone here</value>
|
||||||
|
</data>
|
||||||
|
<data name="Post {0}" xml:space="preserve">
|
||||||
|
<value>Post {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Spam" xml:space="preserve">
|
||||||
|
<value>Spam</value>
|
||||||
|
</data>
|
||||||
|
<data name="Illegal" xml:space="preserve">
|
||||||
|
<value>Illegal</value>
|
||||||
|
</data>
|
||||||
|
<data name="Breaks the rules" xml:space="preserve">
|
||||||
|
<value>Breaks the rules</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report resolved." xml:space="preserve">
|
||||||
|
<value>Report resolved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Suspend (otherwise silence)" xml:space="preserve">
|
||||||
|
<value>Suspend (otherwise silence)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refuse its media" xml:space="preserve">
|
||||||
|
<value>Refuse its media</value>
|
||||||
|
</data>
|
||||||
|
<data name="Why, for everyone to read" xml:space="preserve">
|
||||||
|
<value>Why, for everyone to read</value>
|
||||||
|
</data>
|
||||||
|
<data name="Why, for the admins only" xml:space="preserve">
|
||||||
|
<value>Why, for the admins only</value>
|
||||||
|
</data>
|
||||||
|
<data name="Suspended" xml:space="preserve">
|
||||||
|
<value>Suspended</value>
|
||||||
|
</data>
|
||||||
|
<data name="Silenced" xml:space="preserve">
|
||||||
|
<value>Silenced</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media refused" xml:space="preserve">
|
||||||
|
<value>Media refused</value>
|
||||||
|
</data>
|
||||||
|
<data name="Period" xml:space="preserve">
|
||||||
|
<value>Period</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} days" xml:space="preserve">
|
||||||
|
<value>{0} days</value>
|
||||||
|
</data>
|
||||||
|
<data name="Servers by" xml:space="preserve">
|
||||||
|
<value>Servers by</value>
|
||||||
|
</data>
|
||||||
|
<data name="Traffic" xml:space="preserve">
|
||||||
|
<value>Traffic</value>
|
||||||
|
</data>
|
||||||
|
<data name="Failures" xml:space="preserve">
|
||||||
|
<value>Failures</value>
|
||||||
|
</data>
|
||||||
|
<data name="Latency" xml:space="preserve">
|
||||||
|
<value>Latency</value>
|
||||||
|
</data>
|
||||||
|
<data name="Name" xml:space="preserve">
|
||||||
|
<value>Name</value>
|
||||||
|
</data>
|
||||||
|
<data name="The statistics could not be read." xml:space="preserve">
|
||||||
|
<value>The statistics could not be read.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Deliveries that arrived" xml:space="preserve">
|
||||||
|
<value>Deliveries that arrived</value>
|
||||||
|
</data>
|
||||||
|
<data name="p50 {0} ms, p95 {1} ms" xml:space="preserve">
|
||||||
|
<value>p50 {0} ms, p95 {1} ms</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} known" xml:space="preserve">
|
||||||
|
<value>{0} known</value>
|
||||||
|
</data>
|
||||||
|
<data name="Accounts seen" xml:space="preserve">
|
||||||
|
<value>Accounts seen</value>
|
||||||
|
</data>
|
||||||
|
<data name="ledger {0} written, {1} dropped" xml:space="preserve">
|
||||||
|
<value>ledger {0} written, {1} dropped</value>
|
||||||
|
</data>
|
||||||
|
<data name="In" xml:space="preserve">
|
||||||
|
<value>In</value>
|
||||||
|
</data>
|
||||||
|
<data name="Out" xml:space="preserve">
|
||||||
|
<value>Out</value>
|
||||||
|
</data>
|
||||||
|
<data name="Refused" xml:space="preserve">
|
||||||
|
<value>Refused</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delivery" xml:space="preserve">
|
||||||
|
<value>Delivery</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} failing" xml:space="preserve">
|
||||||
|
<value>{0} failing</value>
|
||||||
|
</data>
|
||||||
|
<data name="Previous page" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Next page" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Crawler" xml:space="preserve">
|
||||||
|
<value>Crawler</value>
|
||||||
|
</data>
|
||||||
|
<data name="on" xml:space="preserve">
|
||||||
|
<value>on</value>
|
||||||
|
</data>
|
||||||
|
<data name="off" xml:space="preserve">
|
||||||
|
<value>off</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} servers touched, {1} only crawled, {2} due, {3} refuse robots" xml:space="preserve">
|
||||||
|
<value>{0} servers touched, {1} only crawled, {2} due, {3} refuse robots</value>
|
||||||
|
</data>
|
||||||
|
<data name="Received" xml:space="preserve">
|
||||||
|
<value>Received</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delivered" xml:space="preserve">
|
||||||
|
<value>Delivered</value>
|
||||||
|
</data>
|
||||||
|
<data name="Channels" xml:space="preserve">
|
||||||
|
<value>Channels</value>
|
||||||
|
</data>
|
||||||
|
<data name="Statistics" xml:space="preserve">
|
||||||
|
<value>Statistics</value>
|
||||||
|
</data>
|
||||||
|
<data name="unread" xml:space="preserve">
|
||||||
|
<value>unread</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mark as read" xml:space="preserve">
|
||||||
|
<value>Mark as read</value>
|
||||||
|
</data>
|
||||||
|
<data name="Personas" xml:space="preserve">
|
||||||
|
<value>Personas</value>
|
||||||
|
</data>
|
||||||
|
<data name="in use" xml:space="preserve">
|
||||||
|
<value>in use</value>
|
||||||
|
</data>
|
||||||
|
<data name="Use" xml:space="preserve">
|
||||||
|
<value>Use</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new persona" xml:space="preserve">
|
||||||
|
<value>A new persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="user name (a-z, 0-9, _)" xml:space="preserve">
|
||||||
|
<value>user name (a-z, 0-9, _)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make the persona" xml:space="preserve">
|
||||||
|
<value>Make the persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Persona made." xml:space="preserve">
|
||||||
|
<value>Persona made.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Locked by its community's moderators" xml:space="preserve">
|
||||||
|
<value>Locked by its community's moderators</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -75,6 +75,35 @@ namespace decePubClient.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The relationships of many accounts at once, 40 a request.</summary>
|
||||||
|
public async Task<List<Relationship>> Relationships(IReadOnlyList<string> accountIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var relationships = new List<Relationship>();
|
||||||
|
foreach (var batch in accountIds.Chunk(40))
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [.. batch.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await LogFailure(response, nameof(Relationships), cancellationToken);
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
relationships.AddRange(await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []);
|
||||||
|
}
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
|
||||||
|
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
|
||||||
|
new[] { new KeyValuePair<string, string>("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
|
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
|
||||||
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -152,6 +181,71 @@ namespace decePubClient.Services
|
|||||||
return new SearchResults();
|
return new SearchResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
|
||||||
|
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func<HttpContent>)form.Content,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
||||||
|
return account?.Id is null
|
||||||
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||||
|
: new WebResult { Data = account };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>A page of the accounts the persona blocked; Data is an AccountPage.</summary>
|
||||||
|
public Task<WebResult> Blocks(string maxId, CancellationToken cancellationToken = default) =>
|
||||||
|
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
|
||||||
|
|
||||||
|
async Task<WebResult> Accounts(string route, string maxId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parameters = new List<string> { "limit=40" };
|
||||||
|
if (maxId is not null)
|
||||||
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
||||||
|
using var response = await httpService.Get(route, [.. parameters], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var accounts = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
|
var (nextMaxId, _) = response.PageCursors();
|
||||||
|
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The servers the persona blocked: nothing from them reaches it, and its followers there are removed.</summary>
|
||||||
|
public async Task<List<string>> DomainBlocks(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_DomainBlocks, ["limit=200"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<string>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> BlockDomain(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair<string, string>("domain", domain.Trim()) },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
|
||||||
|
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
|
||||||
|
/// call. Lists come back empty and values null on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The newest 100 reports, open or resolved.</summary>
|
||||||
|
public async Task<List<ViewReport>> Reports(bool resolved, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorReports, [$"resolved={(resolved ? "true" : "false")}"],
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewReport>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Reports), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The servers blocked for everyone here, by name.</summary>
|
||||||
|
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminDomainBlocks, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<List<ViewDomainBlock>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(ServerBlocks), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blocks a server for everyone, or changes how; Data is the block.</summary>
|
||||||
|
public async Task<WebResult> BlockServer(DomainBlockForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminDomainBlock, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewDomainBlock>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> UnblockServer(string domain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminDomainUnblock(domain), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewStatisticsOverview>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Overview), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One page of the servers we exchanged with; sort is traffic (default), failures, latency or host.</summary>
|
||||||
|
public async Task<ViewHostsPage> Hosts(int days, string sort, int page, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsHosts,
|
||||||
|
[$"days={days}", $"sort={Uri.EscapeDataString(sort ?? "")}", $"page={page}", "limit=50"], cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewHostsPage>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Hosts), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ViewCrawler> Crawler(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsCrawler, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewCrawler>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Crawler), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AdminService)}.{caller}", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,16 @@ namespace decePubClient.Services
|
|||||||
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
|
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere.</summary>
|
||||||
|
public async Task<WebResult> CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
|
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
|
||||||
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ namespace decePubClient.Services
|
|||||||
|
|
||||||
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
@@ -83,6 +85,9 @@ namespace decePubClient.Services
|
|||||||
public Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
|
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||||
|
|
||||||
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||||
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>What a persona carries from one server to another, each in the CSV file Mastodon exports and imports.</summary>
|
||||||
|
public enum PortableData
|
||||||
|
{
|
||||||
|
Follows = 1,
|
||||||
|
Lists,
|
||||||
|
Blocks,
|
||||||
|
Mutes,
|
||||||
|
ServerBlocks,
|
||||||
|
Bookmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How an import went: how many rows were done, and the rows that could not be, with why.</summary>
|
||||||
|
public sealed class ImportReport
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Done { get; set; }
|
||||||
|
public List<string> Failed { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The persona's follows, lists, blocks, mutes, blocked servers and bookmarks as Mastodon's CSV exports, written in
|
||||||
|
/// the browser from the Mastodon API, and the same files imported: each row resolved through PrivaPub (a remote
|
||||||
|
/// account or post is fetched by PrivaPub, never by the browser) and acted on one at a time. Nothing throws; an import
|
||||||
|
/// reports the rows it could not do.
|
||||||
|
/// </summary>
|
||||||
|
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
||||||
|
InstanceService instances)
|
||||||
|
{
|
||||||
|
/// <summary>The most rows read from an export or an import file.</summary>
|
||||||
|
public const int MaxRows = 5000;
|
||||||
|
|
||||||
|
const string AccountHeader = "Account address";
|
||||||
|
|
||||||
|
public static string FileName(PortableData data) => data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => "following_accounts.csv",
|
||||||
|
PortableData.Lists => "lists.csv",
|
||||||
|
PortableData.Blocks => "blocked_accounts.csv",
|
||||||
|
PortableData.Mutes => "muted_accounts.csv",
|
||||||
|
PortableData.ServerBlocks => "blocked_domains.csv",
|
||||||
|
_ => "bookmarks.csv"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
||||||
|
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var rows = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await Follows(personaId, cancellationToken),
|
||||||
|
PortableData.Lists => await Lists(cancellationToken),
|
||||||
|
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
||||||
|
PortableData.Mutes => await Mutes(cancellationToken),
|
||||||
|
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
||||||
|
_ => await Bookmarks(personaId, cancellationToken)
|
||||||
|
};
|
||||||
|
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
|
||||||
|
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
Csv(AccountHeader, "Show boosts", "Notify on new posts", "Languages"),
|
||||||
|
.. followed.Select(a => relationships.GetValueOrDefault(a.Id) is { } r
|
||||||
|
? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), null)
|
||||||
|
: Csv(Full(a.Acct), "true", "false", null))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Mutes(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var muted = await Pages(accounts.Mutes, cancellationToken);
|
||||||
|
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
Csv(AccountHeader, "Hide notifications"),
|
||||||
|
.. muted.Select(a => Csv(Full(a.Acct), Flag(relationships.GetValueOrDefault(a.Id)?.MutingNotifications ?? true)))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Lists(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<string>();
|
||||||
|
foreach (var list in await lists.All(cancellationToken))
|
||||||
|
rows.AddRange((await lists.Members(list.Id, cancellationToken)).Select(member => Csv(list.Title, Full(member.Acct))));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<string>();
|
||||||
|
var maxId = default(string);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
||||||
|
if (result.IsInvalid || result.Data is not FeedPage page)
|
||||||
|
return rows;
|
||||||
|
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
||||||
|
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
||||||
|
}
|
||||||
|
while (maxId is not null && rows.Count < MaxRows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// every page of an account list, up to MaxRows
|
||||||
|
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var all = new List<Account>();
|
||||||
|
var maxId = default(string);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var result = await page(maxId, cancellationToken);
|
||||||
|
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
||||||
|
return all;
|
||||||
|
all.AddRange(accountPage.Accounts);
|
||||||
|
maxId = accountPage.MaxId;
|
||||||
|
}
|
||||||
|
while (maxId is not null && all.Count < MaxRows);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Does what each row of an exported file says, one row at a time; progress counts the rows done.</summary>
|
||||||
|
public async Task<ImportReport> Import(PortableData data, string csv, IProgress<ImportReport> progress = default,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var rows = Parse(csv.TrimStart('\uFEFF')).Where(r => r.Count > 0 && !string.IsNullOrWhiteSpace(r[0])).ToList();
|
||||||
|
if (rows.Count > 0 && rows[0][0].Equals(AccountHeader, StringComparison.OrdinalIgnoreCase))
|
||||||
|
rows.RemoveAt(0);
|
||||||
|
var report = new ImportReport { Total = Math.Min(rows.Count, MaxRows) };
|
||||||
|
var known = data == PortableData.Lists ? await lists.All(cancellationToken) : [];
|
||||||
|
foreach (var row in rows.Take(MaxRows))
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
var failure = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await FollowRow(row, cancellationToken),
|
||||||
|
PortableData.Lists => await ListRow(row, known, cancellationToken),
|
||||||
|
PortableData.Blocks => await AccountRow(row[0], AccountAction.Block, cancellationToken),
|
||||||
|
PortableData.Mutes => await AccountRow(row[0], AccountAction.Mute, cancellationToken),
|
||||||
|
PortableData.ServerBlocks => Failure(row[0], await accounts.BlockDomain(row[0], cancellationToken)),
|
||||||
|
_ => await BookmarkRow(row[0].Trim(), cancellationToken)
|
||||||
|
};
|
||||||
|
if (failure is null)
|
||||||
|
report.Done++;
|
||||||
|
else
|
||||||
|
report.Failed.Add(failure);
|
||||||
|
progress?.Report(report);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> FollowRow(List<string> row, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await accounts.Lookup(row[0], cancellationToken) is not { } account)
|
||||||
|
return Unknown(row[0]);
|
||||||
|
var showReblogs = row.Count < 2 || !row[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> AccountRow(string acct, AccountAction action, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await accounts.Lookup(acct, cancellationToken) is not { } account)
|
||||||
|
return Unknown(acct);
|
||||||
|
return Failure(acct, await accounts.Act(account.Id, action, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a list row names the list and an account; the list is made when missing, and an account is followed first, since
|
||||||
|
// a list holds followed accounts only
|
||||||
|
async Task<string> ListRow(List<string> row, List<MastodonList> known, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (row.Count < 2 || string.IsNullOrWhiteSpace(row[1]))
|
||||||
|
return string.Join(',', row);
|
||||||
|
var title = row[0].Trim();
|
||||||
|
var list = known.FirstOrDefault(l => string.Equals(l.Title, title, StringComparison.Ordinal));
|
||||||
|
if (list is null)
|
||||||
|
{
|
||||||
|
var made = await lists.Save(new MastodonList { Title = title }, cancellationToken);
|
||||||
|
if (made.IsInvalid || made.Data is not MastodonList created)
|
||||||
|
return Failure(title, made) ?? title;
|
||||||
|
known.Add(list = created);
|
||||||
|
}
|
||||||
|
if (await accounts.Lookup(row[1], cancellationToken) is not { } account)
|
||||||
|
return Unknown(row[1]);
|
||||||
|
var relationship = await accounts.Relationship(account.Id, cancellationToken);
|
||||||
|
if (relationship is not { Following: true })
|
||||||
|
{
|
||||||
|
var followed = await accounts.Follow(account.Id, true, cancellationToken);
|
||||||
|
if (followed.IsInvalid)
|
||||||
|
return Failure(row[1], followed);
|
||||||
|
}
|
||||||
|
return Failure($"{title}: {row[1]}", await lists.SetMember(list.Id, account.Id, true, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> BookmarkRow(string uri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await accounts.Search(uri, resolve: true, cancellationToken);
|
||||||
|
if ((found.Statuses.FirstOrDefault(s => s.Uri == uri || s.Url == uri) ?? found.Statuses.FirstOrDefault()) is not { } status)
|
||||||
|
return Unknown(uri);
|
||||||
|
return Failure(uri, await statuses.Act(status.Id, StatusAction.Bookmark, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Unknown(string what) => $"{what}: ?";
|
||||||
|
|
||||||
|
static string Failure(string what, WebResult result) => result.IsInvalid ? $"{what}: {result.ErrorMessage}" : null;
|
||||||
|
|
||||||
|
// a local account's acct has no host; an export names every account in full
|
||||||
|
string Full(string acct) => acct.Contains('@') ? acct : $"{acct}@{instances.SelfHost}";
|
||||||
|
|
||||||
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
|
static string Csv(params string[] cells) => string.Join(',', cells.Select(c => c is null ? "" :
|
||||||
|
c.IndexOfAny([',', '"', '\n', '\r']) < 0 ? c : $"\"{c.Replace("\"", "\"\"")}\""));
|
||||||
|
|
||||||
|
// RFC 4180: quoted cells may hold commas, quotes (doubled) and line breaks
|
||||||
|
static IEnumerable<List<string>> Parse(string csv)
|
||||||
|
{
|
||||||
|
var row = new List<string>();
|
||||||
|
var cell = new StringBuilder();
|
||||||
|
var quoted = false;
|
||||||
|
for (var i = 0; i < csv.Length; i++)
|
||||||
|
{
|
||||||
|
var c = csv[i];
|
||||||
|
if (quoted)
|
||||||
|
{
|
||||||
|
if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"')
|
||||||
|
{
|
||||||
|
cell.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
quoted = false;
|
||||||
|
else
|
||||||
|
cell.Append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
quoted = true;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
cell.Clear();
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
cell.Clear();
|
||||||
|
yield return row;
|
||||||
|
row = [];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
cell.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cell.Length > 0 || row.Count > 0)
|
||||||
|
{
|
||||||
|
row.Add(cell.ToString());
|
||||||
|
yield return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ namespace decePubClient.Services
|
|||||||
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
||||||
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
||||||
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
|
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
|
||||||
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
|
TimelineType.Direct => await Conversations(maxId, minId, cancellationToken),
|
||||||
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
|
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
|
||||||
_ => new WebResult { Data = new FeedPage() }
|
_ => new WebResult { Data = new FeedPage() }
|
||||||
};
|
};
|
||||||
@@ -119,15 +119,53 @@ namespace decePubClient.Services
|
|||||||
return new WebResult();
|
return new WebResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
|
/// <summary>Direct conversations by their newest message, newest first, paged by that message's id.</summary>
|
||||||
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
async Task<WebResult> Conversations(string maxId, string minId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
|
var parameters = new List<string> { $"limit={PageSize}" };
|
||||||
|
if (maxId is not null)
|
||||||
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
||||||
|
if (minId is not null)
|
||||||
|
parameters.Add($"min_id={Uri.EscapeDataString(minId)}");
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [.. parameters], cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
||||||
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
|
var (nextMaxId, nextMinId) = response.PageCursors();
|
||||||
|
return new WebResult
|
||||||
|
{
|
||||||
|
Data = new FeedPage
|
||||||
|
{
|
||||||
|
Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)],
|
||||||
|
MaxId = conversations.Count < PageSize ? null : nextMaxId,
|
||||||
|
MinId = nextMinId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many of the newest conversations have a message the persona has not read.</summary>
|
||||||
|
public async Task<int> UnreadConversations(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, ["limit=40"], cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []).Count(c => c.Unread) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks a conversation read; Data is the conversation.</summary>
|
||||||
|
public async Task<WebResult> ReadConversation(string conversationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_ReadConversation(conversationId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Conversation>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes a conversation off the list until a newer message; its messages stay.</summary>
|
||||||
|
public async Task<WebResult> RemoveConversation(string conversationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Conversation(conversationId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
|
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<i class="ion-md-settings"></i>
|
<i class="ion-md-settings"></i>
|
||||||
<span>@Localizer["Settings"]</span>
|
<span>@Localizer["Settings"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<AuthorizeView Policy="@Policies.IsAdmin">
|
<AuthorizeView Policy="@Policies.IsModerator">
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
|
||||||
<i class="ion-md-switch text-error"></i>
|
<i class="ion-md-switch text-error"></i>
|
||||||
<span>@Localizer["Administration"]</span>
|
<span>@Localizer["Administration"]</span>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
@using decePubClient.Components
|
@using decePubClient.Components
|
||||||
@using decePubClient.Components.Feed
|
@using decePubClient.Components.Feed
|
||||||
@using decePubClient.Components.Nerd
|
@using decePubClient.Components.Nerd
|
||||||
|
@using decePubClient.Components.Settings
|
||||||
|
@using decePubClient.Components.Admin
|
||||||
@using decePubClient.Components.Daisy
|
@using decePubClient.Components.Daisy
|
||||||
@using decePubClient.Components.Daisy.Actions
|
@using decePubClient.Components.Daisy.Actions
|
||||||
@using decePubClient.Components.Daisy.DataDisplay
|
@using decePubClient.Components.Daisy.DataDisplay
|
||||||
@@ -39,5 +41,6 @@
|
|||||||
@using decePubClient.Components.Daisy.Navigation
|
@using decePubClient.Components.Daisy.Navigation
|
||||||
@using decePubClient.LayerComponents
|
@using decePubClient.LayerComponents
|
||||||
@using PrivaPub.ClientModels
|
@using PrivaPub.ClientModels
|
||||||
|
@using PrivaPub.ClientModels.Admin
|
||||||
@using PrivaPub.ClientModels.Group
|
@using PrivaPub.ClientModels.Group
|
||||||
@using PrivaPub.ClientModels.User.Avatar
|
@using PrivaPub.ClientModels.User.Avatar
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// Administration as the pasture's administrator (pastureroot, whom the scenarios make): a server blocked for everyone
|
||||||
|
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
||||||
|
public sealed class AdministrationTests(Browser browser)
|
||||||
|
{
|
||||||
|
const string Root = "pastureroot";
|
||||||
|
const string Password = "Pasture-Pass-1!";
|
||||||
|
static readonly Pasture.Persona Admin = new("alice", "alice", Root, Password, null, null, null);
|
||||||
|
|
||||||
|
static async Task<JsonNode> AsAdmin(string path)
|
||||||
|
{
|
||||||
|
using var http = Pasture.Http();
|
||||||
|
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
|
||||||
|
login.EnsureSuccessStatusCode();
|
||||||
|
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
|
||||||
|
request.Headers.Authorization = new("Bearer", jwt);
|
||||||
|
using var response = await http.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<ILocator> Open(IPage page, string section)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/administration");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
return header.Locator("xpath=..");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_server_is_blocked_for_everyone_and_unblocked()
|
||||||
|
{
|
||||||
|
const string server = "instance-block-by-e2e.example";
|
||||||
|
await using var session = await SignIn.As(browser, Admin);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.admin.server-block", "privapub", Root, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Blocked servers");
|
||||||
|
var blocks = panel.Locator("[data-server-blocks]");
|
||||||
|
await blocks.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await blocks.Locator("[data-server-block-domain]").FillAsync(server);
|
||||||
|
await blocks.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||||
|
var row = blocks.Locator($"[data-server-block='{server}']");
|
||||||
|
await Assertions.Expect(row).ToContainTextAsync("Suspended", new() { Timeout = 15000 });
|
||||||
|
Assert.Contains((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||||
|
|
||||||
|
await row.GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||||
|
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||||
|
Assert.DoesNotContain((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_federation_statistics_and_the_reports_are_shown()
|
||||||
|
{
|
||||||
|
await using var session = await SignIn.As(browser, Admin);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.admin.statistics", "privapub", Root, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Statistics");
|
||||||
|
await Assertions.Expect(panel.Locator("[data-statistics-overview]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||||
|
var expected = (await AsAdmin("/clientapi/admin/statistics/hosts?days=30&sort=traffic&page=1&limit=50"))!["hosts"]!.AsArray()
|
||||||
|
.Select(h => h!["host"]!.GetValue<string>()).Take(5).ToList();
|
||||||
|
Assert.NotEmpty(expected);
|
||||||
|
foreach (var host in expected)
|
||||||
|
await Assertions.Expect(panel.Locator($"[data-statistics-host='{host}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||||
|
|
||||||
|
var reports = await Open(page, "Reports");
|
||||||
|
await Assertions.Expect(reports.Locator("[data-reports]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||||
|
var open = (await AsAdmin("/clientapi/moderator/reports?resolved=false"))!.AsArray().Count;
|
||||||
|
await Assertions.Expect(reports.Locator("[data-report-id]")).ToHaveCountAsync(open, new() { Timeout = 15000 });
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// Direct conversations: a message from another persona badges the Direct tab and its card says unread; marking it
|
||||||
|
// read and taking it off the list are checked on PrivaPub
|
||||||
|
public sealed class ConversationTests(Browser browser)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task A_new_message_is_unread_until_marked_and_leaves_the_list_when_removed()
|
||||||
|
{
|
||||||
|
var personas = Pasture.Personas();
|
||||||
|
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
||||||
|
var (reader, writer) = (personas[0], personas[1]);
|
||||||
|
var message = (await PersonaApi.Call(writer, "/api/v1/statuses", HttpMethod.Post, new JsonObject
|
||||||
|
{
|
||||||
|
["status"] = $"@{reader.UserName} just between us {Guid.NewGuid():N}"[..40], ["visibility"] = "direct"
|
||||||
|
}))!;
|
||||||
|
var uri = message["uri"]!.GetValue<string>();
|
||||||
|
async Task<JsonNode> Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
|
||||||
|
.FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue<string>() == uri);
|
||||||
|
await using var session = await SignIn.As(browser, reader);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.conversation", "privapub", reader.UserName, async () =>
|
||||||
|
{
|
||||||
|
Assert.True(await Pasture.Eventually(async () => (await Conversation())?["unread"]?.GetValue<bool>() == true, 15),
|
||||||
|
"the message never made an unread conversation");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.Locator("[data-unread-conversations]").WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
|
||||||
|
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-mail") }).ClickAsync();
|
||||||
|
var card = page.Locator($"article[data-uri='{uri}']");
|
||||||
|
await card.Locator("[data-conversation-unread]").WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await card.Locator("[data-conversation-read]").ClickAsync();
|
||||||
|
await Assertions.Expect(card.Locator("[data-conversation-unread]")).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||||
|
Assert.False((await Conversation())!["unread"]!.GetValue<bool>());
|
||||||
|
|
||||||
|
await card.Locator("[data-conversation-remove]").ClickAsync();
|
||||||
|
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||||
|
Assert.Null(await Conversation());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await PersonaApi.Call(writer, $"/api/v1/statuses/{message["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// Settings as a persona: its profile saved (update_credentials) and read back from PrivaPub, a server blocked and
|
||||||
|
// unblocked, the follows exported and servers imported in Mastodon's files, and a new persona made; what a test changes
|
||||||
|
// is put back through the API afterwards, but for the persona (PrivaPub keeps every persona, so the test makes it under
|
||||||
|
// the scenarios' own root, never a town one)
|
||||||
|
public sealed class SettingsTests(Browser browser)
|
||||||
|
{
|
||||||
|
static async Task<ILocator> Open(IPage page, string section)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
return header.Locator("xpath=..");
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonObject Restore(JsonNode account)
|
||||||
|
{
|
||||||
|
var fields = new JsonObject();
|
||||||
|
var source = account["source"]!;
|
||||||
|
var kept = source["fields"]!.AsArray();
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
fields[i.ToString()] = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = i < kept.Count ? kept[i]!["name"]!.GetValue<string>() : "",
|
||||||
|
["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue<string>() : ""
|
||||||
|
};
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["display_name"] = account["display_name"]!.GetValue<string>(),
|
||||||
|
["note"] = source["note"]!.GetValue<string>(),
|
||||||
|
["locked"] = account["locked"]!.GetValue<bool>(),
|
||||||
|
["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue<string>() },
|
||||||
|
["fields_attributes"] = fields
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_personas_profile_is_saved_from_settings()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().Last();
|
||||||
|
var original = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||||
|
var mark = Guid.NewGuid().ToString("N")[..6];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.profile-edit", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Profile");
|
||||||
|
var editor = panel.Locator("[data-profile-editor]");
|
||||||
|
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await Assertions.Expect(editor.Locator("[data-profile-name]")).ToHaveValueAsync(original["display_name"]!.GetValue<string>(),
|
||||||
|
new() { Timeout = 10000 });
|
||||||
|
|
||||||
|
await editor.Locator("[data-profile-name]").FillAsync($"Edited {mark}");
|
||||||
|
await editor.Locator("[data-profile-note]").FillAsync($"A biography written in the e2e tests {mark}");
|
||||||
|
var row = editor.Locator("[data-profile-field='3'] input");
|
||||||
|
await row.Nth(0).FillAsync("e2e");
|
||||||
|
await row.Nth(1).FillAsync(mark);
|
||||||
|
await editor.Locator("[data-profile-privacy]").SelectOptionAsync("Unlisted");
|
||||||
|
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
|
||||||
|
var saved = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||||
|
Assert.Equal($"Edited {mark}", saved["display_name"]!.GetValue<string>());
|
||||||
|
Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue<string>());
|
||||||
|
Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue<string>());
|
||||||
|
Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue<string>() == "e2e" && f["value"]!.GetValue<string>() == mark);
|
||||||
|
// the persona chooser shows the new name at once
|
||||||
|
await Assertions.Expect(page.GetByText($"Edited {mark}", new() { Exact = true }).First).ToBeVisibleAsync(new() { Timeout = 5000 });
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, Restore(original));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_server_is_blocked_and_unblocked_from_settings()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().Last();
|
||||||
|
const string server = "blocked-by-e2e.example";
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.domain-block", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Mutes/Blocks");
|
||||||
|
var servers = panel.Locator("[data-domain-blocks]");
|
||||||
|
await servers.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
|
||||||
|
await servers.Locator("[data-domain-new]").FillAsync($"https://{server.ToUpperInvariant()}/about");
|
||||||
|
await servers.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||||
|
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
Assert.Contains(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||||
|
|
||||||
|
await servers.Locator($"[data-domain='{server}']").GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||||
|
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||||
|
Assert.DoesNotContain(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the follows export is Mastodon's following_accounts.csv, naming every account in full
|
||||||
|
[Fact]
|
||||||
|
public async Task The_follows_are_exported_in_mastodons_format()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var following = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray();
|
||||||
|
Assert.SkipWhen(following.Count == 0, $"{persona.UserName} follows nobody in this town");
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.export", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Data import/export");
|
||||||
|
var button = panel.Locator("[data-export-kind='Follows']");
|
||||||
|
await button.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
var download = await page.RunAndWaitForDownloadAsync(() => button.ClickAsync(), new() { Timeout = 60000 });
|
||||||
|
Assert.Equal("following_accounts.csv", download.SuggestedFilename);
|
||||||
|
var lines = (await File.ReadAllLinesAsync((await download.PathAsync())!)).ToList();
|
||||||
|
Assert.Equal("Account address,Show boosts,Notify on new posts,Languages", lines[0]);
|
||||||
|
foreach (var account in following)
|
||||||
|
{
|
||||||
|
var acct = account!["acct"]!.GetValue<string>();
|
||||||
|
var full = acct.Contains('@') ? acct : $"{acct}@privapub.test";
|
||||||
|
Assert.Contains(lines, l => l.StartsWith(full + ",", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Blocked_servers_are_imported_from_a_mastodon_file()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().Last();
|
||||||
|
string[] servers = ["imported-one.example", "imported-two.example"];
|
||||||
|
var csv = Path.Combine(Path.GetTempPath(), $"blocked_domains-{Guid.NewGuid():N}.csv");
|
||||||
|
await File.WriteAllTextAsync(csv, string.Join("\n", servers) + "\n");
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.import", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Data import/export");
|
||||||
|
var import = panel.Locator("[data-import]");
|
||||||
|
await import.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await import.Locator("[data-import-kind]").SelectOptionAsync("ServerBlocks");
|
||||||
|
await import.Locator("[data-import-file]").SetInputFilesAsync(csv);
|
||||||
|
await import.Locator("[data-import-start]").ClickAsync();
|
||||||
|
await Assertions.Expect(import.Locator("[data-import-report]")).ToContainTextAsync("2 of 2 done, 0 could not be.", new() { Timeout = 30000 });
|
||||||
|
var blocked = (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()).ToList();
|
||||||
|
Assert.All(servers, s => Assert.Contains(s, blocked));
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var server in servers)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||||
|
File.Delete(csv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_new_persona_is_made_and_used()
|
||||||
|
{
|
||||||
|
var name = $"e2e_{Guid.NewGuid():N}"[..12];
|
||||||
|
await using var session = await SignIn.As(browser, new Pasture.Persona("alice", "alice", "pastureroot", "Pasture-Pass-1!", null, null, null));
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.persona-create", "privapub", "pastureroot", async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page, "Personas");
|
||||||
|
var form = panel.Locator("[data-persona-new]");
|
||||||
|
await form.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await form.Locator("[data-persona-username]").FillAsync(name);
|
||||||
|
await form.Locator("[data-persona-name]").FillAsync($"Made by e2e {name}");
|
||||||
|
await form.Locator("[data-persona-biography]").FillAsync("A persona the end-to-end tests made.");
|
||||||
|
await form.Locator("[data-persona-create]").ClickAsync();
|
||||||
|
var made = panel.Locator($"[data-persona='{name}']");
|
||||||
|
await made.WaitForAsync(new() { Timeout = 15000 });
|
||||||
|
|
||||||
|
await made.GetByRole(AriaRole.Button, new() { Name = "Use" }).ClickAsync();
|
||||||
|
await Assertions.Expect(made).ToContainTextAsync("in use", new() { Timeout = 15000 });
|
||||||
|
await Assertions.Expect(page.GetByText($"Made by e2e {name}", new() { Exact = true }).First).ToBeVisibleAsync();
|
||||||
|
using var http = Pasture.Http();
|
||||||
|
var webfinger = await http.GetAsync($"{Pasture.Api}/.well-known/webfinger?resource=acct:{name}@privapub.test");
|
||||||
|
Assert.True(webfinger.IsSuccessStatusCode, $"{name} is not found by WebFinger");
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// A remote thread opened in decePub fills with the replies PrivaPub never received: opening it has PrivaPub read the
|
||||||
|
// thread from its server (Mastodon's FEP-7888 context), and the page reads the thread again a few seconds later
|
||||||
|
public sealed class ThreadTests(Browser browser)
|
||||||
|
{
|
||||||
|
const string Platform = "mastodon";
|
||||||
|
const string Author = "elif_ms06";
|
||||||
|
// followed by no persona, so Mastodon never delivers her reply to PrivaPub
|
||||||
|
const string Outsider = "gigi_ms28";
|
||||||
|
|
||||||
|
static async Task<JsonNode> Drive(string user, string verb, JsonObject parameters) =>
|
||||||
|
await Pasture.Town("drive", Platform, user, verb, parameters.ToJsonString());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_remote_thread_brings_the_replies_that_never_reached_privapub()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var mark = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var root = await Drive(Author, "post", new JsonObject { ["text"] = $"a thread to open {mark}", ["visibility"] = "public" });
|
||||||
|
var rootUri = root["uri"]!.GetValue<string>();
|
||||||
|
string replyUri = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reply = await Drive(Outsider, "post", new JsonObject
|
||||||
|
{
|
||||||
|
["text"] = $"said where nobody listens {mark}", ["visibility"] = "public", ["reply_to_uri"] = rootUri
|
||||||
|
});
|
||||||
|
replyUri = reply["uri"]!.GetValue<string>();
|
||||||
|
var found = await PersonaApi.Call(persona, $"/api/v2/search?q={Uri.EscapeDataString(rootUri)}&resolve=true&type=statuses");
|
||||||
|
var rootId = found!["statuses"]!.AsArray().Single()!["id"]!.GetValue<string>();
|
||||||
|
await Task.Delay(3000);
|
||||||
|
Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue<bool>() == true,
|
||||||
|
$"{Outsider}'s reply reached PrivaPub by delivery, so the thread proves nothing");
|
||||||
|
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.render.thread-backfill", Platform, persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/expand/{rootId}");
|
||||||
|
await page.Locator($"article[data-uri='{rootUri}']").First.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
var answer = page.Locator($"article[data-uri='{replyUri}']");
|
||||||
|
await Assertions.Expect(answer).ToContainTextAsync($"said where nobody listens {mark}", new() { Timeout = 25000 });
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (replyUri is not null)
|
||||||
|
await Drive(Outsider, "delete", new JsonObject { ["uri"] = replyUri });
|
||||||
|
await Drive(Author, "delete", new JsonObject { ["uri"] = rootUri });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user