Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da6a398d7 | ||
|
|
356ea7b430 | ||
|
|
48df316256 | ||
|
|
dc2190e271 | ||
|
|
70cb389537 |
No files matched your search
@@ -95,6 +95,9 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam
|
|||||||
- An idle globe draws no frame.
|
- An idle globe draws no frame.
|
||||||
|
|
||||||
Photos and stats go to `tests/e2e/out/globe/`.
|
Photos and stats go to `tests/e2e/out/globe/`.
|
||||||
|
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
|
||||||
|
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
|
||||||
|
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
|
||||||
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
||||||
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
||||||
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
||||||
@@ -151,6 +154,9 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
|||||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||||
shadow with `shadow-none!`.
|
shadow with `shadow-none!`.
|
||||||
|
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
|
||||||
|
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
|
||||||
|
file input's own button (Choose File, Browse…) is raised like the app's buttons.
|
||||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||||
every collapse disappears.
|
every collapse disappears.
|
||||||
|
|
||||||
@@ -165,7 +171,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
|||||||
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||||
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||||
daisyUI's own size variables:
|
daisyUI's own size variables:
|
||||||
- buttons, tabs, inputs and selects are 44px tall;
|
- buttons, tabs, inputs, selects and file inputs are 44px tall;
|
||||||
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
|
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
|
||||||
the padding box);
|
the padding box);
|
||||||
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
||||||
@@ -274,6 +280,8 @@ supplies:
|
|||||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||||
PrivaPub 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.
|
||||||
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
|
`HttpClient`'s own is off.
|
||||||
- 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.
|
||||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||||
@@ -309,8 +317,10 @@ supplies:
|
|||||||
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
||||||
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
|
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
|
||||||
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
||||||
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
|
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
||||||
link, participants and status.
|
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
|
||||||
|
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
|
||||||
|
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
|
||||||
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
||||||
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
||||||
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
||||||
@@ -340,8 +350,21 @@ supplies:
|
|||||||
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
||||||
post opened in its thread shows whole.
|
post opened in its thread shows whole.
|
||||||
- **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`); a post can hold
|
||||||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
a poll instead of media.
|
||||||
|
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
|
||||||
|
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
|
||||||
|
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
|
||||||
|
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||||
|
- the ids are kept, so a post that fails never uploads its media again;
|
||||||
|
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||||
|
- a description changed after the upload is PUT before the post.
|
||||||
|
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
||||||
|
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
||||||
|
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||||
|
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
|
||||||
|
(`media_attributes`). Its visibility and poll stay.
|
||||||
|
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
|
||||||
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
||||||
(`/api/v2/search`, never resolving), once typing pauses.
|
(`/api/v2/search`, never resolving), once typing pauses.
|
||||||
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
||||||
@@ -355,14 +378,27 @@ supplies:
|
|||||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
||||||
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
||||||
the client then signs out), the persona's profile (`ProfileEditor`:
|
the client then signs out), the persona's profile (`ProfileEditor`:
|
||||||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
|
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
|
||||||
|
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||||
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
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
|
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
|
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.
|
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
|
||||||
|
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
|
||||||
|
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
|
||||||
|
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
|
||||||
|
with progress, a report and Stop. Imported posts are delivered to no one.
|
||||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||||
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
||||||
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||||
|
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
|
||||||
|
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
|
||||||
|
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
|
||||||
|
to disk.
|
||||||
|
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
|
||||||
|
has, and carries on from what PrivaPub says after a refused or lost piece.
|
||||||
|
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
|
||||||
|
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
|
||||||
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
|
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
|
||||||
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
||||||
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
|
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||||
[JsonSerializable(typeof(ViewHostsPage))]
|
[JsonSerializable(typeof(ViewHostsPage))]
|
||||||
[JsonSerializable(typeof(ViewCrawler))]
|
[JsonSerializable(typeof(ViewCrawler))]
|
||||||
|
[JsonSerializable(typeof(ViewBackups))]
|
||||||
|
[JsonSerializable(typeof(ViewBackup))]
|
||||||
|
[JsonSerializable(typeof(BackupPasswordForm))]
|
||||||
|
[JsonSerializable(typeof(RestoreBackupForm))]
|
||||||
|
[JsonSerializable(typeof(ViewBackupTicket))]
|
||||||
|
[JsonSerializable(typeof(ViewBackupUpload))]
|
||||||
|
[JsonSerializable(typeof(ViewPersonaArchive))]
|
||||||
|
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
|
||||||
|
[JsonSerializable(typeof(ViewArchiveTicket))]
|
||||||
|
[JsonSerializable(typeof(ViewArchiveUpload))]
|
||||||
public partial class ClientJsonContext : JsonSerializerContext
|
public partial class ClientJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
|
||||||
|
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
|
||||||
|
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
|
||||||
|
and everyone signs in again. A restore never undoes what protects. *@
|
||||||
|
<div class="flex flex-col gap-3 w-full" data-backups>
|
||||||
|
@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 (restarting is not null)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
|
||||||
|
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
|
||||||
|
<i class="ion-md-archive"></i> @Localizer["Back up now"]
|
||||||
|
</DButton>
|
||||||
|
@if (backups.Running is { } running)
|
||||||
|
{
|
||||||
|
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (backups.Waiting is { } waiting)
|
||||||
|
{
|
||||||
|
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
|
||||||
|
<span>
|
||||||
|
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
|
||||||
|
@if (waiting.Error is { Length: > 0 } error)
|
||||||
|
{
|
||||||
|
<br /><span class="text-xs">@error</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backups.LastRestore is { } last)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
|
||||||
|
@if (last.Abandoned)
|
||||||
|
{
|
||||||
|
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
|
||||||
|
<p class="text-xs">@last.Error</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<b>@Localizer["Restored {0}", last.Backup]</b>
|
||||||
|
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
|
||||||
|
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
|
||||||
|
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
|
||||||
|
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs" data-last-restore-tombstoned>
|
||||||
|
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
|
||||||
|
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
|
||||||
|
data-backup-upload />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
@if (uploading)
|
||||||
|
{
|
||||||
|
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (backups.Backups.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var backup in backups.Backups)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
|
||||||
|
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
|
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
|
||||||
|
@(backup.DbOnly
|
||||||
|
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
|
||||||
|
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
|
||||||
|
@if (backup.MediaMissing > 0)
|
||||||
|
{
|
||||||
|
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
|
||||||
|
}
|
||||||
|
@if (!backup.Consistent)
|
||||||
|
{
|
||||||
|
<span>; @Localizer["not read at one instant"]</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (backup.RootsDeletedSince > 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
|
||||||
|
OnClick="() => AskRestore(backup)" data-backup-restore>
|
||||||
|
<i class="ion-md-refresh"></i> @Localizer["Restore"]
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
|
||||||
|
<i class="ion-md-trash"></i> @Localizer["Delete"]
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
|
||||||
|
CloseText="@Localizer["Close"]" data-backup-download-dialog>
|
||||||
|
<Heading>@Localizer["Download a backup"]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (ticket is null)
|
||||||
|
{
|
||||||
|
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
|
||||||
|
placeholder="@Localizer["Password"]" data-backup-password />
|
||||||
|
<ErrorViewer AdditionalError="@dialogError" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
|
||||||
|
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
|
||||||
|
@if (ticket is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
|
||||||
|
@Localizer["Get the link"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
|
||||||
|
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
|
||||||
|
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
|
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
|
||||||
|
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
|
||||||
|
@if (restoring?.RootsDeletedSince > 0)
|
||||||
|
{
|
||||||
|
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
|
||||||
|
}
|
||||||
|
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
|
||||||
|
placeholder="@Localizer["Password"]" data-backup-password />
|
||||||
|
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
|
||||||
|
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
|
||||||
|
<ErrorViewer AdditionalError="@dialogError" />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
<Actions>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error"
|
||||||
|
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
|
||||||
|
OnClick="Restore" data-backup-confirm>
|
||||||
|
@Localizer["Restore"]
|
||||||
|
</DButton>
|
||||||
|
</Actions>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AdminService AdminService { get; set; }
|
||||||
|
[Inject] ConfirmService Confirm { get; set; }
|
||||||
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
|
[Inject] AppConfiguration Configuration { get; set; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
const string UploadInput = "input[data-backup-upload]";
|
||||||
|
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
ViewBackups backups = new();
|
||||||
|
bool busy;
|
||||||
|
string restarting;
|
||||||
|
ViewBackup downloading;
|
||||||
|
ViewBackup restoring;
|
||||||
|
string password;
|
||||||
|
string typedHost;
|
||||||
|
string ticket;
|
||||||
|
long ticketSize;
|
||||||
|
string dialogError;
|
||||||
|
string pickedName;
|
||||||
|
bool uploading;
|
||||||
|
long uploaded;
|
||||||
|
long uploadSize;
|
||||||
|
double uploadedPercent;
|
||||||
|
IJSObjectReference transfer;
|
||||||
|
DotNetObjectReference<BackupsPanel> self;
|
||||||
|
CancellationTokenSource polling;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
backups = await AdminService.Backups() ?? backups;
|
||||||
|
if (backups.Running is not null || backups.Waiting is not null)
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
|
||||||
|
void Poll()
|
||||||
|
{
|
||||||
|
if (polling is not null)
|
||||||
|
return;
|
||||||
|
polling = new();
|
||||||
|
var token = polling.Token;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(PollEvery);
|
||||||
|
while (await timer.WaitForNextTickAsync(token))
|
||||||
|
{
|
||||||
|
var read = await AdminService.Backups(token);
|
||||||
|
if (read is not null)
|
||||||
|
backups = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
polling = null;
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DColor KindColor(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"nightly" => DColor.Neutral,
|
||||||
|
"manual" => DColor.Primary,
|
||||||
|
"pre-deploy" => DColor.Info,
|
||||||
|
"pre-restore" => DColor.Warning,
|
||||||
|
"uploaded" => DColor.Accent,
|
||||||
|
_ => DColor.Neutral
|
||||||
|
};
|
||||||
|
|
||||||
|
string KindName(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"nightly" => Localizer["Nightly"],
|
||||||
|
"manual" => Localizer["Made by hand"],
|
||||||
|
"pre-deploy" => Localizer["Before a deploy"],
|
||||||
|
"pre-restore" => Localizer["Before a restore"],
|
||||||
|
"uploaded" => Localizer["Uploaded"],
|
||||||
|
_ => kind
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task BackUp()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.BackUp();
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.Running = "backup";
|
||||||
|
Toast.AddResponseSuccess(Localizer["The backup has started."]);
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete(ViewBackup backup)
|
||||||
|
{
|
||||||
|
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
|
||||||
|
return;
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.DeleteBackup(backup.Id);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskDownload(ViewBackup backup)
|
||||||
|
{
|
||||||
|
(password, ticket, dialogError) = (null, null, null);
|
||||||
|
downloading = backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
|
||||||
|
|
||||||
|
async Task Download()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await AdminService.BackupTicket(downloading.Id, password);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
dialogError = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var granted = (ViewBackupTicket)result.Data;
|
||||||
|
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
|
||||||
|
ticketSize = granted.Bytes;
|
||||||
|
password = null;
|
||||||
|
dialogError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskRestore(ViewBackup backup)
|
||||||
|
{
|
||||||
|
(password, typedHost, dialogError) = (null, null, null);
|
||||||
|
restoring = backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
|
||||||
|
|
||||||
|
async Task Restore()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var asked = restoring;
|
||||||
|
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
dialogError = result.ErrorMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseRestore();
|
||||||
|
restarting = asked.Id;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||||
|
|
||||||
|
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||||
|
|
||||||
|
async Task Upload()
|
||||||
|
{
|
||||||
|
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||||
|
self ??= DotNetObjectReference.Create(this);
|
||||||
|
var started = await AdminService.StartBackupUpload();
|
||||||
|
if (started.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload = (ViewBackupUpload)started.Data;
|
||||||
|
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||||
|
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
|
||||||
|
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||||
|
if (!sent.Ok)
|
||||||
|
{
|
||||||
|
uploading = false;
|
||||||
|
await AdminService.CancelBackupUpload(upload.Id);
|
||||||
|
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finished = await AdminService.FinishBackupUpload(upload.Id);
|
||||||
|
uploading = false;
|
||||||
|
if (finished.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(finished);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup = (ViewBackup)finished.Data;
|
||||||
|
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
|
||||||
|
pickedName = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public Task Progress(long received, long total)
|
||||||
|
{
|
||||||
|
(uploaded, uploadSize) = (received, total);
|
||||||
|
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||||
|
return InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UploadOutcome
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
polling?.Cancel();
|
||||||
|
self?.Dispose();
|
||||||
|
if (transfer is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transfer.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-36
@@ -5,8 +5,10 @@
|
|||||||
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
||||||
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
|
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
|
||||||
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
|
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
|
||||||
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words
|
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
|
||||||
and warning change, its media, visibility and poll stay). *@
|
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
|
||||||
|
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
|
||||||
|
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
|
||||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@@ -83,29 +85,61 @@
|
|||||||
|
|
||||||
@if (form.Media.Count > 0)
|
@if (form.Media.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2" data-compose-media>
|
||||||
@foreach (var media in form.Media)
|
@foreach (var media in form.Media)
|
||||||
{
|
{
|
||||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
|
||||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
@if (media.Preview is { Length: > 0 } preview)
|
||||||
{
|
{
|
||||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
|
||||||
}
|
}
|
||||||
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
<p class="inline-flex items-center gap-2 text-xs">
|
||||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
@switch (media.State)
|
||||||
|
{
|
||||||
|
case UploadState.Uploading:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Processing:
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
|
||||||
|
break;
|
||||||
|
case UploadState.Failed:
|
||||||
|
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
|
||||||
|
@if (media.File is not null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
|
||||||
|
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
|
||||||
|
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
|
||||||
</div>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
|
||||||
<i class="ion-md-trash text-base text-error"></i>
|
<i class="ion-md-trash text-base text-error"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (AskingAltText)
|
||||||
|
{
|
||||||
|
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
|
||||||
|
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
|
||||||
|
</div>
|
||||||
|
</DAlert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (form.IsLocated)
|
@if (form.IsLocated)
|
||||||
@@ -165,12 +199,12 @@
|
|||||||
<i class="ion-md-warning text-base"></i>
|
<i class="ion-md-warning text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
|
||||||
@if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll)
|
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||||
{
|
{
|
||||||
@* A label styled as a button opens the hidden file input. *@
|
@* A label styled as a button opens the hidden file input. *@
|
||||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||||
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||||
<i class="ion-md-attach text-base"></i>
|
<i class="ion-md-attach text-base"></i>
|
||||||
@if (form.Media.Count != 0)
|
@if (form.Media.Count != 0)
|
||||||
{
|
{
|
||||||
@@ -236,6 +270,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
@@ -317,21 +352,40 @@
|
|||||||
|
|
||||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||||
|
|
||||||
long MaximumFileSize => form.MediaType switch
|
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
|
||||||
|
MediaConfiguration limits = new();
|
||||||
|
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
|
||||||
|
bool askingAltText;
|
||||||
|
bool altTextConfirmed;
|
||||||
|
|
||||||
|
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
|
||||||
|
|
||||||
|
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
|
||||||
|
|
||||||
|
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
|
||||||
|
|
||||||
|
bool OfKind(string type) => form.MediaType switch
|
||||||
{
|
{
|
||||||
MediaType.Images => 16_777_216,
|
MediaType.Images => type.StartsWith("image/"),
|
||||||
MediaType.Audio => 41_943_040,
|
MediaType.Video => type.StartsWith("video/"),
|
||||||
_ => 99_614_720
|
_ => type.StartsWith("audio/")
|
||||||
};
|
};
|
||||||
|
|
||||||
string AcceptedFileTypes => form.MediaType switch
|
// the server's types of the kind chosen, and the extensions a file picker may know them by
|
||||||
|
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
|
||||||
|
|
||||||
|
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||||
_ => default
|
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// what a file is: the browser's word, else its extension's
|
||||||
|
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
|
||||||
|
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
|
||||||
|
|
||||||
// a new post starts in the language the persona chose in Settings, else in the reader's
|
// a new post starts in the language the persona chose in Settings, else in the reader's
|
||||||
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
@@ -361,13 +415,31 @@
|
|||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
form.MessageType = DefaultVisibility;
|
form.MessageType = DefaultVisibility;
|
||||||
form.Language = DefaultLanguage;
|
form.Language = DefaultLanguage;
|
||||||
|
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||||
form.QuotedStatusId = Quoting?.Id;
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
|
limits = await MediaService.Limits();
|
||||||
if (Editing is not null)
|
if (Editing is not null)
|
||||||
{
|
{
|
||||||
var source = await StatusService.Source(Editing.Id);
|
var source = await StatusService.Source(Editing.Id);
|
||||||
form.Content = source?.Text;
|
form.Content = source?.Text;
|
||||||
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||||
form.SpoilerText = source?.SpoilerText;
|
form.SpoilerText = source?.SpoilerText;
|
||||||
|
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
|
||||||
|
form.Language = Editing.Language ?? form.Language;
|
||||||
|
form.Sensitive = Editing.Sensitive;
|
||||||
|
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
|
||||||
|
{
|
||||||
|
Id = m.Id,
|
||||||
|
State = UploadState.Ready,
|
||||||
|
Posted = true,
|
||||||
|
AltText = m.Description,
|
||||||
|
SentAltText = m.Description,
|
||||||
|
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
|
||||||
|
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
|
||||||
|
FileName = Path.GetFileName(m.Url ?? string.Empty)
|
||||||
|
})];
|
||||||
|
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
|
||||||
|
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
|
||||||
}
|
}
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
if (InReplyTo is null)
|
if (InReplyTo is null)
|
||||||
@@ -433,40 +505,121 @@
|
|||||||
|
|
||||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||||
{
|
{
|
||||||
if (file.Size > MaximumFileSize)
|
var type = TypeOf(file);
|
||||||
|
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
|
||||||
|
{
|
||||||
|
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.Size > MaximumFileSize(type))
|
||||||
{
|
{
|
||||||
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ReadFile(file) is { } media)
|
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
|
||||||
|
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
|
||||||
form.Media.Add(media);
|
form.Media.Add(media);
|
||||||
else
|
altTextConfirmed = false;
|
||||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
Start(media);
|
||||||
|
if (type.StartsWith("image/"))
|
||||||
|
media.Preview = await Thumbnail(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
async Task<string> Thumbnail(IBrowserFile file)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
using var buffer = new MemoryStream();
|
||||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
await stream.CopyToAsync(buffer);
|
||||||
if (file.ContentType.StartsWith("image"))
|
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
|
||||||
return media;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Start(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel = new();
|
||||||
|
media.State = UploadState.Uploading;
|
||||||
|
media.Error = default;
|
||||||
|
media.Done = Upload(media, media.Cancel.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
|
||||||
|
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (uploaded.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = uploaded.ErrorMessage;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var attachment = (MediaAttachment)uploaded.Data;
|
||||||
|
media.Id = attachment.Id;
|
||||||
|
media.SentAltText = attachment.Description;
|
||||||
|
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var processed = await MediaService.Get(media.Id, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
if (processed.IsInvalid)
|
||||||
|
{
|
||||||
|
media.State = UploadState.Failed;
|
||||||
|
media.Error = processed.ErrorMessage;
|
||||||
|
}
|
||||||
|
else if (processed.Data is MediaAttachment)
|
||||||
|
media.State = UploadState.Ready;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
|
||||||
|
void Retry(UploadMedia media) => Start(media);
|
||||||
|
|
||||||
|
void Remove(UploadMedia media)
|
||||||
|
{
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
form.Media.Remove(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the reader chose to publish its pictures without descriptions
|
||||||
|
async Task PublishAnyway()
|
||||||
|
{
|
||||||
|
altTextConfirmed = true;
|
||||||
|
askingAltText = false;
|
||||||
|
await Publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media)
|
||||||
|
media.Cancel?.Cancel();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
async Task Publish()
|
async Task Publish()
|
||||||
{
|
{
|
||||||
error = default;
|
error = default;
|
||||||
@@ -480,6 +633,18 @@
|
|||||||
error = Localizer["A poll needs two choices at least."];
|
error = Localizer["A poll needs two choices at least."];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
|
||||||
|
{
|
||||||
|
askingAltText = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// what is still uploading or processing is waited for
|
||||||
|
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
|
||||||
|
{
|
||||||
|
isPublishing = true;
|
||||||
|
await Task.WhenAll(form.Media.Select(m => m.Done));
|
||||||
|
isPublishing = false;
|
||||||
|
}
|
||||||
if (Editing is not null)
|
if (Editing is not null)
|
||||||
{
|
{
|
||||||
isPublishing = true;
|
isPublishing = true;
|
||||||
|
|||||||
@@ -623,9 +623,10 @@
|
|||||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
||||||
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
||||||
{
|
{
|
||||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
@* its blurhash shows behind it: the colours, nothing of what it shows *@
|
||||||
@onclick="() => revealedMedia.Add(media.Id)">
|
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
|
||||||
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
|
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
|
||||||
|
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -776,22 +777,37 @@
|
|||||||
}
|
}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
|
// media that didn't load (gone from their server, or refused): said so, with the way to their original
|
||||||
|
HashSet<string> unavailableMedia = [];
|
||||||
|
|
||||||
|
RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch
|
||||||
{
|
{
|
||||||
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
||||||
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
|
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
|
||||||
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
||||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
|
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
|
||||||
</button>,
|
</button>,
|
||||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
|
// under reduced motion a gifv waits for a tap (its controls)
|
||||||
|
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
|
||||||
|
playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||||
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
||||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
|
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
|
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></audio>,
|
||||||
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
||||||
</a>
|
</a>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RenderFragment Unavailable(MediaAttachment media) =>
|
||||||
|
@<div class="flex flex-col items-center justify-center gap-2 w-full aspect-video rounded-lg neo-inset neo-2xs text-sm" data-media-unavailable>
|
||||||
|
<i class="ion-md-alert text-lg opacity-70" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["Media unavailable"]</span>
|
||||||
|
@if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original)
|
||||||
|
{
|
||||||
|
<a class="link text-xs pointer-coarse:py-3.5" href="@original" target="_blank" rel="noopener noreferrer">@Localizer["Open original"]</a>
|
||||||
|
}
|
||||||
|
</div>;
|
||||||
|
|
||||||
async Task Act(StatusAction action)
|
async Task Act(StatusAction action)
|
||||||
{
|
{
|
||||||
isActing = true;
|
isActing = true;
|
||||||
|
|||||||
@@ -95,9 +95,15 @@
|
|||||||
async Task Export(PortableData data)
|
async Task Export(PortableData data)
|
||||||
{
|
{
|
||||||
exporting = data;
|
exporting = data;
|
||||||
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
exporting = null;
|
exporting = null;
|
||||||
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv");
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Pick(InputFileChangeEventArgs eventArgs)
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's
|
||||||
|
layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from
|
||||||
|
the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@
|
||||||
|
<div class="flex flex-col gap-5 w-full" data-archive>
|
||||||
|
<section class="flex flex-col gap-2" data-archive-export>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
|
||||||
|
@if (state.ExportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="ion-md-archive"></i>
|
||||||
|
}
|
||||||
|
<span>@Localizer["Make the archive"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (state.ExportState == "ready")
|
||||||
|
{
|
||||||
|
@if (link is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Get the link"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
|
||||||
|
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||||
|
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (state.ExportState == "failed")
|
||||||
|
{
|
||||||
|
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2" data-archive-import>
|
||||||
|
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
|
||||||
|
<p class="text-sm opacity-70">@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]</p>
|
||||||
|
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
|
||||||
|
@foreach (var part in Parts)
|
||||||
|
{
|
||||||
|
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
|
||||||
|
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||||
|
@if (!Importing)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
|
||||||
|
data-archive-import-start>
|
||||||
|
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
|
||||||
|
@Localizer["Stop"]
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (uploading)
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
|
||||||
|
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
|
||||||
|
}
|
||||||
|
@if (state.ImportState is not (null or "none"))
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
|
||||||
|
@if (state.ImportState is "queued" or "running")
|
||||||
|
{
|
||||||
|
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
|
||||||
|
}
|
||||||
|
<span class="text-sm">
|
||||||
|
@(state.ImportState switch
|
||||||
|
{
|
||||||
|
"queued" => Localizer["Waiting to start."],
|
||||||
|
"running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal],
|
||||||
|
"done" => Localizer["Imported."],
|
||||||
|
"stopped" => Localizer["Stopped."],
|
||||||
|
_ => Localizer["The import failed: {0}", state.ImportError ?? ""]
|
||||||
|
})
|
||||||
|
</span>
|
||||||
|
@if (state.ImportFrom is { Length: > 0 } from)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
|
||||||
|
}
|
||||||
|
<ul class="text-xs tabular-nums">
|
||||||
|
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
<li data-archive-count="@what">@count × @CountName(what)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] ArchiveService Archives { get; set; }
|
||||||
|
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
|
||||||
|
[Inject] AppConfiguration Configuration { get; set; }
|
||||||
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
|
const string FileInput = "input[data-archive-file]";
|
||||||
|
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
|
||||||
|
static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
||||||
|
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
||||||
|
|
||||||
|
ViewPersonaArchive state = new();
|
||||||
|
string loadedFor;
|
||||||
|
HashSet<string> chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"];
|
||||||
|
bool chosenFlag;
|
||||||
|
bool busy;
|
||||||
|
string link;
|
||||||
|
string pickedName;
|
||||||
|
bool uploading;
|
||||||
|
long uploaded;
|
||||||
|
long uploadSize;
|
||||||
|
double uploadedPercent;
|
||||||
|
IJSObjectReference transfer;
|
||||||
|
DotNetObjectReference<PersonaArchivePanel> self;
|
||||||
|
CancellationTokenSource polling;
|
||||||
|
|
||||||
|
bool Importing => uploading || state.ImportState is "queued" or "running";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// another persona: its own archive
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!IsLoading && loadedFor != CascadingState.PersonaId)
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
loadedFor = CascadingState.PersonaId;
|
||||||
|
link = null;
|
||||||
|
if (loadedFor is null)
|
||||||
|
return;
|
||||||
|
state = await Archives.State(loadedFor) ?? new ViewPersonaArchive();
|
||||||
|
if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running")
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Poll()
|
||||||
|
{
|
||||||
|
if (polling is not null)
|
||||||
|
return;
|
||||||
|
polling = new();
|
||||||
|
var token = polling.Token;
|
||||||
|
var persona = loadedFor;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(PollEvery);
|
||||||
|
while (await timer.WaitForNextTickAsync(token))
|
||||||
|
{
|
||||||
|
if (persona != loadedFor)
|
||||||
|
break;
|
||||||
|
if (await Archives.State(persona, token) is { } read)
|
||||||
|
state = read;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running"))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
polling = null;
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string PartName(string part) => part switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["Posts and their pictures"],
|
||||||
|
"profile" => Localizer["Profile: name, words, fields, pictures"],
|
||||||
|
"follows" => Localizer["Follows, asked again"],
|
||||||
|
"blocks" => Localizer["Blocks"],
|
||||||
|
"mutes" => Localizer["Mutes"],
|
||||||
|
"domainblocks" => Localizer["Blocked servers"],
|
||||||
|
"lists" => Localizer["Lists"],
|
||||||
|
"bookmarks" => Localizer["Bookmarks"],
|
||||||
|
"filters" => Localizer["Filters"],
|
||||||
|
"tags" => Localizer["Followed hashtags"],
|
||||||
|
"notificationpolicy" => Localizer["Notification policy"],
|
||||||
|
"pins" => Localizer["Pinned posts"],
|
||||||
|
"located" => Localizer["Located posts"],
|
||||||
|
"scheduled" => Localizer["Scheduled posts"],
|
||||||
|
"likes" => Localizer["Likes, given again"],
|
||||||
|
_ => part
|
||||||
|
};
|
||||||
|
|
||||||
|
string CountName(string what) => what switch
|
||||||
|
{
|
||||||
|
"posts" => Localizer["posts imported"],
|
||||||
|
"posts already here" => Localizer["posts already here"],
|
||||||
|
"posts deleted here" => Localizer["posts deleted here, left deleted"],
|
||||||
|
"boosts skipped" => Localizer["boosts left out"],
|
||||||
|
"direct or circle posts skipped" => Localizer["private posts left out"],
|
||||||
|
"not the archive's own" => Localizer["posts by others left out"],
|
||||||
|
"not a post" => Localizer["other items left out"],
|
||||||
|
"media" => Localizer["pictures and files imported"],
|
||||||
|
"media not in the archive" => Localizer["pictures not in the archive"],
|
||||||
|
"media refused" => Localizer["pictures refused"],
|
||||||
|
"located posts" => Localizer["located posts imported"],
|
||||||
|
"located posts already here" => Localizer["located posts already here"],
|
||||||
|
"scheduled posts" => Localizer["scheduled posts imported"],
|
||||||
|
"scheduled posts past or refused" => Localizer["scheduled posts past or refused"],
|
||||||
|
"followed hashtags" => Localizer["hashtags followed"],
|
||||||
|
"notification policy" => Localizer["notification policy set"],
|
||||||
|
"filters" => Localizer["filters imported"],
|
||||||
|
"pins" => Localizer["posts pinned"],
|
||||||
|
"blocked servers" => Localizer["servers blocked"],
|
||||||
|
"blocks" => Localizer["accounts blocked"],
|
||||||
|
"mutes" => Localizer["accounts muted"],
|
||||||
|
"follows" => Localizer["follows asked"],
|
||||||
|
"lists" => Localizer["lists made"],
|
||||||
|
"list members" => Localizer["list members added"],
|
||||||
|
"list members not followed" => Localizer["list members not followed"],
|
||||||
|
"bookmarks" => Localizer["bookmarks imported"],
|
||||||
|
"likes" => Localizer["likes given"],
|
||||||
|
"profile" => Localizer["profile updated"],
|
||||||
|
"pictures refused" => Localizer["pictures refused"],
|
||||||
|
_ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]],
|
||||||
|
_ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]],
|
||||||
|
_ => what
|
||||||
|
};
|
||||||
|
|
||||||
|
void Choose(string part, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
chosen.Add(part);
|
||||||
|
else
|
||||||
|
chosen.Remove(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
|
||||||
|
|
||||||
|
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
|
||||||
|
|
||||||
|
async Task Export()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Export(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)result.Data;
|
||||||
|
link = null;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Link()
|
||||||
|
{
|
||||||
|
busy = true;
|
||||||
|
var result = await Archives.Ticket(loadedFor);
|
||||||
|
busy = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Import()
|
||||||
|
{
|
||||||
|
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
|
||||||
|
self ??= DotNetObjectReference.Create(this);
|
||||||
|
var started = await Archives.StartUpload();
|
||||||
|
if (started.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload = (ViewArchiveUpload)started.Data;
|
||||||
|
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
|
||||||
|
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri;
|
||||||
|
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
|
||||||
|
uploading = false;
|
||||||
|
if (!sent.Ok)
|
||||||
|
{
|
||||||
|
await Archives.CancelUpload(upload.Id);
|
||||||
|
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] });
|
||||||
|
if (asked.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(asked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = (ViewPersonaArchive)asked.Data;
|
||||||
|
Poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Stop()
|
||||||
|
{
|
||||||
|
var result = await Archives.Stop(loadedFor);
|
||||||
|
if (result.IsInvalid)
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public Task Progress(long received, long total)
|
||||||
|
{
|
||||||
|
(uploaded, uploadSize) = (received, total);
|
||||||
|
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
|
||||||
|
return InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UploadOutcome
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
polling?.Cancel();
|
||||||
|
self?.Dispose();
|
||||||
|
if (transfer is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await transfer.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,35 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||||
<div class="relative w-full">
|
<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="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? 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)"
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
|
||||||
alt="@Localizer["Avatar"]" />
|
alt="@Localizer["Avatar"]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3 pt-6">
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
<span>@Localizer["Avatar"]</span>
|
<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 />
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
@if (HasOwn(account.Avatar) && form.Avatar is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
<span>@Localizer["Header"]</span>
|
<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 />
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
</label>
|
</label>
|
||||||
|
@if (HasOwn(account.Header) && form.Header is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 max-w-md">
|
<label class="flex flex-col gap-1 max-w-md">
|
||||||
@@ -110,11 +126,10 @@ else
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
[Inject] ILoggingService Logger { 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];
|
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||||
|
|
||||||
Account account;
|
Account account;
|
||||||
@@ -122,6 +137,13 @@ else
|
|||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
bool isSaving;
|
bool isSaving;
|
||||||
List<string> pictureErrors = [];
|
List<string> pictureErrors = [];
|
||||||
|
// the picture types the server takes, read with the account
|
||||||
|
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
|
||||||
|
string PictureTypes => string.Join(",", pictureTypes);
|
||||||
|
|
||||||
|
// a picture of its own, not the server's default
|
||||||
|
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -145,6 +167,7 @@ else
|
|||||||
{
|
{
|
||||||
// the account as the persona sees itself, with its Source
|
// the account as the persona sees itself, with its Source
|
||||||
account = await AuthService.CurrentAccount();
|
account = await AuthService.CurrentAccount();
|
||||||
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||||
Reset();
|
Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,32 +188,56 @@ else
|
|||||||
{
|
{
|
||||||
pictureErrors = [];
|
pictureErrors = [];
|
||||||
var file = eventArgs.File;
|
var file = eventArgs.File;
|
||||||
if (file.Size > MaximumPictureSize)
|
if (!pictureTypes.Contains(file.ContentType))
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.Size > ProfileForm.MaximumPictureSize)
|
||||||
{
|
{
|
||||||
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
|
||||||
{
|
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
|
||||||
using var memoryStream = new MemoryStream();
|
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||||
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)
|
if (isAvatar)
|
||||||
form.Avatar = picture;
|
form.Avatar = picture;
|
||||||
else
|
else
|
||||||
form.Header = picture;
|
form.Header = picture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
|
||||||
|
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(buffer);
|
||||||
|
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task RemovePicture(bool avatar)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.RemovePicture(avatar);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
|
||||||
|
}
|
||||||
|
|
||||||
async Task Save()
|
async Task Save()
|
||||||
{
|
{
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(List<string>))]
|
[JsonSerializable(typeof(List<string>))]
|
||||||
[JsonSerializable(typeof(SearchResults))]
|
[JsonSerializable(typeof(SearchResults))]
|
||||||
[JsonSerializable(typeof(MediaAttachment))]
|
[JsonSerializable(typeof(MediaAttachment))]
|
||||||
|
[JsonSerializable(typeof(InstanceV2))]
|
||||||
[JsonSerializable(typeof(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
[JsonSerializable(typeof(List<Notification>))]
|
[JsonSerializable(typeof(List<Notification>))]
|
||||||
[JsonSerializable(typeof(Conversation))]
|
[JsonSerializable(typeof(Conversation))]
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ public static class APIs
|
|||||||
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||||
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||||
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||||
|
public const string GET_AdminBackups = "clientapi/admin/backups";
|
||||||
|
public const string POST_AdminBackup = "clientapi/admin/backups";
|
||||||
|
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket";
|
||||||
|
public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore";
|
||||||
|
public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads";
|
||||||
|
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
|
||||||
|
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
|
||||||
|
public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
|
||||||
|
public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
|
||||||
|
public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
|
||||||
|
public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
|
||||||
|
public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
|
public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class OAuth
|
public static class OAuth
|
||||||
@@ -73,6 +90,11 @@ public static class APIs
|
|||||||
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
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 static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public const string GET_InstanceV2 = "api/v2/instance";
|
||||||
|
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
public const string GET_Relationships = "api/v1/accounts/relationships";
|
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||||
public const string GET_FollowRequests = "api/v1/follow_requests";
|
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class ComposeForm
|
|||||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
|
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
|
||||||
|
public class InstanceV2
|
||||||
|
{
|
||||||
|
public InstanceConfiguration Configuration { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstanceConfiguration
|
||||||
|
{
|
||||||
|
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
|
||||||
|
public class MediaConfiguration
|
||||||
|
{
|
||||||
|
public List<string> SupportedMimeTypes { get; set; } = [];
|
||||||
|
public long ImageSizeLimit { get; set; }
|
||||||
|
public long VideoSizeLimit { get; set; }
|
||||||
|
public long ImageMatrixLimit { get; set; }
|
||||||
|
public long VideoMatrixLimit { get; set; }
|
||||||
|
public int VideoFrameRateLimit { get; set; }
|
||||||
|
}
|
||||||
@@ -189,13 +189,23 @@ public class PollForm
|
|||||||
public bool Multiple { get; set; }
|
public bool Multiple { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
|
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||||
|
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||||
public class StatusEditForm
|
public class StatusEditForm
|
||||||
{
|
{
|
||||||
public string Status { get; set; }
|
public string Status { get; set; }
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
public bool Sensitive { get; set; }
|
public bool Sensitive { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
public List<string> MediaIds { get; set; } = [];
|
||||||
|
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||||
|
public class MediaAttributeForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ namespace decePubClient.Models
|
|||||||
public bool ThemeIsDarkGray { get; set; } = false;
|
public bool ThemeIsDarkGray { get; set; } = false;
|
||||||
|
|
||||||
public bool ShowDonatorBadge { get; set; } = false;
|
public bool ShowDonatorBadge { get; set; } = false;
|
||||||
|
// before publishing pictures without a description, the composer asks once
|
||||||
|
public bool RemindAltText { get; set; } = true;
|
||||||
|
|
||||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||||
|
|||||||
@@ -91,10 +91,14 @@ namespace decePubClient.Models
|
|||||||
|
|
||||||
static string Flag(bool value) => value ? "true" : "false";
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
static ByteArrayContent Picture(UploadMedia media)
|
// PrivaPub keeps pictures up to this size and resizes them
|
||||||
|
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
// streamed from the browser's file, read again for each attempt
|
||||||
|
static StreamContent Picture(UploadMedia media)
|
||||||
{
|
{
|
||||||
var picture = new ByteArrayContent(media.Blob);
|
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||||
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||||
return picture;
|
return picture;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -1,17 +1,48 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
public enum UploadState
|
||||||
|
{
|
||||||
|
Uploading,
|
||||||
|
Processing,
|
||||||
|
Ready,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||||
|
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||||
|
/// </summary>
|
||||||
public class UploadMedia
|
public class UploadMedia
|
||||||
{
|
{
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
public string FileName { get; set; }
|
public string FileName { get; set; }
|
||||||
public string AltText { get; set; }
|
public string AltText { get; set; }
|
||||||
public byte[] Blob { get; set; } = [];
|
public long Size { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public string Base64Preview { get; set; }
|
public IBrowserFile File { get; set; }
|
||||||
|
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public long Size { get; set; }
|
public string Preview { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public UploadState State { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Error { get; set; }
|
||||||
|
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string SentAltText { get; set; }
|
||||||
|
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public bool Posted { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public CancellationTokenSource Cancel { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public Task Done { get; set; } = Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
<Title>@Localizer["Administration"]</Title>
|
<Title>@Localizer["Administration"]</Title>
|
||||||
|
|
||||||
@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. The
|
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
|
||||||
|
up and restore the server. The
|
||||||
other sections wait for PrivaPub endpoints that do not exist yet. *@
|
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">
|
||||||
@@ -65,6 +66,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="block w-full p-3 md:p-4">
|
||||||
|
<BackupsPanel />
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||||
|
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
|
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
|
data-remind-alt-text />
|
||||||
<label class="flex flex-col gap-2 max-w-md">
|
<label class="flex flex-col gap-2 max-w-md">
|
||||||
<span class="text-sm">@Localizer["Language"]</span>
|
<span class="text-sm">@Localizer["Language"]</span>
|
||||||
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
||||||
@@ -130,7 +133,10 @@
|
|||||||
<div class="block w-full p-3 md:p-4">
|
<div class="block w-full p-3 md:p-4">
|
||||||
@if (CascadingState.IsSignedIn)
|
@if (CascadingState.IsSignedIn)
|
||||||
{
|
{
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<PersonaArchivePanel />
|
||||||
<DataTransfer />
|
<DataTransfer />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
.AddScoped<PortabilityService>()
|
.AddScoped<PortabilityService>()
|
||||||
.AddScoped<AdminService>()
|
.AddScoped<AdminService>()
|
||||||
|
.AddScoped<ArchiveService>()
|
||||||
.AddScoped<ListService>()
|
.AddScoped<ListService>()
|
||||||
.AddScoped<FilterService>()
|
.AddScoped<FilterService>()
|
||||||
.AddScoped<DiscoveryService>()
|
.AddScoped<DiscoveryService>()
|
||||||
@@ -71,6 +72,8 @@ builder.Services.AddHttpClient("default", client =>
|
|||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
||||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
// each request has its own timeout (HttpService.Measure): an upload may take minutes, anything else 100 s
|
||||||
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddSingleton(serviceProvider =>
|
builder.Services.AddSingleton(serviceProvider =>
|
||||||
|
|||||||
@@ -1898,4 +1898,352 @@
|
|||||||
<data name="{0}× anisotropic" xml:space="preserve">
|
<data name="{0}× anisotropic" xml:space="preserve">
|
||||||
<value>anisotropia {0}×</value>
|
<value>anisotropia {0}×</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Avatar removed." xml:space="preserve">
|
||||||
|
<value>Avatar rimosso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Header removed." xml:space="preserve">
|
||||||
|
<value>Intestazione rimossa.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe them" xml:space="preserve">
|
||||||
|
<value>Descrivile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media unavailable" xml:space="preserve">
|
||||||
|
<value>Contenuto non disponibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Processing" xml:space="preserve">
|
||||||
|
<value>In elaborazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish anyway" xml:space="preserve">
|
||||||
|
<value>Pubblica comunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ready" xml:space="preserve">
|
||||||
|
<value>Pronto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
|
||||||
|
<value>Ricordami di descrivere le immagini prima di pubblicare</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the avatar" xml:space="preserve">
|
||||||
|
<value>Rimuovi l'avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the header" xml:space="preserve">
|
||||||
|
<value>Rimuovi l'intestazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploading" xml:space="preserve">
|
||||||
|
<value>Caricamento</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
|
||||||
|
<value>Attendi che i file finiscano di caricarsi, o rimuovi quelli non riusciti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
|
||||||
|
<value>{0} delle immagini non hanno una descrizione, su cui conta chi non può vederle.</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a kind of file this server takes." xml:space="preserve">
|
||||||
|
<value>"{0}" non è un tipo di file che questo server accetta.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A backup is running." xml:space="preserve">
|
||||||
|
<value>È in corso un backup.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A restore is running." xml:space="preserve">
|
||||||
|
<value>È in corso un ripristino.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Back up now" xml:space="preserve">
|
||||||
|
<value>Fai un backup ora</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backups" xml:space="preserve">
|
||||||
|
<value>Backup</value>
|
||||||
|
</data>
|
||||||
|
<data name="Before a deploy" xml:space="preserve">
|
||||||
|
<value>Prima di un rilascio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Before a restore" xml:space="preserve">
|
||||||
|
<value>Prima di un ripristino</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete this backup?" xml:space="preserve">
|
||||||
|
<value>Eliminare questo backup?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
|
||||||
|
<value>Eliminati di nuovo: {0} account, {1} persona, {2} gruppi, {3} post. Mantenuti da allora: {4} blocchi, silenziamenti, segnalazioni e altri atti di protezione.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download" xml:space="preserve">
|
||||||
|
<value>Scarica</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download a backup" xml:space="preserve">
|
||||||
|
<value>Scarica un backup</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download {0}" xml:space="preserve">
|
||||||
|
<value>Scarica {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Get the link" xml:space="preserve">
|
||||||
|
<value>Ottieni il link</value>
|
||||||
|
</data>
|
||||||
|
<data name="It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
|
||||||
|
<value>Contiene le chiavi private e i post privati di ogni persona: tienilo dove solo tu puoi leggerlo. La tua password, per avere un link:</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
|
||||||
|
<value>I suoi file vengono eliminati; il server non viene toccato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
|
||||||
|
<value>Creati dopo il backup, ora eliminati mantenendo i loro nomi: {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made by hand" xml:space="preserve">
|
||||||
|
<value>Fatto a mano</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nightly" xml:space="preserve">
|
||||||
|
<value>Notturno</value>
|
||||||
|
</data>
|
||||||
|
<data name="No backup yet." xml:space="preserve">
|
||||||
|
<value>Ancora nessun backup.</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
|
||||||
|
<value>PrivaPub si sta riavviando per ripristinare {0}. Tutti, tu compreso, accederanno di nuovo quando tornerà.</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
|
||||||
|
<value>PrivaPub si ferma, ripristina questo backup e riparte, in pochi minuti. Tutti dovranno accedere di nuovo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore" xml:space="preserve">
|
||||||
|
<value>Ripristina</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore the backup of {0}?" xml:space="preserve">
|
||||||
|
<value>Ripristinare il backup del {0}?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restored {0}" xml:space="preserve">
|
||||||
|
<value>Ripristinato {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The backup has started." xml:space="preserve">
|
||||||
|
<value>Il backup è iniziato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The backup {0} was uploaded." xml:space="preserve">
|
||||||
|
<value>Il backup {0} è stato caricato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
|
||||||
|
<value>L'ultimo ripristino, di {0}, è stato abbandonato; non è cambiato nulla.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
|
||||||
|
<value>Il link funziona per dieci minuti, solo per questo backup.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
|
||||||
|
<value>Il ripristino di {0}, chiesto da {1}, è {2}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The upload failed: {0}" xml:space="preserve">
|
||||||
|
<value>Il caricamento non è riuscito: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Type {0} to confirm" xml:space="preserve">
|
||||||
|
<value>Scrivi {0} per confermare</value>
|
||||||
|
</data>
|
||||||
|
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
|
||||||
|
<value>Carica un backup scaricato da qui (un file .tar):</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploaded" xml:space="preserve">
|
||||||
|
<value>Caricato</value>
|
||||||
|
</data>
|
||||||
|
<data name="What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names." xml:space="preserve">
|
||||||
|
<value>Quanto fatto da allora va perso: post, modifiche, richieste di seguire, impostazioni. Ciò che protegge no: eliminazioni, blocchi, silenziamenti, server bloccati, segnalazioni, filtri e nomi riservati fatti da allora restano, e i follower restano quelli di adesso. Account, persona e gruppi creati da allora vengono eliminati, mantenendo i loro nomi.</value>
|
||||||
|
</data>
|
||||||
|
<data name="asked for by {0}" xml:space="preserve">
|
||||||
|
<value>chiesto da {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="not read at one instant" xml:space="preserve">
|
||||||
|
<value>non letto in un unico istante</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} documents in {1} collections" xml:space="preserve">
|
||||||
|
<value>{0} documenti in {1} collezioni</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
|
||||||
|
<value>{0} documenti in {1} collezioni; {2} file multimediali recuperati, {3} mancanti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} free on the server's disk" xml:space="preserve">
|
||||||
|
<value>{0} liberi sul disco del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
|
||||||
|
<value>{0} account sono stati eliminati da allora; un ripristino li elimina di nuovo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} media files ({1})" xml:space="preserve">
|
||||||
|
<value>{0} file multimediali ({1})</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
|
||||||
|
<value>{0} file multimediali elencati, conservati dai backup notturni</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} missing" xml:space="preserve">
|
||||||
|
<value>{0} mancanti</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of {1}" xml:space="preserve">
|
||||||
|
<value>{0} di {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}." xml:space="preserve">
|
||||||
|
<value>{0} post creati da allora risultano eliminati; {1} file sono andati nel cestino; {2} sessioni terminate. Il server com'era prima è il backup {3}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="pending" xml:space="preserve">
|
||||||
|
<value>in attesa</value>
|
||||||
|
</data>
|
||||||
|
<data name="running" xml:space="preserve">
|
||||||
|
<value>in corso</value>
|
||||||
|
</data>
|
||||||
|
<data name="unreadable" xml:space="preserve">
|
||||||
|
<value>illeggibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again." xml:space="preserve">
|
||||||
|
<value>Un archivio fatto qui, o da Mastodon. I suoi post arrivano sul profilo di questa persona senza essere inviati a nessuno; i follow vengono richiesti di nuovo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocks" xml:space="preserve">
|
||||||
|
<value>Blocchi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas." xml:space="preserve">
|
||||||
|
<value>Tutto ciò che la persona ha pubblicato, con le sue immagini, chi segue, blocca e silenzia, le sue liste, i segnalibri e i filtri, nell'archivio che fa Mastodon. Niente del tuo account né delle tue altre persona.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follows, asked again" xml:space="preserve">
|
||||||
|
<value>Follow, richiesti di nuovo</value>
|
||||||
|
</data>
|
||||||
|
<data name="From this persona's own archive." xml:space="preserve">
|
||||||
|
<value>Dall'archivio di questa stessa persona.</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0}." xml:space="preserve">
|
||||||
|
<value>Da {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Imported." xml:space="preserve">
|
||||||
|
<value>Importato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Importing: {0} of {1} read." xml:space="preserve">
|
||||||
|
<value>Importazione: {0} di {1} letti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Likes, given again" xml:space="preserve">
|
||||||
|
<value>Mi piace, dati di nuovo</value>
|
||||||
|
</data>
|
||||||
|
<data name="Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only." xml:space="preserve">
|
||||||
|
<value>Rimettere i mi piace lo fa sapere all'autore di ogni post; i post localizzati e programmati vengono solo dagli archivi di PrivaPub.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Located posts" xml:space="preserve">
|
||||||
|
<value>Post localizzati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made {0}; kept until {1}." xml:space="preserve">
|
||||||
|
<value>Fatto {0}; conservato fino al {1}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make the archive" xml:space="preserve">
|
||||||
|
<value>Crea l'archivio</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mutes" xml:space="preserve">
|
||||||
|
<value>Silenziamenti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notification policy" xml:space="preserve">
|
||||||
|
<value>Regole delle notifiche</value>
|
||||||
|
</data>
|
||||||
|
<data name="Pinned posts" xml:space="preserve">
|
||||||
|
<value>Post fissati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Posts and their pictures" xml:space="preserve">
|
||||||
|
<value>Post e le loro immagini</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
|
||||||
|
<value>Profilo: nome, descrizione, campi, immagini</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore an archive into this persona" xml:space="preserve">
|
||||||
|
<value>Ripristina un archivio in questa persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sending {0} of {1}" xml:space="preserve">
|
||||||
|
<value>Invio di {0} su {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stopped." xml:space="preserve">
|
||||||
|
<value>Interrotto.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The archive could not be made: {0}" xml:space="preserve">
|
||||||
|
<value>Non è stato possibile creare l'archivio: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The import failed: {0}" xml:space="preserve">
|
||||||
|
<value>L'importazione non è riuscita: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona's archive" xml:space="preserve">
|
||||||
|
<value>L'archivio di questa persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Waiting to start." xml:space="preserve">
|
||||||
|
<value>In attesa di iniziare.</value>
|
||||||
|
</data>
|
||||||
|
<data name="accounts blocked" xml:space="preserve">
|
||||||
|
<value>account bloccati</value>
|
||||||
|
</data>
|
||||||
|
<data name="accounts muted" xml:space="preserve">
|
||||||
|
<value>account silenziati</value>
|
||||||
|
</data>
|
||||||
|
<data name="already there: {0}" xml:space="preserve">
|
||||||
|
<value>già presenti: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="bookmarks imported" xml:space="preserve">
|
||||||
|
<value>segnalibri importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="boosts left out" xml:space="preserve">
|
||||||
|
<value>condivisioni escluse</value>
|
||||||
|
</data>
|
||||||
|
<data name="filters imported" xml:space="preserve">
|
||||||
|
<value>filtri importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="follows asked" xml:space="preserve">
|
||||||
|
<value>follow richiesti</value>
|
||||||
|
</data>
|
||||||
|
<data name="hashtags followed" xml:space="preserve">
|
||||||
|
<value>hashtag seguiti</value>
|
||||||
|
</data>
|
||||||
|
<data name="likes given" xml:space="preserve">
|
||||||
|
<value>mi piace dati</value>
|
||||||
|
</data>
|
||||||
|
<data name="list members added" xml:space="preserve">
|
||||||
|
<value>membri aggiunti alle liste</value>
|
||||||
|
</data>
|
||||||
|
<data name="list members not followed" xml:space="preserve">
|
||||||
|
<value>membri delle liste non seguiti</value>
|
||||||
|
</data>
|
||||||
|
<data name="lists made" xml:space="preserve">
|
||||||
|
<value>liste create</value>
|
||||||
|
</data>
|
||||||
|
<data name="located posts already here" xml:space="preserve">
|
||||||
|
<value>post localizzati già presenti</value>
|
||||||
|
</data>
|
||||||
|
<data name="located posts imported" xml:space="preserve">
|
||||||
|
<value>post localizzati importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="not found: {0}" xml:space="preserve">
|
||||||
|
<value>non trovati: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="notification policy set" xml:space="preserve">
|
||||||
|
<value>regole delle notifiche impostate</value>
|
||||||
|
</data>
|
||||||
|
<data name="other items left out" xml:space="preserve">
|
||||||
|
<value>altri elementi esclusi</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures and files imported" xml:space="preserve">
|
||||||
|
<value>immagini e file importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures not in the archive" xml:space="preserve">
|
||||||
|
<value>immagini assenti dall'archivio</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures refused" xml:space="preserve">
|
||||||
|
<value>immagini rifiutate</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts already here" xml:space="preserve">
|
||||||
|
<value>post già presenti</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts by others left out" xml:space="preserve">
|
||||||
|
<value>post di altri esclusi</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts deleted here, left deleted" xml:space="preserve">
|
||||||
|
<value>post eliminati qui, lasciati eliminati</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts imported" xml:space="preserve">
|
||||||
|
<value>post importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts pinned" xml:space="preserve">
|
||||||
|
<value>post fissati</value>
|
||||||
|
</data>
|
||||||
|
<data name="private posts left out" xml:space="preserve">
|
||||||
|
<value>post privati esclusi</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile updated" xml:space="preserve">
|
||||||
|
<value>profilo aggiornato</value>
|
||||||
|
</data>
|
||||||
|
<data name="scheduled posts imported" xml:space="preserve">
|
||||||
|
<value>post programmati importati</value>
|
||||||
|
</data>
|
||||||
|
<data name="scheduled posts past or refused" xml:space="preserve">
|
||||||
|
<value>post programmati scaduti o rifiutati</value>
|
||||||
|
</data>
|
||||||
|
<data name="servers blocked" xml:space="preserve">
|
||||||
|
<value>server bloccati</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -1898,4 +1898,352 @@
|
|||||||
<data name="{0}× anisotropic" xml:space="preserve">
|
<data name="{0}× anisotropic" xml:space="preserve">
|
||||||
<value>{0}× anisotropic</value>
|
<value>{0}× anisotropic</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Avatar removed." xml:space="preserve">
|
||||||
|
<value>Avatar removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Header removed." xml:space="preserve">
|
||||||
|
<value>Header removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe them" xml:space="preserve">
|
||||||
|
<value>Describe them</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media unavailable" xml:space="preserve">
|
||||||
|
<value>Media unavailable</value>
|
||||||
|
</data>
|
||||||
|
<data name="Processing" xml:space="preserve">
|
||||||
|
<value>Processing</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish anyway" xml:space="preserve">
|
||||||
|
<value>Publish anyway</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ready" xml:space="preserve">
|
||||||
|
<value>Ready</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
|
||||||
|
<value>Remind me to describe pictures before publishing</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the avatar" xml:space="preserve">
|
||||||
|
<value>Remove the avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the header" xml:space="preserve">
|
||||||
|
<value>Remove the header</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploading" xml:space="preserve">
|
||||||
|
<value>Uploading</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
|
||||||
|
<value>Wait for the files to finish uploading, or remove the ones that failed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
|
||||||
|
<value>{0} of the pictures have no description, which people who can't see them rely on.</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a kind of file this server takes." xml:space="preserve">
|
||||||
|
<value>"{0}" is not a kind of file this server takes.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A backup is running." xml:space="preserve">
|
||||||
|
<value>A backup is running.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A restore is running." xml:space="preserve">
|
||||||
|
<value>A restore is running.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Back up now" xml:space="preserve">
|
||||||
|
<value>Back up now</value>
|
||||||
|
</data>
|
||||||
|
<data name="Backups" xml:space="preserve">
|
||||||
|
<value>Backups</value>
|
||||||
|
</data>
|
||||||
|
<data name="Before a deploy" xml:space="preserve">
|
||||||
|
<value>Before a deploy</value>
|
||||||
|
</data>
|
||||||
|
<data name="Before a restore" xml:space="preserve">
|
||||||
|
<value>Before a restore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete this backup?" xml:space="preserve">
|
||||||
|
<value>Delete this backup?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
|
||||||
|
<value>Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download" xml:space="preserve">
|
||||||
|
<value>Download</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download a backup" xml:space="preserve">
|
||||||
|
<value>Download a backup</value>
|
||||||
|
</data>
|
||||||
|
<data name="Download {0}" xml:space="preserve">
|
||||||
|
<value>Download {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Get the link" xml:space="preserve">
|
||||||
|
<value>Get the link</value>
|
||||||
|
</data>
|
||||||
|
<data name="It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
|
||||||
|
<value>It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
|
||||||
|
<value>Its files are deleted; the server itself is untouched.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
|
||||||
|
<value>Made after the backup, now deleted with their names kept: {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made by hand" xml:space="preserve">
|
||||||
|
<value>Made by hand</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nightly" xml:space="preserve">
|
||||||
|
<value>Nightly</value>
|
||||||
|
</data>
|
||||||
|
<data name="No backup yet." xml:space="preserve">
|
||||||
|
<value>No backup yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
|
||||||
|
<value>PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.</value>
|
||||||
|
</data>
|
||||||
|
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
|
||||||
|
<value>PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore" xml:space="preserve">
|
||||||
|
<value>Restore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore the backup of {0}?" xml:space="preserve">
|
||||||
|
<value>Restore the backup of {0}?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restored {0}" xml:space="preserve">
|
||||||
|
<value>Restored {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The backup has started." xml:space="preserve">
|
||||||
|
<value>The backup has started.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The backup {0} was uploaded." xml:space="preserve">
|
||||||
|
<value>The backup {0} was uploaded.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
|
||||||
|
<value>The last restore, of {0}, was abandoned; nothing changed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
|
||||||
|
<value>The link works for ten minutes, for this backup only.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
|
||||||
|
<value>The restore of {0}, asked for by {1}, is {2}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The upload failed: {0}" xml:space="preserve">
|
||||||
|
<value>The upload failed: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Type {0} to confirm" xml:space="preserve">
|
||||||
|
<value>Type {0} to confirm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
|
||||||
|
<value>Upload a backup downloaded from here (a .tar file):</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploaded" xml:space="preserve">
|
||||||
|
<value>Uploaded</value>
|
||||||
|
</data>
|
||||||
|
<data name="What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names." xml:space="preserve">
|
||||||
|
<value>What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names.</value>
|
||||||
|
</data>
|
||||||
|
<data name="asked for by {0}" xml:space="preserve">
|
||||||
|
<value>asked for by {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="not read at one instant" xml:space="preserve">
|
||||||
|
<value>not read at one instant</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} documents in {1} collections" xml:space="preserve">
|
||||||
|
<value>{0} documents in {1} collections</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
|
||||||
|
<value>{0} documents in {1} collections; {2} media files brought back, {3} missing.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} free on the server's disk" xml:space="preserve">
|
||||||
|
<value>{0} free on the server's disk</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
|
||||||
|
<value>{0} logins were deleted since; a restore deletes them again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} media files ({1})" xml:space="preserve">
|
||||||
|
<value>{0} media files ({1})</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
|
||||||
|
<value>{0} media files listed, kept by the nightly backups</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} missing" xml:space="preserve">
|
||||||
|
<value>{0} missing</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of {1}" xml:space="preserve">
|
||||||
|
<value>{0} of {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}." xml:space="preserve">
|
||||||
|
<value>{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="pending" xml:space="preserve">
|
||||||
|
<value>pending</value>
|
||||||
|
</data>
|
||||||
|
<data name="running" xml:space="preserve">
|
||||||
|
<value>running</value>
|
||||||
|
</data>
|
||||||
|
<data name="unreadable" xml:space="preserve">
|
||||||
|
<value>unreadable</value>
|
||||||
|
</data>
|
||||||
|
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again." xml:space="preserve">
|
||||||
|
<value>An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blocks" xml:space="preserve">
|
||||||
|
<value>Blocks</value>
|
||||||
|
</data>
|
||||||
|
<data name="Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas." xml:space="preserve">
|
||||||
|
<value>Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follows, asked again" xml:space="preserve">
|
||||||
|
<value>Follows, asked again</value>
|
||||||
|
</data>
|
||||||
|
<data name="From this persona's own archive." xml:space="preserve">
|
||||||
|
<value>From this persona's own archive.</value>
|
||||||
|
</data>
|
||||||
|
<data name="From {0}." xml:space="preserve">
|
||||||
|
<value>From {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Imported." xml:space="preserve">
|
||||||
|
<value>Imported.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Importing: {0} of {1} read." xml:space="preserve">
|
||||||
|
<value>Importing: {0} of {1} read.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Likes, given again" xml:space="preserve">
|
||||||
|
<value>Likes, given again</value>
|
||||||
|
</data>
|
||||||
|
<data name="Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only." xml:space="preserve">
|
||||||
|
<value>Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Located posts" xml:space="preserve">
|
||||||
|
<value>Located posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Made {0}; kept until {1}." xml:space="preserve">
|
||||||
|
<value>Made {0}; kept until {1}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make the archive" xml:space="preserve">
|
||||||
|
<value>Make the archive</value>
|
||||||
|
</data>
|
||||||
|
<data name="Mutes" xml:space="preserve">
|
||||||
|
<value>Mutes</value>
|
||||||
|
</data>
|
||||||
|
<data name="Notification policy" xml:space="preserve">
|
||||||
|
<value>Notification policy</value>
|
||||||
|
</data>
|
||||||
|
<data name="Pinned posts" xml:space="preserve">
|
||||||
|
<value>Pinned posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Posts and their pictures" xml:space="preserve">
|
||||||
|
<value>Posts and their pictures</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
|
||||||
|
<value>Profile: name, words, fields, pictures</value>
|
||||||
|
</data>
|
||||||
|
<data name="Restore an archive into this persona" xml:space="preserve">
|
||||||
|
<value>Restore an archive into this persona</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sending {0} of {1}" xml:space="preserve">
|
||||||
|
<value>Sending {0} of {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stopped." xml:space="preserve">
|
||||||
|
<value>Stopped.</value>
|
||||||
|
</data>
|
||||||
|
<data name="The archive could not be made: {0}" xml:space="preserve">
|
||||||
|
<value>The archive could not be made: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The import failed: {0}" xml:space="preserve">
|
||||||
|
<value>The import failed: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona's archive" xml:space="preserve">
|
||||||
|
<value>This persona's archive</value>
|
||||||
|
</data>
|
||||||
|
<data name="Waiting to start." xml:space="preserve">
|
||||||
|
<value>Waiting to start.</value>
|
||||||
|
</data>
|
||||||
|
<data name="accounts blocked" xml:space="preserve">
|
||||||
|
<value>accounts blocked</value>
|
||||||
|
</data>
|
||||||
|
<data name="accounts muted" xml:space="preserve">
|
||||||
|
<value>accounts muted</value>
|
||||||
|
</data>
|
||||||
|
<data name="already there: {0}" xml:space="preserve">
|
||||||
|
<value>already there: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="bookmarks imported" xml:space="preserve">
|
||||||
|
<value>bookmarks imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="boosts left out" xml:space="preserve">
|
||||||
|
<value>boosts left out</value>
|
||||||
|
</data>
|
||||||
|
<data name="filters imported" xml:space="preserve">
|
||||||
|
<value>filters imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="follows asked" xml:space="preserve">
|
||||||
|
<value>follows asked</value>
|
||||||
|
</data>
|
||||||
|
<data name="hashtags followed" xml:space="preserve">
|
||||||
|
<value>hashtags followed</value>
|
||||||
|
</data>
|
||||||
|
<data name="likes given" xml:space="preserve">
|
||||||
|
<value>likes given</value>
|
||||||
|
</data>
|
||||||
|
<data name="list members added" xml:space="preserve">
|
||||||
|
<value>list members added</value>
|
||||||
|
</data>
|
||||||
|
<data name="list members not followed" xml:space="preserve">
|
||||||
|
<value>list members not followed</value>
|
||||||
|
</data>
|
||||||
|
<data name="lists made" xml:space="preserve">
|
||||||
|
<value>lists made</value>
|
||||||
|
</data>
|
||||||
|
<data name="located posts already here" xml:space="preserve">
|
||||||
|
<value>located posts already here</value>
|
||||||
|
</data>
|
||||||
|
<data name="located posts imported" xml:space="preserve">
|
||||||
|
<value>located posts imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="not found: {0}" xml:space="preserve">
|
||||||
|
<value>not found: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="notification policy set" xml:space="preserve">
|
||||||
|
<value>notification policy set</value>
|
||||||
|
</data>
|
||||||
|
<data name="other items left out" xml:space="preserve">
|
||||||
|
<value>other items left out</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures and files imported" xml:space="preserve">
|
||||||
|
<value>pictures and files imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures not in the archive" xml:space="preserve">
|
||||||
|
<value>pictures not in the archive</value>
|
||||||
|
</data>
|
||||||
|
<data name="pictures refused" xml:space="preserve">
|
||||||
|
<value>pictures refused</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts already here" xml:space="preserve">
|
||||||
|
<value>posts already here</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts by others left out" xml:space="preserve">
|
||||||
|
<value>posts by others left out</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts deleted here, left deleted" xml:space="preserve">
|
||||||
|
<value>posts deleted here, left deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts imported" xml:space="preserve">
|
||||||
|
<value>posts imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="posts pinned" xml:space="preserve">
|
||||||
|
<value>posts pinned</value>
|
||||||
|
</data>
|
||||||
|
<data name="private posts left out" xml:space="preserve">
|
||||||
|
<value>private posts left out</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile updated" xml:space="preserve">
|
||||||
|
<value>profile updated</value>
|
||||||
|
</data>
|
||||||
|
<data name="scheduled posts imported" xml:space="preserve">
|
||||||
|
<value>scheduled posts imported</value>
|
||||||
|
</data>
|
||||||
|
<data name="scheduled posts past or refused" xml:space="preserve">
|
||||||
|
<value>scheduled posts past or refused</value>
|
||||||
|
</data>
|
||||||
|
<data name="servers blocked" xml:space="preserve">
|
||||||
|
<value>servers blocked</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -221,6 +221,20 @@ namespace decePubClient.Services
|
|||||||
: new WebResult { Data = account };
|
: new WebResult { Data = account };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the persona's avatar or header (the default shows again); Data is the account as it is now.</summary>
|
||||||
|
public async Task<WebResult> RemovePicture(bool avatar, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(avatar ? APIs.Mastodon.DELETE_ProfileAvatar : APIs.Mastodon.DELETE_ProfileHeader,
|
||||||
|
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>
|
/// <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) =>
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
||||||
|
|||||||
@@ -62,6 +62,74 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's backups, what runs and the last restore; null on failure.</summary>
|
||||||
|
public async Task<ViewBackups> Backups(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminBackups, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewBackups>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Backups), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts a backup; it runs on while the list says so.</summary>
|
||||||
|
public async Task<WebResult> BackUp(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackup, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> DeleteBackup(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackup(id), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A download link for a backup, for the password; Data is the ViewBackupTicket.</summary>
|
||||||
|
public async Task<WebResult> BackupTicket(string id, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupTicket(id), new BackupPasswordForm { Password = password },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupTicket>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks for a backup to be restored: PrivaPub stops within seconds and restores it as it starts again.</summary>
|
||||||
|
public async Task<WebResult> RestoreBackup(string id, RestoreBackupForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupRestore(id), form, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload to send a backup's pieces to (transfer.js); Data is the ViewBackupUpload.</summary>
|
||||||
|
public async Task<WebResult> StartBackupUpload(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackupUpload, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupUpload>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The whole upload read into a backup; Data is the ViewBackup.</summary>
|
||||||
|
public async Task<WebResult> FinishBackupUpload(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupUploadFinish(id), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackup>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> CancelBackupUpload(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackupUpload(id), 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)
|
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
|
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
|||||||
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
|
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
|
||||||
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
|
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
|
||||||
|
|
||||||
|
// read once: a card asks it for each gifv it shows
|
||||||
|
bool? reducedMotion;
|
||||||
|
|
||||||
|
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
||||||
|
|
||||||
// a sideways-scrolling strip of tabs shows its selected tab
|
// a sideways-scrolling strip of tabs shows its selected tab
|
||||||
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.User;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A persona's whole archive on PrivaPub (/clientapi/persona/{id}/archive): made in a job and downloaded through a
|
||||||
|
/// link, or another archive (its own, or Mastodon's) uploaded in pieces by js/transfer.js and imported into it, the
|
||||||
|
/// parts chosen, until it is done or asked to stop. As the root. Values null on failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<ViewPersonaArchive> State(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.ClientApi.GET_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ArchiveService)}.{nameof(State)}", cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks for the archive to be made; Data is the state.</summary>
|
||||||
|
public async Task<WebResult> Export(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A link to download the archive made; Data is the ViewArchiveTicket.</summary>
|
||||||
|
public async Task<WebResult> Ticket(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveTicket(personaId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveTicket>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload for an archive's pieces; Data is the ViewArchiveUpload.</summary>
|
||||||
|
public async Task<WebResult> StartUpload(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_PersonaArchiveUpload, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveUpload>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> CancelUpload(string uploadId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_PersonaArchiveUpload(uploadId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The uploaded archive imported into the persona; Data is the state.</summary>
|
||||||
|
public async Task<WebResult> Import(string personaId, ImportPersonaArchiveForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImport(personaId), form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Stop(string personaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImportStop(personaId), cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,10 +310,24 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sends the request and records it in RequestStats.</summary>
|
/// <summary>Sends the request and records it in RequestStats.</summary>
|
||||||
|
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
|
||||||
|
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
|
||||||
var started = Stopwatch.GetTimestamp();
|
var started = Stopwatch.GetTimestamp();
|
||||||
var response = await httpClient.SendAsync(request, cancellationToken);
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await httpClient.SendAsync(request, timeout.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
|
||||||
|
}
|
||||||
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
||||||
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
||||||
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
||||||
|
|||||||
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
|
|||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
|
/// <summary>
|
||||||
|
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
||||||
|
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
|
||||||
|
/// </summary>
|
||||||
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
|
static readonly MediaConfiguration Fallback = new()
|
||||||
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
|
{
|
||||||
|
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
|
||||||
|
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
|
||||||
|
ImageSizeLimit = 16 * 1024 * 1024,
|
||||||
|
VideoSizeLimit = 99 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaConfiguration limits;
|
||||||
|
|
||||||
|
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
|
||||||
|
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (limits is not null)
|
||||||
|
return limits;
|
||||||
|
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode
|
||||||
|
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
|
||||||
|
return limits = instance.Configuration.MediaAttachments;
|
||||||
|
return Fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
|
||||||
|
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
||||||
{
|
{
|
||||||
var file = new ByteArrayContent(media.Blob);
|
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
|
||||||
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
|
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
|
||||||
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
||||||
if (media.AltText is { Length: > 0 } description)
|
if (media.AltText is { Length: > 0 } description)
|
||||||
content.Add(new StringContent(description), "description");
|
content.Add(new StringContent(description), "description");
|
||||||
@@ -29,6 +54,35 @@ namespace decePubClient.Services
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
|
||||||
|
/// when it can't be.</summary>
|
||||||
|
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.PartialContent)
|
||||||
|
return new WebResult();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
|
||||||
|
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
|
||||||
|
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
||||||
return attachment?.Id is null
|
return attachment?.Id is null
|
||||||
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using collAnon.Client.Services;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
@@ -35,7 +36,7 @@ namespace decePubClient.Services
|
|||||||
/// reports the rows it could not do.
|
/// reports the rows it could not do.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
||||||
InstanceService instances)
|
InstanceService instances, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The most rows read from an export or an import file.</summary>
|
/// <summary>The most rows read from an export or an import file.</summary>
|
||||||
public const int MaxRows = 5000;
|
public const int MaxRows = 5000;
|
||||||
@@ -53,23 +54,33 @@ namespace decePubClient.Services
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
||||||
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
|
||||||
|
sealed class Stop
|
||||||
{
|
{
|
||||||
var rows = data switch
|
public WebResult Failed { get; set; }
|
||||||
{
|
|
||||||
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)
|
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
|
||||||
|
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
|
var stop = new Stop();
|
||||||
|
var rows = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
|
||||||
|
PortableData.Lists => await Lists(cancellationToken),
|
||||||
|
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
||||||
|
PortableData.Mutes => await Mutes(stop, cancellationToken),
|
||||||
|
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
||||||
|
_ => await Bookmarks(personaId, stop, cancellationToken)
|
||||||
|
};
|
||||||
|
if (stop.Failed is { } failed)
|
||||||
|
return failed;
|
||||||
|
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
|
||||||
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -80,9 +91,9 @@ namespace decePubClient.Services
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<List<string>> Mutes(CancellationToken cancellationToken)
|
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var muted = await Pages(accounts.Mutes, cancellationToken);
|
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
|
||||||
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -99,7 +110,7 @@ namespace decePubClient.Services
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
|
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var rows = new List<string>();
|
var rows = new List<string>();
|
||||||
var maxId = default(string);
|
var maxId = default(string);
|
||||||
@@ -107,7 +118,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
||||||
if (result.IsInvalid || result.Data is not FeedPage page)
|
if (result.IsInvalid || result.Data is not FeedPage page)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
return rows;
|
return rows;
|
||||||
|
}
|
||||||
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
||||||
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
||||||
}
|
}
|
||||||
@@ -116,7 +130,7 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
// every page of an account list, up to MaxRows
|
// every page of an account list, up to MaxRows
|
||||||
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
|
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var all = new List<Account>();
|
var all = new List<Account>();
|
||||||
var maxId = default(string);
|
var maxId = default(string);
|
||||||
@@ -124,7 +138,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var result = await page(maxId, cancellationToken);
|
var result = await page(maxId, cancellationToken);
|
||||||
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
return all;
|
return all;
|
||||||
|
}
|
||||||
all.AddRange(accountPage.Accounts);
|
all.AddRange(accountPage.Accounts);
|
||||||
maxId = accountPage.MaxId;
|
maxId = accountPage.MaxId;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-13
@@ -33,27 +33,25 @@ namespace decePubClient.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
|
/// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
|
||||||
|
/// it is picked); a description changed since is sent first.</summary>
|
||||||
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (form.IsLocated || form.GroupId is not null)
|
if (form.IsLocated || form.GroupId is not null)
|
||||||
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
||||||
|
|
||||||
var mediaIds = new List<string>();
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
foreach (var media in form.Media)
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
{
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
var uploaded = await mediaService.Upload(media, cancellationToken);
|
return undescribed;
|
||||||
if (uploaded.IsInvalid)
|
var mediaIds = form.Media.Select(m => m.Id).ToList();
|
||||||
return uploaded;
|
|
||||||
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
||||||
{
|
{
|
||||||
Status = form.Content ?? string.Empty,
|
Status = form.Content ?? string.Empty,
|
||||||
MediaIds = mediaIds,
|
MediaIds = mediaIds,
|
||||||
InReplyToId = form.InReplyToId,
|
InReplyToId = form.InReplyToId,
|
||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.Visibility,
|
||||||
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
@@ -70,6 +68,20 @@ namespace decePubClient.Services
|
|||||||
return new WebResult { Data = FeedPost.Of(status) };
|
return new WebResult { Data = FeedPost.Of(status) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
|
||||||
|
// described again through the edit's media_attributes)
|
||||||
|
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
|
||||||
|
{
|
||||||
|
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
|
||||||
|
if (described.IsInvalid)
|
||||||
|
return described;
|
||||||
|
media.SentAltText = media.AltText;
|
||||||
|
}
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
||||||
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -104,15 +116,23 @@ namespace decePubClient.Services
|
|||||||
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
|
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
|
||||||
|
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
|
||||||
|
/// changed; Data is the edited Status.</summary>
|
||||||
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
|
return undescribed;
|
||||||
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
|
||||||
{
|
{
|
||||||
Status = form.Content ?? string.Empty,
|
Status = form.Content ?? string.Empty,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
MediaIds = [.. form.Media.Select(m => m.Id)],
|
||||||
|
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
|
||||||
}, cancellationToken: cancellationToken);
|
}, 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);
|
||||||
|
|||||||
+35
-2
@@ -210,21 +210,54 @@
|
|||||||
|
|
||||||
&:not(:where(.file-input-ghost, .file-input-neutral, .file-input-primary, .file-input-secondary, .file-input-accent, .file-input-info, .file-input-success, .file-input-warning, .file-input-error, .validator:user-invalid, [aria-invalid="true"])) {
|
&:not(:where(.file-input-ghost, .file-input-neutral, .file-input-primary, .file-input-secondary, .file-input-accent, .file-input-info, .file-input-success, .file-input-warning, .file-input-error, .validator:user-invalid, [aria-invalid="true"])) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
|
||||||
|
/* its button (Choose File, Browse…) is a raised button like every other, not daisyUI's flat neutral block; a
|
||||||
|
small gap keeps its shadow inside the well */
|
||||||
|
&::file-selector-button {
|
||||||
|
margin-block: .25rem;
|
||||||
|
margin-inline-start: .25rem;
|
||||||
|
height: calc(100% - .5rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--radius-box) - .25rem);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
background-image: linear-gradient(145deg, color-mix(in oklab, var(--color-base-100), white var(--neo-lift)), color-mix(in oklab, var(--color-base-100), black var(--neo-sink)));
|
||||||
|
box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selectors: an inset well while unchecked; once checked they keep daisyUI's filled look. */
|
/* Selectors: an inset well while unchecked. A checked box is filled like a button of the primary colour, raised, its
|
||||||
|
tick in the colour's content (daisyUI fills one only for a colour variant: without, a tick on nothing); a checked
|
||||||
|
radio keeps the well, its dot the primary colour. A colour variant keeps its own colour. */
|
||||||
@utility checkbox {
|
@utility checkbox {
|
||||||
|
&:not(:where(.checkbox-neutral, .checkbox-primary, .checkbox-secondary, .checkbox-accent, .checkbox-info, .checkbox-success, .checkbox-warning, .checkbox-error)) {
|
||||||
|
--input-color: var(--color-primary);
|
||||||
|
color: var(--color-primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
&:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) {
|
&:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
background-origin: border-box;
|
background-origin: border-box;
|
||||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"]):not(:disabled) {
|
||||||
|
border-color: transparent;
|
||||||
|
background-image: linear-gradient(145deg, color-mix(in oklab, var(--input-color), white var(--neo-lift)), color-mix(in oklab, var(--input-color), black var(--neo-sink)));
|
||||||
|
background-origin: border-box;
|
||||||
|
box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility radio {
|
@utility radio {
|
||||||
&:not(:where(:checked, [aria-checked="true"], :disabled)) {
|
&:not(:where(.radio-neutral, .radio-primary, .radio-secondary, .radio-accent, .radio-info, .radio-success, .radio-warning, .radio-error)) {
|
||||||
|
--input-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:where(:disabled)) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||||
background-origin: border-box;
|
background-origin: border-box;
|
||||||
|
|||||||
+2
-1
@@ -89,7 +89,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input:not(.input-lg, .input-xl),
|
.input:not(.input-lg, .input-xl),
|
||||||
.select:not(.select-lg, .select-xl) {
|
.select:not(.select-lg, .select-xl),
|
||||||
|
.file-input:not(.file-input-lg, .file-input-xl) {
|
||||||
--size: 2.75rem;
|
--size: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Formats.Tar;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
|
||||||
|
// downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
|
||||||
|
// pieces; each step checked on /clientapi
|
||||||
|
public sealed class BackupsTests(Browser browser)
|
||||||
|
{
|
||||||
|
const string Root = PastureAdmin.Root;
|
||||||
|
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
||||||
|
|
||||||
|
internal static async Task<ILocator> Open(IPage page)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/administration");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
var panel = header.Locator("xpath=..").Locator("[data-backups]");
|
||||||
|
await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<string> BackUp(ILocator panel)
|
||||||
|
{
|
||||||
|
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).ToHashSet();
|
||||||
|
await panel.Locator("[data-backup-now]").ClickAsync();
|
||||||
|
var made = default(string);
|
||||||
|
Assert.True(await Pasture.Eventually(async () =>
|
||||||
|
{
|
||||||
|
var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
|
||||||
|
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
|
||||||
|
return made is not null && list["running"] is null;
|
||||||
|
}, 300), "the backup was never made");
|
||||||
|
await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_backup_is_made_downloaded_and_uploaded_again()
|
||||||
|
{
|
||||||
|
await using var session = await SignIn.As(browser, Admin);
|
||||||
|
var page = session.Page;
|
||||||
|
var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.admin.backup", "privapub", Root, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page);
|
||||||
|
var id = await BackUp(panel);
|
||||||
|
var row = panel.Locator($"[data-backup='{id}']");
|
||||||
|
await Assertions.Expect(row).ToContainTextAsync("Made by hand");
|
||||||
|
|
||||||
|
// the link, for the password
|
||||||
|
await row.Locator("[data-backup-download]").ClickAsync();
|
||||||
|
var dialog = page.Locator("[data-backup-download-dialog]");
|
||||||
|
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
||||||
|
await dialog.Locator("[data-backup-confirm]").ClickAsync();
|
||||||
|
var link = dialog.Locator("[data-backup-link]");
|
||||||
|
await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
||||||
|
await download.SaveAsAsync(file);
|
||||||
|
var names = new List<string>();
|
||||||
|
await using (var reader = new TarReader(File.OpenRead(file)))
|
||||||
|
while (await reader.GetNextEntryAsync() is { } entry)
|
||||||
|
names.Add(entry.Name);
|
||||||
|
Assert.Contains($"{id}/manifest.json", names);
|
||||||
|
Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
|
||||||
|
await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
|
||||||
|
|
||||||
|
// deleted (asked first), then uploaded again from the file
|
||||||
|
await row.Locator("[data-backup-delete]").ClickAsync();
|
||||||
|
await page.Locator("[data-confirm-yes]").ClickAsync();
|
||||||
|
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||||
|
Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue<string>() == id);
|
||||||
|
|
||||||
|
await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
|
||||||
|
await panel.Locator("[data-backup-upload-start]").ClickAsync();
|
||||||
|
await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
|
||||||
|
var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!;
|
||||||
|
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
|
||||||
|
// ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
|
||||||
|
// ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
|
||||||
|
// again (town.sh renew privapub).
|
||||||
|
[Trait("Category", "Restore")]
|
||||||
|
public sealed class RestoreTests(Browser browser)
|
||||||
|
{
|
||||||
|
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_backup_is_restored_and_everyone_signs_in_again()
|
||||||
|
{
|
||||||
|
await using var session = await SignIn.As(browser, Admin);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
|
||||||
|
{
|
||||||
|
var panel = await BackupsTests.Open(page);
|
||||||
|
var id = await BackupsTests.BackUp(panel);
|
||||||
|
var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue<string>();
|
||||||
|
|
||||||
|
await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
|
||||||
|
var dialog = page.Locator("[data-backup-restore-dialog]");
|
||||||
|
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
||||||
|
var confirm = dialog.Locator("[data-backup-confirm]");
|
||||||
|
await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
|
||||||
|
await Assertions.Expect(confirm).ToBeDisabledAsync();
|
||||||
|
await dialog.Locator("[data-backup-host]").FillAsync(host);
|
||||||
|
await confirm.ClickAsync();
|
||||||
|
await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
|
||||||
|
// once PrivaPub is back, the session it ended sends the page to sign in again
|
||||||
|
await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
|
||||||
|
await Pasture.RenewTown();
|
||||||
|
}, page);
|
||||||
|
|
||||||
|
await using var again = await SignIn.As(browser, Admin);
|
||||||
|
var panelAgain = await BackupsTests.Open(again.Page);
|
||||||
|
await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||||
|
Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
|
||||||
|
await again.AssertGuards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
|
||||||
|
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
|
||||||
|
// again, and an avatar set and removed
|
||||||
|
public sealed class MediaTests(Browser browser)
|
||||||
|
{
|
||||||
|
// a 1×1 PNG
|
||||||
|
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
|
||||||
|
|
||||||
|
static async Task OpenComposer(IPage page)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Task Attach(IPage page, string name) =>
|
||||||
|
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
|
||||||
|
|
||||||
|
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
||||||
|
|
||||||
|
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
|
||||||
|
{
|
||||||
|
JsonNode latest = null;
|
||||||
|
Assert.True(await Pasture.Eventually(async () =>
|
||||||
|
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
|
||||||
|
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"a picture {Guid.NewGuid():N}"[..20];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "dot.png");
|
||||||
|
// uploaded on its own, before anything is published
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_without_a_description_is_asked_about_once()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"undescribed {Guid.NewGuid():N}"[..22];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "dot.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
|
||||||
|
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failed_upload_is_retried_alone()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"retried {Guid.NewGuid():N}"[..18];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
// the second upload is refused once, as a server would refuse it
|
||||||
|
var uploads = 0;
|
||||||
|
await page.RouteAsync("**/api/v2/media", async route =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Increment(ref uploads) == 2)
|
||||||
|
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
|
||||||
|
else
|
||||||
|
await route.ContinueAsync();
|
||||||
|
});
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "first.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Attach(page, "second.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
|
||||||
|
|
||||||
|
await page.Locator("[data-upload-retry]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
|
||||||
|
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
|
||||||
|
await alt.FillAsync("a dot");
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
|
||||||
|
// the first file was sent once: one upload for it, two for the second (refused, then retried)
|
||||||
|
Assert.Equal(3, uploads);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_avatar_is_set_and_removed_from_settings()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().Last();
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
|
||||||
|
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
|
||||||
|
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
|
||||||
|
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
|
||||||
|
Assert.DoesNotContain("missing", set);
|
||||||
|
|
||||||
|
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
|
||||||
|
// its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
|
||||||
|
// again after, so the town stays as seeded
|
||||||
|
public sealed class PersonaArchiveTests(Browser browser)
|
||||||
|
{
|
||||||
|
static async Task<ILocator> Open(IPage page)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
var panel = header.Locator("xpath=..").Locator("[data-archive]");
|
||||||
|
await panel.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_archive_is_downloaded_and_imported_into_another_persona()
|
||||||
|
{
|
||||||
|
var personas = Pasture.Personas();
|
||||||
|
var (source, target) = (personas[0], personas[1]);
|
||||||
|
var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var session = await SignIn.As(browser, source))
|
||||||
|
{
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page);
|
||||||
|
await panel.Locator("[data-archive-make]").ClickAsync();
|
||||||
|
var get = panel.Locator("[data-archive-get-link]");
|
||||||
|
await get.WaitForAsync(new() { Timeout = 120000 });
|
||||||
|
await get.ClickAsync();
|
||||||
|
var link = panel.Locator("[data-archive-link]");
|
||||||
|
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
||||||
|
await download.SaveAsAsync(file);
|
||||||
|
using var archive = ZipFile.OpenRead(file);
|
||||||
|
Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
|
||||||
|
Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
|
||||||
|
Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var session = await SignIn.As(browser, target))
|
||||||
|
{
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
|
||||||
|
{
|
||||||
|
var panel = await Open(page);
|
||||||
|
await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
|
||||||
|
foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
|
||||||
|
await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
|
||||||
|
await panel.Locator("[data-archive-import-start]").ClickAsync();
|
||||||
|
var report = panel.Locator("[data-archive-report][data-archive-state='done']");
|
||||||
|
await report.WaitForAsync(new() { Timeout = 300000 });
|
||||||
|
await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
|
||||||
|
Assert.NotEmpty(copies);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
// the copies go, as if never imported: the town stays as seeded
|
||||||
|
await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
|
||||||
|
+ "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Mobile
|
||||||
|
{
|
||||||
|
// Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export
|
||||||
|
// opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a
|
||||||
|
// ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/.
|
||||||
|
[Trait("Category", "Mobile")]
|
||||||
|
public sealed class ControlsTests(Browser browser)
|
||||||
|
{
|
||||||
|
static readonly Phone Phone = Phone.Narrow360;
|
||||||
|
static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest()
|
||||||
|
{
|
||||||
|
await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
||||||
|
await header.TapAsync();
|
||||||
|
var archive = page.Locator("[data-archive]");
|
||||||
|
await archive.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
|
||||||
|
Fit.AssertTouchable(await Fit.Now(page, "[data-archive]"));
|
||||||
|
Fit.AssertTouchable(await Fit.Now(page, "[data-import]"));
|
||||||
|
|
||||||
|
// a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise
|
||||||
|
var ticked = archive.Locator("input[type=checkbox]:checked").First;
|
||||||
|
var fill = await ticked.EvaluateAsync<string>("e => getComputedStyle(e).backgroundColor");
|
||||||
|
Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})");
|
||||||
|
// a file input is as tall as the other fields on a touch screen
|
||||||
|
foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync())
|
||||||
|
Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: "
|
||||||
|
+ await input.EvaluateAsync<string>("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }"));
|
||||||
|
// photos the size of the screen: one of an element taller than the screen makes Playwright resize the page,
|
||||||
|
// which drops the touch emulation for what comes after
|
||||||
|
Directory.CreateDirectory(OutDir);
|
||||||
|
foreach (var theme in new[] { "neo-dark", "neo-light" })
|
||||||
|
{
|
||||||
|
await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme);
|
||||||
|
foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") })
|
||||||
|
{
|
||||||
|
await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })");
|
||||||
|
await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,20 @@ namespace decePubClient.E2E.Support
|
|||||||
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
|
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session
|
||||||
|
public static async Task RenewTown()
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||||
|
foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" })
|
||||||
|
start.ArgumentList.Add(arg);
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
await process.StandardOutput.ReadToEndAsync();
|
||||||
|
var error = await process.StandardError.ReadToEndAsync();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
throw new InvalidOperationException($"town.sh renew privapub failed: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
|
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
|
||||||
public static async Task Mongo(string script)
|
public static async Task Mongo(string script)
|
||||||
{
|
{
|
||||||
|
|||||||
+24
-11
@@ -61,35 +61,48 @@
|
|||||||
return pixels
|
return pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint(image) {
|
// paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of
|
||||||
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
|
// sensitive media) for good. A changed hash, or an image given another source, is painted again.
|
||||||
|
function paint(element) {
|
||||||
|
const hash = element.dataset.blurhash
|
||||||
|
const isImage = element.tagName === "IMG"
|
||||||
|
if (!hash || element.dataset.blurhashPainted === hash || (isImage && element.complete && element.naturalWidth))
|
||||||
return
|
return
|
||||||
image.dataset.blurhashPainted = "1"
|
element.dataset.blurhashPainted = hash
|
||||||
try {
|
try {
|
||||||
const pixels = decode(image.dataset.blurhash)
|
const pixels = decode(hash)
|
||||||
if (!pixels)
|
if (!pixels)
|
||||||
return
|
return
|
||||||
const canvas = document.createElement("canvas")
|
const canvas = document.createElement("canvas")
|
||||||
canvas.width = size
|
canvas.width = size
|
||||||
canvas.height = size
|
canvas.height = size
|
||||||
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
||||||
image.style.backgroundImage = `url(${canvas.toDataURL()})`
|
element.style.backgroundImage = `url(${canvas.toDataURL()})`
|
||||||
image.style.backgroundSize = "cover"
|
element.style.backgroundSize = "cover"
|
||||||
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
|
if (isImage)
|
||||||
|
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.logFromJs?.(e.message, "blurhash")
|
window.logFromJs?.(e.message, "blurhash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scan = (root) => root.querySelectorAll("img[data-blurhash]").forEach(paint)
|
const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint)
|
||||||
new MutationObserver(records => {
|
new MutationObserver(records => {
|
||||||
for (const record of records)
|
for (const record of records) {
|
||||||
|
if (record.type === "attributes") {
|
||||||
|
// another picture in the same element (the viewer's previous and next): painted anew
|
||||||
|
if (record.attributeName === "src")
|
||||||
|
delete record.target.dataset.blurhashPainted
|
||||||
|
paint(record.target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const node of record.addedNodes)
|
for (const node of record.addedNodes)
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
if (node.matches("img[data-blurhash]"))
|
if (node.matches("[data-blurhash]"))
|
||||||
paint(node)
|
paint(node)
|
||||||
scan(node)
|
scan(node)
|
||||||
}
|
}
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
}
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] })
|
||||||
scan(document)
|
scan(document)
|
||||||
})()
|
})()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Files sent to PrivaPub in pieces, read by the browser and never copied into .NET's memory: a backup can be gigabytes.
|
||||||
|
// Each piece is a slice of the picked file PUT at the offset PrivaPub has received; a piece refused (409) or lost to the
|
||||||
|
// network carries on from what PrivaPub says it has, so a broken upload resumes. Progress goes to listener.Progress.
|
||||||
|
|
||||||
|
const attempts = 6;
|
||||||
|
|
||||||
|
function pause(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function received(endpoint, token) {
|
||||||
|
const response = await fetch(endpoint, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`PrivaPub answered ${response.status}`);
|
||||||
|
return (await response.json()).received;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the picked file's name and size, or null
|
||||||
|
export function picked(selector) {
|
||||||
|
const file = document.querySelector(selector)?.files?.[0];
|
||||||
|
return file ? { name: file.name, size: file.size } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sends the picked file: { ok, size } or { ok: false, status, error }
|
||||||
|
export async function upload(selector, endpoint, token, chunkBytes, listener) {
|
||||||
|
const file = document.querySelector(selector)?.files?.[0];
|
||||||
|
if (!file)
|
||||||
|
return { ok: false, status: 0, error: "no file" };
|
||||||
|
let offset = 0;
|
||||||
|
let failures = 0;
|
||||||
|
while (offset < file.size) {
|
||||||
|
const end = Math.min(offset + chunkBytes, file.size);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${endpoint}?offset=${offset}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream" },
|
||||||
|
body: file.slice(offset, end)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (++failures >= attempts)
|
||||||
|
return { ok: false, status: 0, error: String(error) };
|
||||||
|
await pause(1000 * 2 ** failures);
|
||||||
|
try {
|
||||||
|
offset = await received(endpoint, token);
|
||||||
|
} catch {
|
||||||
|
// still away: try the same piece again
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.ok || response.status === 409) {
|
||||||
|
const now = (await response.json()).received;
|
||||||
|
// refused where it already stood: the piece itself is refused, and sending it again changes nothing
|
||||||
|
if (!response.ok && now === offset && ++failures >= attempts)
|
||||||
|
return { ok: false, status: 409, error: "the piece was refused" };
|
||||||
|
if (now !== offset)
|
||||||
|
failures = 0;
|
||||||
|
offset = now;
|
||||||
|
await listener.invokeMethodAsync("Progress", offset, file.size);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { ok: false, status: response.status, error: await response.text() };
|
||||||
|
}
|
||||||
|
return { ok: true, size: file.size };
|
||||||
|
}
|
||||||
@@ -169,6 +169,17 @@ window.revealActive = strip => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
|
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
|
||||||
|
// the reader asked for less motion: what would move by itself (a gifv) waits for a tap
|
||||||
|
window.prefersReducedMotion = () => {
|
||||||
|
try {
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
window.logFromJs(e.message, "prefersReducedMotion")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.isMobileMedia = () => {
|
window.isMobileMedia = () => {
|
||||||
try {
|
try {
|
||||||
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
|
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
|
||||||
|
|||||||
Reference in new issue
Block a user