Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da6a398d7 | ||
|
|
356ea7b430 | ||
|
|
48df316256 | ||
|
|
dc2190e271 | ||
|
|
70cb389537 | ||
|
|
9d662f702b | ||
|
|
e94e32d198 | ||
|
|
e88f4afc45 | ||
|
|
eade4e399a | ||
|
|
a3461c3ece | ||
|
|
ed3bf57e5b | ||
|
|
b66d7cf545 | ||
|
|
47ff60f57b | ||
|
|
18331531b8 | ||
|
|
f1ac5eaad2 | ||
|
|
5522fe4fbb | ||
|
|
7b1bfa71c0 | ||
|
|
da8f4ffcab | ||
|
|
89cef25def | ||
|
|
765220059d | ||
|
|
dd38002682 | ||
|
|
1c9773bab0 | ||
|
|
931b7fcff1 | ||
|
|
8f15d0d5c1 | ||
|
|
071bcce4b0 | ||
|
|
b3bb2a28c3 | ||
|
|
d2ab5e7b40 | ||
|
|
0b237b1951 | ||
|
|
ffd4acd3d0 | ||
|
|
63f2517f5b | ||
|
|
f1cb90ac03 | ||
|
|
6731077191 | ||
|
|
87f3f2402a | ||
|
|
38336f9869 | ||
|
|
63d83810d7 | ||
|
|
88e55e671c | ||
|
|
b7ff917d8c | ||
|
|
24748168c0 |
No files matched your search
@@ -69,6 +69,35 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam
|
||||
town's report (`out/client.jsonl`).
|
||||
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
||||
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
||||
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
|
||||
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
|
||||
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
|
||||
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
|
||||
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
|
||||
- sideways scroll, unless the strip is marked `data-scroll-x`;
|
||||
- boxes past the screen's sides;
|
||||
- open menus, sheets and dialogs that are cut or outside the screen;
|
||||
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
|
||||
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
|
||||
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
|
||||
- control text under 12px.
|
||||
|
||||
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
|
||||
`client.mobile.touch.*`, with the phone as the ref.
|
||||
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
|
||||
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
|
||||
- `DesktopUnchangedTests` holds desktop's control sizes.
|
||||
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
|
||||
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
|
||||
turned to the subsolar point.
|
||||
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
|
||||
to the banner.
|
||||
- An idle globe draws no frame.
|
||||
|
||||
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`
|
||||
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`.
|
||||
@@ -125,9 +154,63 @@ 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
|
||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||
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
|
||||
every collapse disappears.
|
||||
|
||||
## Phones
|
||||
|
||||
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
|
||||
- **Width and pointer are separate.**
|
||||
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||
`pointer-coarse:` at a call site.
|
||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
|
||||
- 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
|
||||
daisyUI's own size variables:
|
||||
- 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
|
||||
the padding box);
|
||||
- checkboxes and radios are 24px, toggles and range thumbs 28;
|
||||
- field text is 16px, so iOS doesn't zoom into a focused field.
|
||||
|
||||
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
||||
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
||||
its arrow with `::after` (NerdTip takes minimum sizes).
|
||||
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
|
||||
phone sweep fails anything less.
|
||||
- **Overlays on a phone rise from the bottom.**
|
||||
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
||||
close button (`ShowClose`, named by `CloseText`).
|
||||
- `DDropdown SheetOnNarrow` turns its content into a bottom sheet.
|
||||
- `Components/PhoneSheet.razor` shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a
|
||||
sheet on a phone.
|
||||
- All are capped by `dvh`, and the app itself is `h-dvh`.
|
||||
- **The Back button closes the newest open overlay** instead of leaving the page.
|
||||
- Overlays register through `IDOverlayHost` (`Components/Daisy`, cascaded from `MainLayout`):
|
||||
- `DModal` and `DDropdown` through the cascade;
|
||||
- the nav menu, `PhoneSheet` and the full-screen globe through `Services/OverlayService.cs`.
|
||||
- `main.js` marks a real Back press (a capture-phase `popstate`), since Blazor can't tell it from the app's own
|
||||
navigation, which closes every overlay and goes on.
|
||||
- No history entries are pushed.
|
||||
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
|
||||
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and
|
||||
signing out.
|
||||
- **On a touch screen:**
|
||||
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
|
||||
pinned to the globe with a mouse only;
|
||||
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
|
||||
- pictures open in `Components/MediaViewer.razor`.
|
||||
- **The feed on a phone:**
|
||||
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
|
||||
stats' Details are in More, and the author's picture is in the header;
|
||||
- a new post starts from a floating button;
|
||||
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
|
||||
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
|
||||
|
||||
## daisyUI Blazor components (`Components/Daisy/`)
|
||||
|
||||
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
||||
@@ -183,6 +266,7 @@ supplies:
|
||||
messages.
|
||||
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
||||
`AdditionalError` and `AdditionalErrors`.
|
||||
- `ConfirmService` asks before what can't be undone (see **Phones**).
|
||||
|
||||
**HTTP (`Services/IHttpService.cs`).**
|
||||
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
||||
@@ -196,6 +280,8 @@ supplies:
|
||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||
PrivaPub redirects it).
|
||||
- `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
|
||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||
@@ -215,7 +301,7 @@ supplies:
|
||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||||
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||||
`/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
||||
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
|
||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
||||
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
|
||||
@@ -231,8 +317,10 @@ supplies:
|
||||
- **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
|
||||
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
|
||||
link, participants and status.
|
||||
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
||||
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
|
||||
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
|
||||
@@ -262,8 +350,21 @@ supplies:
|
||||
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.
|
||||
- **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
|
||||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
||||
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
|
||||
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
|
||||
(`/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
|
||||
@@ -277,14 +378,27 @@ supplies:
|
||||
- **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`;
|
||||
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,
|
||||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
|
||||
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
|
||||
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||||
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
|
||||
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`):
|
||||
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
|
||||
@@ -327,8 +441,28 @@ supplies:
|
||||
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season
|
||||
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||||
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
|
||||
top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
|
||||
the points.
|
||||
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
|
||||
a table). The globe has no tooltip. What it draws:
|
||||
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
|
||||
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
|
||||
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
|
||||
- the tropics, the polar circles and the CDN ring;
|
||||
- the view's centre and spread, and rings around the posts in view;
|
||||
- a small orrery in the top left corner, re-anchored to the camera before each render.
|
||||
|
||||
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
|
||||
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
|
||||
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
|
||||
already made, so an idle globe still draws nothing.
|
||||
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
|
||||
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
|
||||
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
|
||||
classes, only the ring and the legend remain.
|
||||
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
|
||||
gets everything.
|
||||
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||
@@ -339,8 +473,7 @@ supplies:
|
||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||
request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||||
the place's elevation and day length, the next equinox or solstice).
|
||||
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
|
||||
|
||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||
|
||||
@@ -49,6 +49,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ViewPost))]
|
||||
[JsonSerializable(typeof(List<ViewPost>))]
|
||||
[JsonSerializable(typeof(InsertPostForm))]
|
||||
[JsonSerializable(typeof(DeletePostForm))]
|
||||
[JsonSerializable(typeof(ViewReport))]
|
||||
[JsonSerializable(typeof(List<ViewReport>))]
|
||||
[JsonSerializable(typeof(DomainBlockForm))]
|
||||
@@ -57,6 +58,16 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ViewStatisticsOverview))]
|
||||
[JsonSerializable(typeof(ViewHostsPage))]
|
||||
[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
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview>
|
||||
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
|
||||
<DStat>
|
||||
<Heading>@Localizer["Deliveries that arrived"]</Heading>
|
||||
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
|
||||
|
||||
+228
-47
@@ -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
|
||||
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.
|
||||
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). *@
|
||||
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
|
||||
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">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@@ -17,18 +19,19 @@
|
||||
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
||||
</DInput>
|
||||
}
|
||||
<div class="relative">
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
<div class="flex flex-col gap-1">
|
||||
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||
placeholder="@Placeholder" rows="3" data-compose />
|
||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
@* under the words, not over their last line *@
|
||||
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@* alt="": the handle is next to it. *@
|
||||
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="truncate">@("@" + account.Acct)</span>
|
||||
@@ -36,7 +39,7 @@
|
||||
}
|
||||
@foreach (var tag in suggestedTags)
|
||||
{
|
||||
<DButton Size="DSize.Xs" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -82,29 +85,61 @@
|
||||
|
||||
@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)
|
||||
{
|
||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
||||
<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.Preview is { Length: > 0 } preview)
|
||||
{
|
||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||
}
|
||||
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">
|
||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
||||
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||
<p class="inline-flex items-center gap-2 text-xs">
|
||||
@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>
|
||||
<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>
|
||||
</DButton>
|
||||
</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)
|
||||
@@ -131,16 +166,18 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||
{
|
||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
|
||||
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
|
||||
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
<Trigger>
|
||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
|
||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var messageType in Enum.GetValues<MessageType>())
|
||||
{
|
||||
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
||||
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
|
||||
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||
<span>@Localizer[messageType.ToString()]</span>
|
||||
</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
@@ -162,12 +199,12 @@
|
||||
<i class="ion-md-warning text-base"></i>
|
||||
</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. *@
|
||||
<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"
|
||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
||||
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||
<i class="ion-md-attach text-base"></i>
|
||||
@if (form.Media.Count != 0)
|
||||
{
|
||||
@@ -233,6 +270,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] MediaService MediaService { get; set; }
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
@@ -293,6 +331,7 @@
|
||||
if (!typed.Success)
|
||||
return;
|
||||
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||
@@ -313,21 +352,40 @@
|
||||
|
||||
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.Audio => 41_943_040,
|
||||
_ => 99_614_720
|
||||
MediaType.Images => type.StartsWith("image/"),
|
||||
MediaType.Video => type.StartsWith("video/"),
|
||||
_ => 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",
|
||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
||||
_ => default
|
||||
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||
[".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
|
||||
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||
@@ -340,18 +398,48 @@
|
||||
_ => MessageType.Public
|
||||
};
|
||||
|
||||
DTextarea<string> textarea;
|
||||
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||
bool focusPending = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
return;
|
||||
focusPending = false;
|
||||
await textarea.Element.FocusAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
form.GroupId = GroupId;
|
||||
form.MessageType = DefaultVisibility;
|
||||
form.Language = DefaultLanguage;
|
||||
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||
form.QuotedStatusId = Quoting?.Id;
|
||||
limits = await MediaService.Limits();
|
||||
if (Editing is not null)
|
||||
{
|
||||
var source = await StatusService.Source(Editing.Id);
|
||||
form.Content = source?.Text;
|
||||
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||
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;
|
||||
if (InReplyTo is null)
|
||||
@@ -417,40 +505,121 @@
|
||||
|
||||
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,
|
||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await ReadFile(file) is { } media)
|
||||
form.Media.Add(media);
|
||||
else
|
||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||
// 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);
|
||||
altTextConfirmed = false;
|
||||
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>
|
||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
||||
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||
async Task<string> Thumbnail(IBrowserFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||
if (file.ContentType.StartsWith("image"))
|
||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
||||
return media;
|
||||
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer);
|
||||
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
error = default;
|
||||
@@ -464,6 +633,18 @@
|
||||
error = Localizer["A poll needs two choices at least."];
|
||||
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)
|
||||
{
|
||||
isPublishing = true;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
|
||||
dialog in the middle from md. Closing it in any way answers no. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
|
||||
<Heading>@Confirm.Heading</Heading>
|
||||
<ChildContent>
|
||||
@if (Confirm.Text is { Length: > 0 } text)
|
||||
{
|
||||
<p class="text-sm">@text</p>
|
||||
}
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
|
||||
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
|
||||
@Confirm.ConfirmText
|
||||
</DButton>
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Confirm.Changed += Refresh;
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Refresh() => InvokeAsync(StateHasChanged);
|
||||
|
||||
void Closed(bool open)
|
||||
{
|
||||
if (!open)
|
||||
Confirm.Answer(false);
|
||||
}
|
||||
|
||||
public void Dispose() => Confirm.Changed -= Refresh;
|
||||
}
|
||||
+175
-65
@@ -7,7 +7,7 @@
|
||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
||||
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
|
||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
|
||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||
@if (!IsFilteredOut)
|
||||
{
|
||||
@@ -17,11 +17,11 @@
|
||||
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
|
||||
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
||||
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))"
|
||||
data-uri="@(Compact ? null : Post.Status?.Uri)">
|
||||
data-uri="@(Compact ? null : Post.Status?.Uri)" data-thread="@(IncludeExpand && !Compact && Status is not null && !Post.Located ? "" : null)">
|
||||
|
||||
@if (Author is { } author)
|
||||
{
|
||||
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
<div class="hidden md:flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
style="@HeaderBackground(author)">
|
||||
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
|
||||
@* alt="": the author's name is next to it. *@
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
|
||||
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 max-md:pl-3 md:py-4 md:pr-4 min-w-0">
|
||||
|
||||
@if (Post.Notification is { } notification)
|
||||
{
|
||||
@@ -55,7 +55,7 @@
|
||||
{
|
||||
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
|
||||
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
|
||||
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
|
||||
<a class="font-bold truncate pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(booster.Id)}")" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
|
||||
<span>@Localizer["boosted"]</span>
|
||||
</p>
|
||||
}
|
||||
@@ -79,11 +79,19 @@
|
||||
|
||||
@if (Status is { } status)
|
||||
{
|
||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
||||
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-sm">
|
||||
@if (Author is { } phoneAuthor)
|
||||
{
|
||||
<a class="md:hidden flex-none self-center grid place-items-center h-11 w-11" href="@($"account/{Uri.EscapeDataString(phoneAuthor.Id)}")"
|
||||
title="@phoneAuthor.Acct">
|
||||
@* alt="": the author's name is next to it. *@
|
||||
<img alt="" loading="lazy" class="h-10 w-10 object-cover rounded-full neo-raised neo-2xs bg-base-100" src="@(phoneAuthor.Avatar ?? "imgs/icon-192.png")" />
|
||||
</a>
|
||||
}
|
||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1 max-md:self-center">
|
||||
<span class="inline-flex max-md:flex-col md:gap-2 min-w-0 max-w-full">
|
||||
<b class="shrink truncate" title="@status.Account?.Name">@CustomEmojis.Text(status.Account?.Name, status.Account?.Emojis ?? [])</b>
|
||||
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||
<span class="flex-1 min-w-6 opacity-50 truncate md:self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||
</span>
|
||||
</NerdTip>
|
||||
<span class="flex-none inline-flex gap-2 items-center">
|
||||
@@ -91,7 +99,7 @@
|
||||
{
|
||||
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
|
||||
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
|
||||
<i class="ion-md-cloud" aria-hidden="true"></i>
|
||||
<span class="truncate">@fronted.Label</span>
|
||||
</span>
|
||||
@@ -100,7 +108,7 @@
|
||||
else if (Post.Location is { } location)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
|
||||
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
|
||||
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
|
||||
</span>
|
||||
@@ -112,7 +120,7 @@
|
||||
</NerdTip>
|
||||
@if (status.EditedAt is not null)
|
||||
{
|
||||
<button type="button" class="link link-hover opacity-60" data-history title="@Localizer["Edit history"]"
|
||||
<button type="button" class="link link-hover opacity-60 touch-target" data-history title="@Localizer["Edit history"]"
|
||||
@onclick="() => OpenHistory(status)">@Localizer["edited"]</button>
|
||||
}
|
||||
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
|
||||
@@ -123,7 +131,7 @@
|
||||
{
|
||||
<p class="inline-flex items-center gap-2 min-w-0 text-xs opacity-80" data-wall="@wall.Acct">
|
||||
<i class="ion-md-clipboard" aria-hidden="true"></i>
|
||||
<a class="truncate" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
|
||||
<a class="truncate pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
|
||||
@Localizer["On the wall of {0}", wall.Name]
|
||||
</a>
|
||||
</p>
|
||||
@@ -182,7 +190,7 @@
|
||||
@* the reactions stand outside the content warning: they are not what it hides *@
|
||||
@if (status.EmojiReactions.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-1" data-reactions>
|
||||
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2" data-reactions>
|
||||
@foreach (var reaction in status.EmojiReactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
||||
@@ -203,10 +211,20 @@
|
||||
|
||||
@if (Post.Located)
|
||||
{
|
||||
<p class="inline-flex gap-2 items-center text-xs opacity-70">
|
||||
<i class="ion-md-locate"></i>
|
||||
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<p class="inline-flex gap-2 items-center opacity-70">
|
||||
<i class="ion-md-locate"></i>
|
||||
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
|
||||
</p>
|
||||
@* its author takes it back here: it has no other action *@
|
||||
@if (IsOwn && !Compact)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="ml-auto" Disabled="IsDefaultDisabled || isActing" OnClick="Delete" data-located-delete>
|
||||
<i aria-hidden="true" class="ion-md-trash text-error"></i>
|
||||
<span>@Localizer["Delete"]</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (status.Privapub?.Approval is "pending" or "rejected")
|
||||
@@ -220,59 +238,65 @@
|
||||
|
||||
@if (!Post.Located && !Compact)
|
||||
{
|
||||
<footer class="flex gap-2 mt-1 justify-between">
|
||||
<div class="flex gap-2">
|
||||
@* on a phone the actions are five equal cells (Reply, Boost, Favourite, React, More); Bookmark and the thread are in
|
||||
More, and the downvotes, the counts and Expand show from md *@
|
||||
<footer class="grid grid-cols-5 gap-1 mt-1 md:flex md:gap-2 md:justify-between">
|
||||
<div class="contents md:flex md:gap-2">
|
||||
@if (status.Privapub?.Locked == true)
|
||||
{
|
||||
@* its community's moderators locked the thread: no reply is taken *@
|
||||
<span class="btn btn-sm btn-disabled" title="@Localizer["Locked by its community's moderators"]" data-locked>
|
||||
<span class="btn btn-sm btn-disabled max-md:w-full" title="@Localizer["Locked by its community's moderators"]" data-locked>
|
||||
<i aria-hidden="true" class="ion-md-lock text-lg"></i>
|
||||
@if (status.RepliesCount > 0)
|
||||
{
|
||||
<span>@status.RepliesCount</span>
|
||||
<span class="md:hidden">@status.RepliesCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.RepliesCount</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
|
||||
@if (status.RepliesCount > 0)
|
||||
{
|
||||
<span>@status.RepliesCount</span>
|
||||
<span class="md:hidden">@status.RepliesCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.RepliesCount</span>
|
||||
}
|
||||
</DButton>
|
||||
}
|
||||
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
|
||||
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
|
||||
@if (status.ReblogsCount > 0)
|
||||
{
|
||||
<span>@status.ReblogsCount</span>
|
||||
<span class="md:hidden">@status.ReblogsCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.ReblogsCount</span>
|
||||
}
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
|
||||
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
|
||||
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
|
||||
@if (status.FavouritesCount > 0)
|
||||
{
|
||||
<span>@status.FavouritesCount</span>
|
||||
<span class="md:hidden">@status.FavouritesCount.Compact()</span>
|
||||
<span class="hidden md:inline">@status.FavouritesCount</span>
|
||||
}
|
||||
</DButton>
|
||||
@* a threadiverse post's downvotes: its upvotes are its favourites *@
|
||||
@if (status.Privapub?.Votes is { Down: > 0 } votes)
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
||||
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
|
||||
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
|
||||
</span>
|
||||
}
|
||||
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
</DButton>
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
|
||||
title="@Localizer["React"]">
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
@@ -281,20 +305,40 @@
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
</NerdTip>
|
||||
<span class="hidden md:contents">
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
</NerdTip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="contents md:flex md:gap-2">
|
||||
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
|
||||
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
|
||||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||||
SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
|
||||
<Trigger>
|
||||
<i aria-hidden="true" class="ion-md-more text-lg"></i>
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@if (IncludeExpand)
|
||||
{
|
||||
<NavLink class="btn btn-sm justify-start whitespace-nowrap md:hidden" href="@($"expand/{Uri.EscapeDataString(status.Id)}")"
|
||||
@onclick="OpenConversation" data-thread-link>
|
||||
<i aria-hidden="true" class="ion-md-expand text-base"></i>
|
||||
<span>@Localizer["Thread"]</span>
|
||||
</NavLink>
|
||||
}
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" data-bookmark-phone>
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
<span>@Localizer["Bookmark"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
|
||||
<i aria-hidden="true" class="ion-md-stats text-base"></i>
|
||||
<span>@Localizer["Details"]</span>
|
||||
</DButton>
|
||||
@if (status.Url is { Length: > 0 } url)
|
||||
{
|
||||
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
|
||||
@@ -368,7 +412,7 @@
|
||||
</DDropdown>
|
||||
@if (IncludeExpand)
|
||||
{
|
||||
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
|
||||
<NavLink class="btn btn-sm hidden md:inline-flex" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
|
||||
@onclick="OpenConversation">
|
||||
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
||||
</NavLink>
|
||||
@@ -376,17 +420,38 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@if (isReplying)
|
||||
{
|
||||
@* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
|
||||
<PhoneSheet IsOpen="isReplying" OnClose="() => isReplying = false" Heading="@Localizer["Reply to {0}", "@" + status.Account?.Acct]">
|
||||
<Composer InReplyTo="status" OnPublished="Replied" />
|
||||
}
|
||||
@if (isQuoting)
|
||||
{
|
||||
</PhoneSheet>
|
||||
<PhoneSheet IsOpen="isQuoting" OnClose="() => isQuoting = false" Heading="@Localizer["Quote"]">
|
||||
<Composer Quoting="status" OnPublished="Quoted" />
|
||||
}
|
||||
@if (isEditing)
|
||||
{
|
||||
</PhoneSheet>
|
||||
<PhoneSheet IsOpen="isEditing" OnClose="() => isEditing = false" Heading="@Localizer["Edit"]">
|
||||
<Composer Editing="status" OnPublished="Edited" />
|
||||
</PhoneSheet>
|
||||
@* a phone's way to the nerd stats the card's corners hold on hover, all in one sheet *@
|
||||
@if (isDetailsOpen)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isDetailsOpen = open" CloseText="@Localizer["Close"]"
|
||||
data-details-sheet>
|
||||
<Heading>@Localizer["Details"]</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
<NerdRows Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" />
|
||||
<NerdRows Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" />
|
||||
@if (Post.Location is { Source: LocationSource.Cdn } detailsCdn)
|
||||
{
|
||||
<NerdRows Heading="@Localizer["The server is behind {0}, so its place is unknown.", detailsCdn.Label]" Rows="CdnRows()" Pending="isReadingServer" />
|
||||
}
|
||||
else if (Post.Location is not null)
|
||||
{
|
||||
<NerdRows Heading="@Localizer["Place"]" Rows="LocationRows()" />
|
||||
}
|
||||
<NerdRows Heading="@Localizer["Counts"]" Rows="CountRows(status)" />
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
@if (isReporting && status.Account is { } reported)
|
||||
{
|
||||
@@ -398,7 +463,7 @@
|
||||
}
|
||||
@if (isHistoryOpen)
|
||||
{
|
||||
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||
<Heading>@Localizer["Edit history"]</Heading>
|
||||
<ChildContent>
|
||||
<ol class="flex flex-col gap-4 mt-3">
|
||||
@@ -449,6 +514,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] TimelineService TimelineService { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] ProvenanceService ProvenanceService { get; set; }
|
||||
@@ -468,6 +534,8 @@
|
||||
[Parameter] public FilterContext? FilterIn { get; set; }
|
||||
|
||||
bool isReplying;
|
||||
bool isDetailsOpen;
|
||||
MediaAttachment viewingPicture;
|
||||
bool isQuoting;
|
||||
bool isEditing;
|
||||
bool isActing;
|
||||
@@ -552,12 +620,13 @@
|
||||
<div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
|
||||
@foreach (var media in status.MediaAttachments)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
|
||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
||||
@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"
|
||||
@onclick="() => revealedMedia.Add(media.Id)">
|
||||
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
|
||||
@* its blurhash shows behind it: the colours, nothing of what it shows *@
|
||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
|
||||
@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>
|
||||
}
|
||||
else
|
||||
@@ -569,6 +638,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (viewingPicture is { } picture)
|
||||
{
|
||||
<MediaViewer Pictures="Pictures(status)" Start="Pictures(status).IndexOf(picture)" OnClose="() => viewingPicture = null" />
|
||||
}
|
||||
|
||||
@if (status.Card is { } card && status.MediaAttachments.Count == 0)
|
||||
{
|
||||
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
|
||||
@@ -586,16 +660,16 @@
|
||||
|
||||
@if (status.Poll is { } poll)
|
||||
{
|
||||
<div class="flex flex-col gap-1 text-xs" data-poll>
|
||||
<div class="flex flex-col gap-1 pointer-coarse:gap-2 text-xs" data-poll>
|
||||
@if (CanVote(poll))
|
||||
{
|
||||
@for (var i = 0; i < poll.Options.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 cursor-pointer">
|
||||
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 pointer-coarse:min-h-11 cursor-pointer">
|
||||
<input type="@(poll.Multiple ? "checkbox" : "radio")" class="@(poll.Multiple ? "checkbox checkbox-xs" : "radio radio-xs")"
|
||||
name="@($"poll-{poll.Id}")" checked="@pollChoices.Contains(index)" @onchange="() => ToggleChoice(poll, index)" />
|
||||
<span>@poll.Options[index].Title</span>
|
||||
<span class="min-w-0 break-words">@poll.Options[index].Title</span>
|
||||
</label>
|
||||
}
|
||||
<DButton Size="DSize.Xs" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isActing || pollChoices.Count == 0)"
|
||||
@@ -634,7 +708,7 @@
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
|
||||
@foreach (var venue in happening.Places)
|
||||
{
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
|
||||
<span class="inline-flex gap-2 items-center min-w-0 break-all"><i class="ion-md-pin flex-none"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
|
||||
}
|
||||
@if (IsWebLink(happening.OnlineUrl))
|
||||
{
|
||||
@@ -694,7 +768,7 @@
|
||||
|
||||
@if (status.Tags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2">
|
||||
@foreach (var tag in status.Tags)
|
||||
{
|
||||
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
|
||||
@@ -703,21 +777,37 @@
|
||||
}
|
||||
</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" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
||||
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]")"
|
||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
|
||||
</a>,
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
|
||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
|
||||
</button>,
|
||||
// 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"
|
||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
|
||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
|
||||
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" @onerror="() => unavailableMedia.Add(media.Id)"></audio>,
|
||||
_ => @<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>
|
||||
</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)
|
||||
{
|
||||
isActing = true;
|
||||
@@ -885,8 +975,12 @@
|
||||
|
||||
async Task Delete()
|
||||
{
|
||||
// a located post never left PrivaPub: no server is asked
|
||||
if (!await Confirm.Ask(Localizer["Delete this post?"], Post.Located ? null : Localizer["It goes from here, and the servers that have it are asked to delete it too."],
|
||||
Localizer["Delete"], true))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
var result = Post.Located ? await StatusService.DeleteLocated(Status.Id, CascadingState.PersonaId) : await StatusService.Delete(Status.Id);
|
||||
isActing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
@@ -904,6 +998,8 @@
|
||||
|
||||
async Task TakeOffWall()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Take this post off your wall?"], null, Localizer["Take off my wall"], true))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
isActing = false;
|
||||
@@ -919,6 +1015,10 @@
|
||||
|
||||
async Task AccountAction(string action)
|
||||
{
|
||||
var name = Status.Account?.Name ?? Status.Account?.Acct;
|
||||
if (!await Confirm.Ask(action == "block" ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
|
||||
Localizer[action == "block" ? "Block" : "Mute"], action == "block"))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await StatusService.AccountAction(Status.Account.Id, action);
|
||||
isActing = false;
|
||||
@@ -970,6 +1070,16 @@
|
||||
await OnReplied.InvokeAsync(reply);
|
||||
}
|
||||
|
||||
static List<MediaAttachment> Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")];
|
||||
|
||||
void OpenPicture(MediaAttachment media) => viewingPicture = media;
|
||||
|
||||
async Task OpenDetails()
|
||||
{
|
||||
isDetailsOpen = true;
|
||||
await Task.WhenAll(ReadProvenance(), ReadServer());
|
||||
}
|
||||
|
||||
async Task ReadProvenance()
|
||||
{
|
||||
if (Status is null || Post.Located)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
||||
@if (Href is null)
|
||||
{
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
|
||||
@onmousedown:preventDefault="KeepFocus">
|
||||
@ChildContent
|
||||
</button>
|
||||
}
|
||||
@@ -27,6 +28,8 @@ else
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
[Parameter] public string Href { get; set; }
|
||||
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
||||
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
|
||||
[Parameter] public bool KeepFocus { get; set; }
|
||||
|
||||
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
||||
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
@inherits DComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||
too. With Hover the CSS hover rule opens it instead. *@
|
||||
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
|
||||
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
@onclick="Toggle">
|
||||
@Trigger
|
||||
</div>
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
||||
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
|
||||
aria-hidden="true" @onclick="Close"></div>
|
||||
}
|
||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
||||
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
|
||||
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +36,21 @@
|
||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; }
|
||||
[Parameter] public string ContentClass { get; set; }
|
||||
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||
|
||||
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
|
||||
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
|
||||
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
|
||||
|
||||
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||
|
||||
ElementReference trigger;
|
||||
ElementReference content;
|
||||
IDisposable overlay;
|
||||
bool focusContent;
|
||||
bool focusTrigger;
|
||||
|
||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||
@@ -50,17 +70,54 @@
|
||||
_ => null
|
||||
};
|
||||
|
||||
Task Toggle() => SetOpen(!IsOpen);
|
||||
protected override void OnParametersSet() => TrackOverlay();
|
||||
|
||||
Task Close() => SetOpen(false);
|
||||
|
||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen)
|
||||
void TrackOverlay()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||
{
|
||||
await SetOpen(false, true);
|
||||
StateHasChanged();
|
||||
}));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => overlay?.Dispose();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (focusContent)
|
||||
{
|
||||
focusContent = false;
|
||||
await content.FocusAsync();
|
||||
}
|
||||
if (focusTrigger)
|
||||
{
|
||||
focusTrigger = false;
|
||||
await trigger.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
Task Toggle() => SetOpen(!IsOpen, IsOpen);
|
||||
|
||||
Task Close() => SetOpen(false, true);
|
||||
|
||||
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
|
||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen, bool giveFocusBack)
|
||||
{
|
||||
focusContent = isOpen && !IsOpen;
|
||||
focusTrigger = !isOpen && IsOpen && giveFocusBack;
|
||||
IsOpen = isOpen;
|
||||
TrackOverlay();
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
@inherits DComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
||||
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
|
||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
|
||||
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
|
||||
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
||||
class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
||||
@if (Heading is not null)
|
||||
@if (Heading is not null || ShowClose)
|
||||
{
|
||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
||||
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||
@if (Heading is not null)
|
||||
{
|
||||
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="flex-1"></span>
|
||||
}
|
||||
@if (ShowClose)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
|
||||
@onclick="Close" data-modal-close>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@ChildContent
|
||||
@if (Actions is not null)
|
||||
@@ -35,11 +53,17 @@
|
||||
[Parameter] public DModalPlacement Placement { get; set; }
|
||||
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
||||
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
||||
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
|
||||
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
|
||||
[Parameter] public string CloseText { get; set; } = "close";
|
||||
/// <summary>A visible close button beside the heading.</summary>
|
||||
[Parameter] public bool ShowClose { get; set; } = true;
|
||||
[Parameter] public string BoxClass { get; set; }
|
||||
|
||||
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
|
||||
[CascadingParameter] IDOverlayHost Overlays { get; set; }
|
||||
|
||||
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
||||
IDisposable overlay;
|
||||
ElementReference box;
|
||||
bool wasOpen;
|
||||
bool focusPending;
|
||||
@@ -52,6 +76,7 @@
|
||||
DModalPlacement.Bottom => "modal-bottom",
|
||||
DModalPlacement.Start => "modal-start",
|
||||
DModalPlacement.End => "modal-end",
|
||||
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -60,8 +85,26 @@
|
||||
if (IsOpen && !wasOpen)
|
||||
focusPending = true;
|
||||
wasOpen = IsOpen;
|
||||
TrackOverlay();
|
||||
}
|
||||
|
||||
void TrackOverlay()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays?.Open(() => InvokeAsync(async () =>
|
||||
{
|
||||
await SetOpen(false);
|
||||
StateHasChanged();
|
||||
}));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => overlay?.Dispose();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
@@ -78,6 +121,7 @@
|
||||
{
|
||||
IsOpen = isOpen;
|
||||
wasOpen = isOpen;
|
||||
TrackOverlay();
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,7 @@ public enum DModalPlacement
|
||||
Top,
|
||||
Bottom,
|
||||
Start,
|
||||
End
|
||||
End,
|
||||
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
|
||||
ResponsiveBottom
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
||||
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96). *@
|
||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96); it is marked as a strip that scrolls sideways on
|
||||
purpose (data-scroll-x), which the phone tests allow. *@
|
||||
@if (Scroll)
|
||||
{
|
||||
<div class="@Cx("overflow-x-auto", WrapperClass)">
|
||||
<div class="@Cx("overflow-x-auto", WrapperClass)" data-scroll-x>
|
||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
||||
value is the content; rows, placeholder and the like pass through. *@
|
||||
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
||||
|
||||
@code {
|
||||
/// <summary>The textarea itself, for focusing it.</summary>
|
||||
public ElementReference Element { get; private set; }
|
||||
|
||||
[Parameter] public DColor Color { get; set; }
|
||||
[Parameter] public DSize Size { get; set; }
|
||||
[Parameter] public bool Ghost { get; set; }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace decePubClient.Components.Daisy;
|
||||
|
||||
/// <summary>
|
||||
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
|
||||
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
|
||||
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
|
||||
/// </summary>
|
||||
public interface IDOverlayHost
|
||||
{
|
||||
IDisposable Open(Func<Task> close);
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
|
||||
[Parameter] public string AccessibleName { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
var index = i;
|
||||
var tab = tabs[i];
|
||||
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
|
||||
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
|
||||
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
|
||||
@onclick="() => Select(index)">
|
||||
@tab.Header
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
|
||||
tabs wait; from md only the selected tab shows its name until xl *@
|
||||
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
|
||||
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||
aria-label="@Localizer["Feeds"]">
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<DTab Class="gap-1 whitespace-nowrap">
|
||||
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
|
||||
<Header>
|
||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
|
||||
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
|
||||
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
|
||||
{
|
||||
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
||||
@@ -30,10 +33,15 @@
|
||||
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
||||
</NerdTip>
|
||||
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
||||
<i class="ion-md-planet text-base"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||
@* a phone held sideways has no room for the banner: the globe opens full screen *@
|
||||
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
|
||||
data-globe-full>
|
||||
<i class="ion-md-planet text-base"></i>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
||||
<i class="ion-md-create text-base"></i>
|
||||
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
||||
</DButton>
|
||||
@@ -107,6 +115,8 @@
|
||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||
[Parameter] public bool IsComposing { get; set; }
|
||||
[Parameter] public EventCallback OnCompose { get; set; }
|
||||
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
|
||||
[Parameter] public EventCallback OnGlobeFull { get; set; }
|
||||
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
||||
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
||||
|
||||
@@ -120,6 +130,17 @@
|
||||
|
||||
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
||||
|
||||
ElementReference strip;
|
||||
int revealed = -1;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (ActiveIndex == revealed)
|
||||
return;
|
||||
revealed = ActiveIndex;
|
||||
CascadingState.Status.RevealActive(strip);
|
||||
}
|
||||
|
||||
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
||||
|
||||
[Inject] DiscoveryService DiscoveryService { get; set; }
|
||||
|
||||
+128
-122
@@ -3,55 +3,58 @@
|
||||
|
||||
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
|
||||
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
|
||||
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
|
||||
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
|
||||
<div class="relative w-full h-full">
|
||||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||||
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
|
||||
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
|
||||
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
|
||||
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
|
||||
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
|
||||
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
|
||||
brings the feed back. *@
|
||||
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
|
||||
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
|
||||
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
|
||||
<dl id="@($"{uid}-sun")" class="sr-only">
|
||||
@if (sun is not null)
|
||||
{
|
||||
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt>@row.Label</dt>
|
||||
<dd>@row.Value</dd>
|
||||
}
|
||||
}
|
||||
</dl>
|
||||
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
|
||||
|
||||
@if (stats?.Sun is { } sun)
|
||||
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||
@if (sun is not null)
|
||||
{
|
||||
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
|
||||
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
|
||||
<span class="flex items-center gap-1 font-semibold">
|
||||
<i class="@SunIcon(sun)"></i>
|
||||
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||
</span>
|
||||
@if (sun.Phase is { } phase)
|
||||
{
|
||||
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
|
||||
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
|
||||
}
|
||||
</div>
|
||||
</NerdTip>
|
||||
</div>
|
||||
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
|
||||
data-globe-moment>
|
||||
<i class="@SunIcon(sun)" aria-hidden="true"></i>
|
||||
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||||
</p>
|
||||
}
|
||||
else if (error is { Length: > 0 })
|
||||
{
|
||||
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
|
||||
}
|
||||
|
||||
@if (Expanded)
|
||||
{
|
||||
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
|
||||
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
|
||||
<i class="ion-md-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
|
||||
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
|
||||
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
||||
<span class="badge badge-ghost badge-xs gap-1">
|
||||
<i class="ion-md-planet"></i>
|
||||
@if (stats is { Error: null } live)
|
||||
{
|
||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
|
||||
}
|
||||
else if (stats?.Error is { Length: > 0 })
|
||||
{
|
||||
<span>@Localizer["The globe could not start."]</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Localizer["loading"]…</span>
|
||||
}
|
||||
</span>
|
||||
</NerdTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -61,23 +64,85 @@
|
||||
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
||||
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
||||
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
||||
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
|
||||
[Parameter] public bool Expanded { get; set; }
|
||||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
|
||||
ElementReference closeButton;
|
||||
ElementReference expandButton;
|
||||
IDisposable overlay;
|
||||
bool focusClose;
|
||||
bool focusExpand;
|
||||
|
||||
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
|
||||
|
||||
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
|
||||
static readonly string[] InstrumentKeys =
|
||||
[
|
||||
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
|
||||
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
|
||||
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
|
||||
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
|
||||
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
|
||||
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
|
||||
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
|
||||
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
|
||||
];
|
||||
|
||||
async Task Open()
|
||||
{
|
||||
focusClose = true;
|
||||
await ExpandedChanged.InvokeAsync(true);
|
||||
}
|
||||
|
||||
async Task Close()
|
||||
{
|
||||
focusExpand = true;
|
||||
await ExpandedChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
|
||||
|
||||
ElementReference element;
|
||||
IJSObjectReference module;
|
||||
DotNetObjectReference<GlobePanel> reference;
|
||||
PeriodicTimer statsTimer;
|
||||
readonly CancellationTokenSource cancellation = new();
|
||||
int? sentFingerprint;
|
||||
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
|
||||
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
|
||||
bool postsHandedOver = true;
|
||||
GlobeStats stats;
|
||||
SunStats sun;
|
||||
string error;
|
||||
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
protected override void OnParametersSet() => postsHandedOver = true;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
postsHandedOver = true;
|
||||
// full screen, the Back button closes it
|
||||
if (Expanded && overlay is null)
|
||||
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||
else if (!Expanded && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (focusClose && Expanded)
|
||||
{
|
||||
focusClose = false;
|
||||
await closeButton.FocusAsync();
|
||||
}
|
||||
if (focusExpand && !Expanded)
|
||||
{
|
||||
focusExpand = false;
|
||||
await expandButton.FocusAsync(preventScroll: true);
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
try
|
||||
{
|
||||
@@ -85,10 +150,11 @@
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||||
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
||||
_ = ReadStats(cancellation.Token);
|
||||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
|
||||
{
|
||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
|
||||
});
|
||||
}
|
||||
if (postsHandedOver)
|
||||
{
|
||||
@@ -148,7 +214,8 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||||
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
|
||||
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
|
||||
[JSInvokable]
|
||||
public async Task SunChanged()
|
||||
{
|
||||
@@ -157,7 +224,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||||
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -168,42 +235,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task ReadStats(CancellationToken cancellationToken)
|
||||
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
|
||||
[JSInvokable]
|
||||
public void GlobeFailed(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> StatsRows()
|
||||
{
|
||||
if (stats is null)
|
||||
yield break;
|
||||
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
|
||||
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
|
||||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
||||
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
|
||||
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
|
||||
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
|
||||
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
|
||||
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
|
||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
|
||||
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
|
||||
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
|
||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||||
error = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||||
@@ -272,9 +309,9 @@
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
overlay?.Dispose();
|
||||
await cancellation.CancelAsync();
|
||||
cancellation.Dispose();
|
||||
statsTimer?.Dispose();
|
||||
reference?.Dispose();
|
||||
try
|
||||
{
|
||||
@@ -295,37 +332,6 @@
|
||||
|
||||
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||||
|
||||
sealed class GlobeStats
|
||||
{
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public double? Altitude { get; set; }
|
||||
public int Visible { get; set; }
|
||||
public int VisibleLocated { get; set; }
|
||||
public double? CentroidLatitude { get; set; }
|
||||
public double? CentroidLongitude { get; set; }
|
||||
public double SpreadKm { get; set; }
|
||||
public int Points { get; set; }
|
||||
public int Posts { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public int CdnGroups { get; set; }
|
||||
public int Fps { get; set; }
|
||||
public string Renderer { get; set; }
|
||||
public string Texture { get; set; }
|
||||
public int Anisotropy { get; set; }
|
||||
public bool AutoRotate { get; set; }
|
||||
public bool Paused { get; set; }
|
||||
public bool Idle { get; set; }
|
||||
public string Hovered { get; set; }
|
||||
public int Arcs { get; set; }
|
||||
public int Rings { get; set; }
|
||||
public int Countries { get; set; }
|
||||
public int CountryNames { get; set; }
|
||||
public int Borders { get; set; }
|
||||
public SunStats Sun { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||
sealed class SunStats
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
|
||||
have done so, newest first, each opening its profile. *@
|
||||
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
|
||||
<ChildContent>
|
||||
@if (IsLoading)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
|
||||
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
|
||||
keys), and the original in a new tab for whoever wants the file. *@
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
|
||||
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
|
||||
<Heading>
|
||||
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
|
||||
</Heading>
|
||||
<ChildContent>
|
||||
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
|
||||
<figure class="flex flex-col gap-2 min-h-0">
|
||||
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
|
||||
data-blurhash="@Current.Blurhash" />
|
||||
@if (Current.Description is { Length: > 0 } description)
|
||||
{
|
||||
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
|
||||
}
|
||||
</figure>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@if (Pictures.Count > 1)
|
||||
{
|
||||
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
|
||||
title="@Localizer["Previous picture"]" data-picture-previous>
|
||||
<i class="ion-md-arrow-back" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
}
|
||||
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
|
||||
<i class="ion-md-open" aria-hidden="true"></i>
|
||||
<span>@Localizer["Open original"]</span>
|
||||
</a>
|
||||
@if (Pictures.Count > 1)
|
||||
{
|
||||
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
|
||||
title="@Localizer["Next picture"]" data-picture-next>
|
||||
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@code {
|
||||
/// <summary>The post's pictures, in its order.</summary>
|
||||
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
|
||||
/// <summary>The one opened.</summary>
|
||||
[Parameter] public int Start { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
int index;
|
||||
|
||||
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Previous() => index = Math.Max(0, index - 1);
|
||||
|
||||
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
|
||||
|
||||
void OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "ArrowLeft")
|
||||
Previous();
|
||||
else if (e.Key == "ArrowRight")
|
||||
Next();
|
||||
}
|
||||
|
||||
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
|
||||
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
|
||||
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
|
||||
@if (Heading is { Length: > 0 })
|
||||
{
|
||||
<p class="font-bold opacity-80">@Heading</p>
|
||||
}
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||
<dd class="break-all">@row.Value</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (Pending)
|
||||
{
|
||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Heading { get; set; }
|
||||
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
|
||||
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
|
||||
[Parameter] public bool Pending { get; set; }
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
|
||||
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
|
||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||
@ChildContent
|
||||
</DTooltip>
|
||||
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
|
||||
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
|
||||
pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
|
||||
@if (TapOpens)
|
||||
{
|
||||
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
|
||||
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
|
||||
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
|
||||
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
|
||||
@ChildContent
|
||||
</DTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
|
||||
@ChildContent
|
||||
</DTooltip>
|
||||
}
|
||||
@if (isSheetOpen)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
|
||||
<Heading>@Heading</Heading>
|
||||
<ChildContent>
|
||||
<NerdRows Rows="Rows" Pending="Pending" />
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
@@ -16,8 +38,19 @@
|
||||
[Parameter] public DTooltipPlacement Placement { get; set; }
|
||||
[Parameter] public DTooltipAlign Align { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
|
||||
[Parameter] public bool TapOpens { get; set; } = true;
|
||||
|
||||
bool opened;
|
||||
bool isSheetOpen;
|
||||
|
||||
async Task Tap()
|
||||
{
|
||||
if (CascadingState.Status.CanHover())
|
||||
return;
|
||||
isSheetOpen = true;
|
||||
await Open();
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
@@ -29,21 +62,5 @@
|
||||
await OnOpen.InvokeAsync();
|
||||
}
|
||||
|
||||
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
|
||||
@if (Heading is { Length: > 0 })
|
||||
{
|
||||
<p class="font-bold opacity-80">@Heading</p>
|
||||
}
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
||||
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
|
||||
{
|
||||
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
|
||||
<dd class="break-all">@row.Value</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (Pending)
|
||||
{
|
||||
<p class="opacity-60">@Localizer["loading"]…</p>
|
||||
}
|
||||
</div>;
|
||||
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
|
||||
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
|
||||
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
|
||||
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
|
||||
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
|
||||
data-sheet>
|
||||
<div class="md:hidden flex items-center gap-3 mb-3">
|
||||
<b class="flex-1 min-w-0 truncate">@Heading</b>
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
|
||||
@onclick="Close" data-sheet-close>
|
||||
<i class="ion-md-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
/// <summary>The sheet's heading on a phone.</summary>
|
||||
[Parameter] public string Heading { get; set; }
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public string Class { get; set; }
|
||||
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
|
||||
IDisposable overlay;
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
// open, the Back button closes it
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsOpen && overlay is null)
|
||||
overlay = Overlays.Open(() => InvokeAsync(Close));
|
||||
else if (!IsOpen && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
overlay?.Dispose();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
Task Close() => OnClose.InvokeAsync();
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
|
||||
(PublicNoticeService), in MainLayout. *@
|
||||
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
|
||||
data-public-notice>
|
||||
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
|
||||
<ChildContent>
|
||||
<p class="text-sm">
|
||||
@@ -14,7 +15,7 @@
|
||||
</ChildContent>
|
||||
<Actions>
|
||||
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)">@Localizer["Continue"]</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
|
||||
</Actions>
|
||||
</DModal>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
|
||||
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
|
||||
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||
<ChildContent>
|
||||
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
|
||||
@@ -20,7 +20,10 @@
|
||||
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
|
||||
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
|
||||
}
|
||||
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||
<div class="flex justify-end gap-2">
|
||||
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
|
||||
@@ -95,9 +95,15 @@
|
||||
async Task Export(PortableData data)
|
||||
{
|
||||
exporting = data;
|
||||
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
<div class="relative w-full">
|
||||
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
|
||||
<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?.Preview ?? account.Avatar)"
|
||||
alt="@Localizer["Avatar"]" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 pt-6">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span>@Localizer["Avatar"]</span>
|
||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span>@Localizer["Header"]</span>
|
||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>@Localizer["Avatar"]</span>
|
||||
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||
</label>
|
||||
@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>
|
||||
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||
</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>
|
||||
|
||||
<label class="flex flex-col gap-1 max-w-md">
|
||||
@@ -110,11 +126,10 @@ else
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] MediaService MediaService { get; set; }
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
|
||||
// PrivaPub keeps pictures up to 20 MB and resizes them
|
||||
const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||
|
||||
Account account;
|
||||
@@ -122,6 +137,13 @@ else
|
||||
string loadedPersonaId;
|
||||
bool isSaving;
|
||||
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()
|
||||
{
|
||||
@@ -145,6 +167,7 @@ else
|
||||
{
|
||||
// the account as the persona sees itself, with its Source
|
||||
account = await AuthService.CurrentAccount();
|
||||
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||
Reset();
|
||||
}
|
||||
|
||||
@@ -165,32 +188,56 @@ else
|
||||
{
|
||||
pictureErrors = [];
|
||||
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,
|
||||
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
||||
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||
if (isAvatar)
|
||||
form.Avatar = picture;
|
||||
else
|
||||
form.Header = picture;
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
|
||||
await fileStream.CopyToAsync(memoryStream);
|
||||
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
|
||||
if (isAvatar)
|
||||
form.Avatar = picture;
|
||||
else
|
||||
form.Header = picture;
|
||||
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)
|
||||
{
|
||||
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{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()
|
||||
{
|
||||
isSaving = true;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
|
||||
@if (Toast.Messages.Count > 0)
|
||||
{
|
||||
<DToast class="z-[1000]">
|
||||
@* (on a phone above the round New post button) *@
|
||||
<DToast class="z-[1000] max-md:bottom-20">
|
||||
@foreach (var message in Toast.Messages)
|
||||
{
|
||||
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
|
||||
|
||||
@@ -57,6 +57,15 @@ public static class GenericExtensions
|
||||
};
|
||||
}
|
||||
|
||||
// a count as a phone's narrow action buttons show it: 999, 1.2k, 12k, 1.2M
|
||||
public static string Compact(this int count) => count switch
|
||||
{
|
||||
< 1000 => count.ToString(CultureInfo.CurrentCulture),
|
||||
< 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k",
|
||||
< 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k",
|
||||
_ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M"
|
||||
};
|
||||
|
||||
public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
|
||||
{
|
||||
>= 1_048_576 => localizer["{0}MB", size / 1_048_576],
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
public PublicCacheData PublicCacheData { get; private set; } = new();
|
||||
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
|
||||
public bool IsSignedIn { get; private set; }
|
||||
/// <summary>The session has been read once: from then on a null PersonaId means the login has no persona, not "not yet".</summary>
|
||||
public bool IsSessionLoaded { get; private set; }
|
||||
/// <summary>The root's login name, shown to its owner only.</summary>
|
||||
public string RootUserName { get; private set; }
|
||||
/// <summary>The root's personas.</summary>
|
||||
@@ -93,6 +95,7 @@
|
||||
if (!IsSignedIn)
|
||||
{
|
||||
ClearSession();
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
@@ -105,11 +108,16 @@
|
||||
{
|
||||
PersonaId = null;
|
||||
Persona = null;
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// loaded once the persona is known (or couldn't be used): a page rendered while it is switched to would otherwise
|
||||
// see a loaded session without one, and the feed would say it is empty
|
||||
await SwitchPersona(personaId, cancellationToken);
|
||||
IsSessionLoaded = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(SearchResults))]
|
||||
[JsonSerializable(typeof(MediaAttachment))]
|
||||
[JsonSerializable(typeof(InstanceV2))]
|
||||
[JsonSerializable(typeof(Notification))]
|
||||
[JsonSerializable(typeof(List<Notification>))]
|
||||
[JsonSerializable(typeof(Conversation))]
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class APIs
|
||||
public const string GET_Posts = "clientapi/post/list";
|
||||
public const string GET_Nearby = "clientapi/post/nearby";
|
||||
public const string POST_InsertPost = "clientapi/post/insert";
|
||||
public const string POST_DeletePost = "clientapi/post/delete";
|
||||
public const string GET_ModeratorReports = "clientapi/moderator/reports";
|
||||
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
|
||||
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
|
||||
@@ -41,6 +42,23 @@ public static class APIs
|
||||
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
|
||||
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
|
||||
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
|
||||
public 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
|
||||
@@ -72,6 +90,11 @@ public static class APIs
|
||||
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 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_Relationships = "api/v1/accounts/relationships";
|
||||
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||
|
||||
@@ -24,6 +24,8 @@ public class ComposeForm
|
||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||
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))]
|
||||
public string SpoilerText { get; set; }
|
||||
[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; }
|
||||
}
|
||||
|
||||
/// <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 string Status { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
public bool Sensitive { 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>
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace decePubClient.Models
|
||||
public bool ThemeIsDarkGray { 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;
|
||||
// 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 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);
|
||||
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
||||
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||
return picture;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -1,17 +1,48 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
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 string ContentType { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public string AltText { get; set; }
|
||||
public byte[] Blob { get; set; } = [];
|
||||
public long Size { get; set; }
|
||||
|
||||
[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)]
|
||||
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>
|
||||
|
||||
@* 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. *@
|
||||
|
||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||
@@ -65,6 +66,19 @@
|
||||
</div>
|
||||
</ChildContent>
|
||||
</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>
|
||||
|
||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||
|
||||
@@ -34,14 +34,14 @@
|
||||
{
|
||||
@foreach (var post in ancestors)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 max-w-[calc(100%-1.5rem)]" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
|
||||
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
|
||||
@foreach (var post in descendants)
|
||||
{
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 max-w-[calc(100%-1.5rem)]" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
+15
-3
@@ -14,8 +14,9 @@
|
||||
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||
<div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
|
||||
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
||||
aria-label="@Localizer["Explore"]">
|
||||
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
|
||||
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
|
||||
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
|
||||
@@ -43,7 +44,7 @@
|
||||
@foreach (var item in tags)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
|
||||
<button type="button" class="flex flex-col items-start min-w-0 flex-1 text-left" @onclick="() => OpenTag(item.Name)">
|
||||
<button type="button" class="flex flex-col items-start justify-center min-w-0 flex-1 text-left pointer-coarse:min-h-11" @onclick="() => OpenTag(item.Name)">
|
||||
<b class="truncate">#@item.Name</b>
|
||||
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
|
||||
</button>
|
||||
@@ -139,6 +140,17 @@
|
||||
</section>
|
||||
|
||||
@code {
|
||||
ElementReference strip;
|
||||
int revealed = -1;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if ((int)tab == revealed)
|
||||
return;
|
||||
revealed = (int)tab;
|
||||
CascadingState.Status.RevealActive(strip);
|
||||
}
|
||||
|
||||
enum ExploreTab
|
||||
{
|
||||
Hashtags,
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
|
||||
|
||||
[Inject] GroupService GroupService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
List<ViewGroup> groups = [];
|
||||
List<ViewGroupMember> members = [];
|
||||
@@ -281,6 +282,8 @@
|
||||
|
||||
async Task Leave(ViewGroup group)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
|
||||
return;
|
||||
isSaving = true;
|
||||
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
|
||||
isSaving = false;
|
||||
|
||||
+51
-14
@@ -13,24 +13,33 @@
|
||||
the stream tells is applied at once. *@
|
||||
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
||||
|
||||
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
|
||||
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
|
||||
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
||||
<aside class="@GlobeClass" role="@(globeExpanded ? "dialog" : null)" aria-modal="@(globeExpanded ? "true" : null)"
|
||||
aria-label="@(globeExpanded ? Localizer["Globe"] : null)">
|
||||
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" @bind-Expanded="globeExpanded" />
|
||||
</aside>
|
||||
|
||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
|
||||
@if (!isComposing)
|
||||
{
|
||||
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
|
||||
<DButton Shape="DButtonShape.Circle" Size="DSize.Lg" Color="DColor.Primary" OnClick="ToggleCompose"
|
||||
class="md:hidden fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-30" aria-label="@Localizer["New post"]"
|
||||
title="@Localizer["New post"]" data-compose-open>
|
||||
<i class="ion-md-create text-xl" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
}
|
||||
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
|
||||
OnChanged="ChangeFeed" />
|
||||
|
||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
|
||||
@* (on a phone the bottom stays clear of the New post button) *@
|
||||
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 max-md:pb-24 md:p-4">
|
||||
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
|
||||
|
||||
@if (isComposing)
|
||||
{
|
||||
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||
</div>
|
||||
}
|
||||
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
|
||||
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
||||
</PhoneSheet>
|
||||
|
||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||
{
|
||||
@@ -134,9 +143,19 @@
|
||||
string loadError;
|
||||
bool isLoadingMore;
|
||||
bool isComposing;
|
||||
bool globeExpanded;
|
||||
|
||||
// a phone's banner (none when the phone is held sideways: the feed would have no room), the column from lg, or
|
||||
// full screen
|
||||
string GlobeClass => globeExpanded
|
||||
? "fixed inset-0 z-[70] block h-dvh w-full overflow-hidden bg-base-100"
|
||||
: Filter.ShowGlobe
|
||||
? "block max-lg:short:hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden"
|
||||
: "hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden";
|
||||
(double Latitude, double Longitude)? position;
|
||||
string positionError;
|
||||
string loadedPersonaId;
|
||||
bool waitingForPersona;
|
||||
FeedQuery lastQuery;
|
||||
DateTime? lastPollAt;
|
||||
TimeSpan? lastLoadTook;
|
||||
@@ -165,9 +184,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// (while the session is still being read the persona isn't known yet: the feed is loading, not empty)
|
||||
if (CascadingState.PersonaId is null)
|
||||
{
|
||||
IsLoading = false;
|
||||
waitingForPersona = !CascadingState.IsSessionLoaded;
|
||||
IsLoading = waitingForPersona;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,11 +222,19 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
||||
// The persona is known (the session was read after the page opened) or changed (NavMenu's switcher): everything on the
|
||||
// page belongs to it.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
|
||||
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
|
||||
{
|
||||
waitingForPersona = false;
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
|
||||
return;
|
||||
waitingForPersona = false;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
@@ -461,6 +490,14 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
// a composer that opens shows at the top of the feed, where it is, however far down the feed was read
|
||||
async Task ToggleCompose()
|
||||
{
|
||||
isComposing = !isComposing;
|
||||
if (isComposing && feedModule is not null)
|
||||
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
||||
}
|
||||
|
||||
async Task ShowPending()
|
||||
{
|
||||
var shown = pending;
|
||||
|
||||
+10
-3
@@ -40,8 +40,9 @@
|
||||
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
|
||||
</p>
|
||||
}
|
||||
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
|
||||
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" style="@HeaderStyle"></div>
|
||||
@* not overflow-hidden: the Lists menu hangs out of it; the header picture takes the card's top corners itself *@
|
||||
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs" data-account-id="@account.Id">
|
||||
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center rounded-t-xl" style="@HeaderStyle"></div>
|
||||
<div class="flex flex-wrap items-end gap-3 px-4 -mt-10">
|
||||
@* alt="": the name is next to it. *@
|
||||
<img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100"
|
||||
@@ -66,7 +67,7 @@
|
||||
}
|
||||
@if (relationship?.Following == true)
|
||||
{
|
||||
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm"
|
||||
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm" SheetOnNarrow="true"
|
||||
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-64 bg-base-100 neo-raised neo-xs z-20" title="@Localizer["Lists"]">
|
||||
<Trigger>
|
||||
<i class="ion-md-list"></i><span>@Localizer["Lists"]</span>
|
||||
@@ -190,6 +191,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] ListService ListService { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||
@@ -328,6 +330,11 @@
|
||||
|
||||
async Task Do(AccountAction action)
|
||||
{
|
||||
var name = account.Name ?? account.Acct;
|
||||
if (action is AccountAction.Block or AccountAction.Mute
|
||||
&& !await Confirm.Ask(action == AccountAction.Block ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
|
||||
Localizer[action == AccountAction.Block ? "Block" : "Mute"], action == AccountAction.Block))
|
||||
return;
|
||||
isActing = true;
|
||||
var result = await AccountService.Act(account.Id, action);
|
||||
isActing = false;
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
List<ScheduledStatus> scheduled = [];
|
||||
readonly Dictionary<string, string> moves = [];
|
||||
@@ -129,6 +130,8 @@
|
||||
|
||||
async Task Drop(ScheduledStatus item)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Never publish this post?"], null, Localizer["Never publish it"], true))
|
||||
return;
|
||||
isSaving = true;
|
||||
var result = await StatusService.Unschedule(item.Id);
|
||||
isSaving = false;
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||
<DToggle LabelText="@Localizer["Grey"]" Value="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">
|
||||
<span class="text-sm">@Localizer["Language"]</span>
|
||||
@* 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">
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<DataTransfer />
|
||||
<div class="flex flex-col gap-6">
|
||||
<PersonaArchivePanel />
|
||||
<DataTransfer />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -45,11 +45,14 @@ builder.Services.AddOptions()
|
||||
.AddScoped<AccountService>()
|
||||
.AddScoped<PortabilityService>()
|
||||
.AddScoped<AdminService>()
|
||||
.AddScoped<ArchiveService>()
|
||||
.AddScoped<ListService>()
|
||||
.AddScoped<FilterService>()
|
||||
.AddScoped<DiscoveryService>()
|
||||
.AddScoped<StreamingService>()
|
||||
.AddScoped<PublicNoticeService>()
|
||||
.AddScoped<ConfirmService>()
|
||||
.AddScoped<OverlayService>()
|
||||
.AddScoped<MediaService>()
|
||||
.AddScoped<GroupService>()
|
||||
.AddScoped<InstanceService>()
|
||||
@@ -69,6 +72,8 @@ builder.Services.AddHttpClient("default", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
||||
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 =>
|
||||
|
||||
+450
-21
@@ -569,9 +569,6 @@
|
||||
<data name="spread" xml:space="preserve">
|
||||
<value>dispersione</value>
|
||||
</data>
|
||||
<data name="texture" xml:space="preserve">
|
||||
<value>texture</value>
|
||||
</data>
|
||||
<data name="the author's server" xml:space="preserve">
|
||||
<value>il server dell'autore</value>
|
||||
</data>
|
||||
@@ -629,15 +626,6 @@
|
||||
<data name="{0}: {1}" xml:space="preserve">
|
||||
<value>{0}: {1}</value>
|
||||
</data>
|
||||
<data name="hovered" xml:space="preserve">
|
||||
<value>sotto il puntatore</value>
|
||||
</data>
|
||||
<data name="arcs" xml:space="preserve">
|
||||
<value>archi</value>
|
||||
</data>
|
||||
<data name="rings" xml:space="preserve">
|
||||
<value>anelli</value>
|
||||
</data>
|
||||
<data name="placed" xml:space="preserve">
|
||||
<value>collocati</value>
|
||||
</data>
|
||||
@@ -683,12 +671,6 @@
|
||||
<data name="idle" xml:space="preserve">
|
||||
<value>fermo</value>
|
||||
</data>
|
||||
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
|
||||
<value>fermo: nulla si muove, nulla viene disegnato</value>
|
||||
</data>
|
||||
<data name="Sun" xml:space="preserve">
|
||||
<value>Sole</value>
|
||||
</data>
|
||||
<data name="Now" xml:space="preserve">
|
||||
<value>Ora</value>
|
||||
</data>
|
||||
@@ -710,9 +692,6 @@
|
||||
<data name="countries" xml:space="preserve">
|
||||
<value>paesi</value>
|
||||
</data>
|
||||
<data name="named at this zoom" xml:space="preserve">
|
||||
<value>con nome a questo zoom</value>
|
||||
</data>
|
||||
<data name="borders" xml:space="preserve">
|
||||
<value>confini</value>
|
||||
</data>
|
||||
@@ -1817,4 +1796,454 @@
|
||||
<data name="{0} filtered, waiting for you" xml:space="preserve">
|
||||
<value>{0} filtrate, ti aspettano</value>
|
||||
</data>
|
||||
<data name="Menu" xml:space="preserve">
|
||||
<value>Menu</value>
|
||||
</data>
|
||||
<data name="New post" xml:space="preserve">
|
||||
<value>Nuovo post</value>
|
||||
</data>
|
||||
<data name="Open the globe full screen" xml:space="preserve">
|
||||
<value>Apri il globo a schermo intero</value>
|
||||
</data>
|
||||
<data name="Delete this post?" xml:space="preserve">
|
||||
<value>Eliminare questo post?</value>
|
||||
</data>
|
||||
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
|
||||
<value>Sparisce da qui, e ai server che lo hanno viene chiesto di eliminarlo.</value>
|
||||
</data>
|
||||
<data name="Take this post off your wall?" xml:space="preserve">
|
||||
<value>Togliere questo post dalla tua bacheca?</value>
|
||||
</data>
|
||||
<data name="Block {0}?" xml:space="preserve">
|
||||
<value>Bloccare {0}?</value>
|
||||
</data>
|
||||
<data name="Mute {0}?" xml:space="preserve">
|
||||
<value>Silenziare {0}?</value>
|
||||
</data>
|
||||
<data name="Leave {0}?" xml:space="preserve">
|
||||
<value>Lasciare {0}?</value>
|
||||
</data>
|
||||
<data name="Never publish this post?" xml:space="preserve">
|
||||
<value>Non pubblicare mai questo post?</value>
|
||||
</data>
|
||||
<data name="Log out?" xml:space="preserve">
|
||||
<value>Uscire?</value>
|
||||
</data>
|
||||
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
|
||||
<value>Tutte le persone di questo accesso escono da questo dispositivo.</value>
|
||||
</data>
|
||||
<data name="Feeds" xml:space="preserve">
|
||||
<value>Feed</value>
|
||||
</data>
|
||||
<data name="Details" xml:space="preserve">
|
||||
<value>Dettagli</value>
|
||||
</data>
|
||||
<data name="Picture" xml:space="preserve">
|
||||
<value>Immagine</value>
|
||||
</data>
|
||||
<data name="Previous picture" xml:space="preserve">
|
||||
<value>Immagine precedente</value>
|
||||
</data>
|
||||
<data name="Next picture" xml:space="preserve">
|
||||
<value>Immagine successiva</value>
|
||||
</data>
|
||||
<data name="Picture {0} of {1}" xml:space="preserve">
|
||||
<value>Immagine {0} di {1}</value>
|
||||
</data>
|
||||
<data name="day/night line" xml:space="preserve">
|
||||
<value>linea giorno/notte</value>
|
||||
</data>
|
||||
<data name="solar noon" xml:space="preserve">
|
||||
<value>mezzogiorno solare</value>
|
||||
</data>
|
||||
<data name="solar midnight" xml:space="preserve">
|
||||
<value>mezzanotte solare</value>
|
||||
</data>
|
||||
<data name="Tropic of Cancer" xml:space="preserve">
|
||||
<value>Tropico del Cancro</value>
|
||||
</data>
|
||||
<data name="Tropic of Capricorn" xml:space="preserve">
|
||||
<value>Tropico del Capricorno</value>
|
||||
</data>
|
||||
<data name="Arctic Circle" xml:space="preserve">
|
||||
<value>Circolo polare artico</value>
|
||||
</data>
|
||||
<data name="Antarctic Circle" xml:space="preserve">
|
||||
<value>Circolo polare antartico</value>
|
||||
</data>
|
||||
<data name="this hour, all year" xml:space="preserve">
|
||||
<value>a quest'ora, tutto l'anno</value>
|
||||
</data>
|
||||
<data name="the Sun never sets" xml:space="preserve">
|
||||
<value>il Sole non tramonta</value>
|
||||
</data>
|
||||
<data name="the Sun never rises" xml:space="preserve">
|
||||
<value>il Sole non sorge</value>
|
||||
</data>
|
||||
<data name="perihelion" xml:space="preserve">
|
||||
<value>perielio</value>
|
||||
</data>
|
||||
<data name="aphelion" xml:space="preserve">
|
||||
<value>afelio</value>
|
||||
</data>
|
||||
<data name="not to scale" xml:space="preserve">
|
||||
<value>non in scala</value>
|
||||
</data>
|
||||
<data name="day map" xml:space="preserve">
|
||||
<value>mappa del giorno</value>
|
||||
</data>
|
||||
<data name="day and night maps" xml:space="preserve">
|
||||
<value>mappe del giorno e della notte</value>
|
||||
</data>
|
||||
<data name="{0}× anisotropic" xml:space="preserve">
|
||||
<value>anisotropia {0}×</value>
|
||||
</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>
|
||||
+450
-21
@@ -569,9 +569,6 @@
|
||||
<data name="spread" xml:space="preserve">
|
||||
<value>spread</value>
|
||||
</data>
|
||||
<data name="texture" xml:space="preserve">
|
||||
<value>texture</value>
|
||||
</data>
|
||||
<data name="the author's server" xml:space="preserve">
|
||||
<value>the author's server</value>
|
||||
</data>
|
||||
@@ -629,15 +626,6 @@
|
||||
<data name="{0}: {1}" xml:space="preserve">
|
||||
<value>{0}: {1}</value>
|
||||
</data>
|
||||
<data name="hovered" xml:space="preserve">
|
||||
<value>hovered</value>
|
||||
</data>
|
||||
<data name="arcs" xml:space="preserve">
|
||||
<value>arcs</value>
|
||||
</data>
|
||||
<data name="rings" xml:space="preserve">
|
||||
<value>rings</value>
|
||||
</data>
|
||||
<data name="placed" xml:space="preserve">
|
||||
<value>placed</value>
|
||||
</data>
|
||||
@@ -683,12 +671,6 @@
|
||||
<data name="idle" xml:space="preserve">
|
||||
<value>idle</value>
|
||||
</data>
|
||||
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
|
||||
<value>idle: nothing moves, nothing is drawn</value>
|
||||
</data>
|
||||
<data name="Sun" xml:space="preserve">
|
||||
<value>Sun</value>
|
||||
</data>
|
||||
<data name="Now" xml:space="preserve">
|
||||
<value>Now</value>
|
||||
</data>
|
||||
@@ -710,9 +692,6 @@
|
||||
<data name="countries" xml:space="preserve">
|
||||
<value>countries</value>
|
||||
</data>
|
||||
<data name="named at this zoom" xml:space="preserve">
|
||||
<value>named at this zoom</value>
|
||||
</data>
|
||||
<data name="borders" xml:space="preserve">
|
||||
<value>borders</value>
|
||||
</data>
|
||||
@@ -1817,4 +1796,454 @@
|
||||
<data name="{0} filtered, waiting for you" xml:space="preserve">
|
||||
<value>{0} filtered, waiting for you</value>
|
||||
</data>
|
||||
<data name="Menu" xml:space="preserve">
|
||||
<value>Menu</value>
|
||||
</data>
|
||||
<data name="New post" xml:space="preserve">
|
||||
<value>New post</value>
|
||||
</data>
|
||||
<data name="Open the globe full screen" xml:space="preserve">
|
||||
<value>Open the globe full screen</value>
|
||||
</data>
|
||||
<data name="Delete this post?" xml:space="preserve">
|
||||
<value>Delete this post?</value>
|
||||
</data>
|
||||
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
|
||||
<value>It goes from here, and the servers that have it are asked to delete it too.</value>
|
||||
</data>
|
||||
<data name="Take this post off your wall?" xml:space="preserve">
|
||||
<value>Take this post off your wall?</value>
|
||||
</data>
|
||||
<data name="Block {0}?" xml:space="preserve">
|
||||
<value>Block {0}?</value>
|
||||
</data>
|
||||
<data name="Mute {0}?" xml:space="preserve">
|
||||
<value>Mute {0}?</value>
|
||||
</data>
|
||||
<data name="Leave {0}?" xml:space="preserve">
|
||||
<value>Leave {0}?</value>
|
||||
</data>
|
||||
<data name="Never publish this post?" xml:space="preserve">
|
||||
<value>Never publish this post?</value>
|
||||
</data>
|
||||
<data name="Log out?" xml:space="preserve">
|
||||
<value>Log out?</value>
|
||||
</data>
|
||||
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
|
||||
<value>Every persona of this login is signed out on this device.</value>
|
||||
</data>
|
||||
<data name="Feeds" xml:space="preserve">
|
||||
<value>Feeds</value>
|
||||
</data>
|
||||
<data name="Details" xml:space="preserve">
|
||||
<value>Details</value>
|
||||
</data>
|
||||
<data name="Picture" xml:space="preserve">
|
||||
<value>Picture</value>
|
||||
</data>
|
||||
<data name="Previous picture" xml:space="preserve">
|
||||
<value>Previous picture</value>
|
||||
</data>
|
||||
<data name="Next picture" xml:space="preserve">
|
||||
<value>Next picture</value>
|
||||
</data>
|
||||
<data name="Picture {0} of {1}" xml:space="preserve">
|
||||
<value>Picture {0} of {1}</value>
|
||||
</data>
|
||||
<data name="day/night line" xml:space="preserve">
|
||||
<value>day/night line</value>
|
||||
</data>
|
||||
<data name="solar noon" xml:space="preserve">
|
||||
<value>solar noon</value>
|
||||
</data>
|
||||
<data name="solar midnight" xml:space="preserve">
|
||||
<value>solar midnight</value>
|
||||
</data>
|
||||
<data name="Tropic of Cancer" xml:space="preserve">
|
||||
<value>Tropic of Cancer</value>
|
||||
</data>
|
||||
<data name="Tropic of Capricorn" xml:space="preserve">
|
||||
<value>Tropic of Capricorn</value>
|
||||
</data>
|
||||
<data name="Arctic Circle" xml:space="preserve">
|
||||
<value>Arctic Circle</value>
|
||||
</data>
|
||||
<data name="Antarctic Circle" xml:space="preserve">
|
||||
<value>Antarctic Circle</value>
|
||||
</data>
|
||||
<data name="this hour, all year" xml:space="preserve">
|
||||
<value>this hour, all year</value>
|
||||
</data>
|
||||
<data name="the Sun never sets" xml:space="preserve">
|
||||
<value>the Sun never sets</value>
|
||||
</data>
|
||||
<data name="the Sun never rises" xml:space="preserve">
|
||||
<value>the Sun never rises</value>
|
||||
</data>
|
||||
<data name="perihelion" xml:space="preserve">
|
||||
<value>perihelion</value>
|
||||
</data>
|
||||
<data name="aphelion" xml:space="preserve">
|
||||
<value>aphelion</value>
|
||||
</data>
|
||||
<data name="not to scale" xml:space="preserve">
|
||||
<value>not to scale</value>
|
||||
</data>
|
||||
<data name="day map" xml:space="preserve">
|
||||
<value>day map</value>
|
||||
</data>
|
||||
<data name="day and night maps" xml:space="preserve">
|
||||
<value>day and night maps</value>
|
||||
</data>
|
||||
<data name="{0}× anisotropic" xml:space="preserve">
|
||||
<value>{0}× anisotropic</value>
|
||||
</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>
|
||||
@@ -221,6 +221,20 @@ namespace decePubClient.Services
|
||||
: 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>
|
||||
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using decePubClient.Models;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
@@ -41,6 +42,20 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
||||
|
||||
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
|
||||
|
||||
// whether the navigation being decided came from the Back (or Forward) button
|
||||
public bool TookBack() => JsSyncRuntime.Invoke<bool>("tookBack");
|
||||
|
||||
// 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");
|
||||
|
||||
// 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
|
||||
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||
|
||||
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
|
||||
|
||||
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Asks before what can't be undone (deleting a post, blocking, leaving a group, signing out): Ask shows the question
|
||||
/// in ConfirmHost (MainLayout) and returns whether the reader confirmed it. One question at a time; a second one asked
|
||||
/// while the first is open answers the first no.
|
||||
/// </summary>
|
||||
public class ConfirmService
|
||||
{
|
||||
TaskCompletionSource<bool> pending;
|
||||
|
||||
public event Action Changed;
|
||||
|
||||
public bool IsAsking => pending is not null;
|
||||
public string Heading { get; private set; }
|
||||
public string Text { get; private set; }
|
||||
public string ConfirmText { get; private set; }
|
||||
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
|
||||
public bool Danger { get; private set; }
|
||||
|
||||
public async Task<bool> Ask(string heading, string text, string confirmText, bool danger, CancellationToken cancellationToken = default)
|
||||
{
|
||||
pending?.TrySetResult(false);
|
||||
var asking = new TaskCompletionSource<bool>();
|
||||
pending = asking;
|
||||
(Heading, Text, ConfirmText, Danger) = (heading, text, confirmText, danger);
|
||||
Changed?.Invoke();
|
||||
await using var cancel = cancellationToken.Register(() => asking.TrySetResult(false));
|
||||
var confirmed = await asking.Task;
|
||||
if (pending == asking)
|
||||
{
|
||||
pending = null;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
public void Answer(bool confirmed) => pending?.TrySetResult(confirmed);
|
||||
}
|
||||
}
|
||||
@@ -310,10 +310,24 @@ namespace decePubClient.Services
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// 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 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;
|
||||
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")));
|
||||
|
||||
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
|
||||
|
||||
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)
|
||||
{
|
||||
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
|
||||
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
|
||||
static readonly MediaConfiguration Fallback = new()
|
||||
{
|
||||
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>)(() =>
|
||||
{
|
||||
var file = new ByteArrayContent(media.Blob);
|
||||
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
|
||||
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
|
||||
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
||||
if (media.AltText is { Length: > 0 } description)
|
||||
content.Add(new StringContent(description), "description");
|
||||
@@ -29,6 +54,35 @@ namespace decePubClient.Services
|
||||
if (!response.IsSuccessStatusCode)
|
||||
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);
|
||||
return attachment?.Id is null
|
||||
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using decePubClient.Components.Daisy;
|
||||
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// The overlays open now, newest last (IDOverlayHost): MainLayout closes the newest when the Back button is pressed
|
||||
/// while one is open, and all of them when the app goes elsewhere.
|
||||
/// </summary>
|
||||
public class OverlayService : IDOverlayHost
|
||||
{
|
||||
readonly List<Entry> open = [];
|
||||
|
||||
public bool HasOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (open)
|
||||
return open.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable Open(Func<Task> close)
|
||||
{
|
||||
var entry = new Entry(this, close);
|
||||
lock (open)
|
||||
open.Add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public async Task CloseTop(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Entry top;
|
||||
lock (open)
|
||||
top = open.LastOrDefault();
|
||||
if (top is null)
|
||||
return;
|
||||
top.Dispose();
|
||||
await top.Close();
|
||||
}
|
||||
|
||||
public async Task CloseAll(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Entry[] all;
|
||||
lock (open)
|
||||
{
|
||||
all = [.. open];
|
||||
open.Clear();
|
||||
}
|
||||
foreach (var entry in all.Reverse())
|
||||
await entry.Close();
|
||||
}
|
||||
|
||||
sealed class Entry(OverlayService owner, Func<Task> close) : IDisposable
|
||||
{
|
||||
public Func<Task> Close { get; } = close;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (owner.open)
|
||||
owner.open.Remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using collAnon.Client.Services;
|
||||
using System.Text;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
@@ -35,7 +36,7 @@ namespace decePubClient.Services
|
||||
/// reports the rows it could not do.
|
||||
/// </summary>
|
||||
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>
|
||||
public const int MaxRows = 5000;
|
||||
@@ -53,23 +54,33 @@ namespace decePubClient.Services
|
||||
};
|
||||
|
||||
/// <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
|
||||
{
|
||||
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")));
|
||||
public WebResult Failed { get; set; }
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
return
|
||||
[
|
||||
@@ -99,7 +110,7 @@ namespace decePubClient.Services
|
||||
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 maxId = default(string);
|
||||
@@ -107,7 +118,10 @@ namespace decePubClient.Services
|
||||
{
|
||||
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
||||
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;
|
||||
}
|
||||
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
||||
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
||||
}
|
||||
@@ -116,7 +130,7 @@ namespace decePubClient.Services
|
||||
}
|
||||
|
||||
// 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 maxId = default(string);
|
||||
@@ -124,7 +138,10 @@ namespace decePubClient.Services
|
||||
{
|
||||
var result = await page(maxId, cancellationToken);
|
||||
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;
|
||||
}
|
||||
all.AddRange(accountPage.Accounts);
|
||||
maxId = accountPage.MaxId;
|
||||
}
|
||||
|
||||
+44
-13
@@ -33,27 +33,25 @@ namespace decePubClient.Services
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
if (form.IsLocated || form.GroupId is not null)
|
||||
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
||||
|
||||
var mediaIds = new List<string>();
|
||||
foreach (var media in form.Media)
|
||||
{
|
||||
var uploaded = await mediaService.Upload(media, cancellationToken);
|
||||
if (uploaded.IsInvalid)
|
||||
return uploaded;
|
||||
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
|
||||
}
|
||||
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;
|
||||
var mediaIds = form.Media.Select(m => m.Id).ToList();
|
||||
|
||||
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
||||
{
|
||||
Status = form.Content ?? string.Empty,
|
||||
MediaIds = mediaIds,
|
||||
InReplyToId = form.InReplyToId,
|
||||
Sensitive = form.HasContentWarning,
|
||||
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||
Visibility = form.Visibility,
|
||||
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||
@@ -70,6 +68,20 @@ namespace decePubClient.Services
|
||||
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>
|
||||
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -104,15 +116,23 @@ namespace decePubClient.Services
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
Status = form.Content ?? string.Empty,
|
||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||
Sensitive = form.HasContentWarning,
|
||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
||||
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||
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);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
@@ -228,6 +248,17 @@ namespace decePubClient.Services
|
||||
return await StatusResult(response, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.</summary>
|
||||
public async Task<WebResult> DeleteLocated(string postId, string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePost, new DeletePostForm { AvatarId = personaId, PostId = postId },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
|
||||
|
||||
+42
-9
@@ -1,14 +1,47 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
<div class="relative flex flex-col md:flex-row w-full h-full">
|
||||
<NavMenu />
|
||||
@* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's
|
||||
Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own
|
||||
navigation closes them all and goes on. *@
|
||||
<CascadingValue Value="(IDOverlayHost)Overlays" IsFixed="true">
|
||||
<div class="relative flex flex-col md:flex-row w-full h-full">
|
||||
<NavMenu />
|
||||
|
||||
<div class="block flex-1 relative w-full h-full">
|
||||
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
|
||||
@Body
|
||||
<div class="block flex-1 relative w-full h-full">
|
||||
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
<PublicNoticeHost />
|
||||
<ToastHost />
|
||||
<PublicNoticeHost />
|
||||
<ConfirmHost />
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
[Inject] NavigationManager Navigation { get; set; }
|
||||
[Inject] AppStatusService Status { get; set; }
|
||||
|
||||
IDisposable changing;
|
||||
|
||||
protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
|
||||
|
||||
async ValueTask OnLocationChanging(LocationChangingContext context)
|
||||
{
|
||||
var back = !context.IsNavigationIntercepted && Status.TookBack();
|
||||
if (!Overlays.HasOpen)
|
||||
return;
|
||||
if (back)
|
||||
{
|
||||
context.PreventNavigation();
|
||||
await Overlays.CloseTop(context.CancellationToken);
|
||||
return;
|
||||
}
|
||||
await Overlays.CloseAll(context.CancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose() => changing?.Dispose();
|
||||
}
|
||||
+79
-20
@@ -1,12 +1,22 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0">
|
||||
@* On a phone the menu opens from the hamburger over the page, and closes after a choice (any navigation, a persona
|
||||
switch), on a tap outside it or on Escape; from md it is the sidebar, which scrolls on its own when the window is
|
||||
short (a phone held sideways). *@
|
||||
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0 md:h-full md:overflow-y-auto md:overscroll-contain"
|
||||
@onkeydown="OnKeyDown">
|
||||
|
||||
@if (isMenuOpen)
|
||||
{
|
||||
<div class="md:hidden fixed inset-0 z-40 bg-black/30" aria-hidden="true" @onclick="CloseMenu"></div>
|
||||
}
|
||||
<div class="relative md:w-52">
|
||||
<DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu">
|
||||
<i class="ion-md-menu text-lg"></i>
|
||||
<DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu" aria-label="@Localizer["Menu"]" title="@Localizer["Menu"]"
|
||||
aria-expanded="@(isMenuOpen ? "true" : "false")" aria-controls="nav-panel" data-nav-toggle>
|
||||
<i class="ion-md-menu text-lg" aria-hidden="true"></i>
|
||||
</DButton>
|
||||
<div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block">
|
||||
<div id="nav-panel" data-nav-panel
|
||||
class="@(isMenuOpen ? "block" : "hidden") md:block absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-[min(18rem,calc(100vw-1.5rem))] md:w-full max-h-[calc(100dvh-5rem)] md:max-h-none overflow-y-auto overscroll-contain rounded-lg neo-raised neo-sm md:shadow-none! z-50">
|
||||
<div class="flex flex-col gap-3 p-3 md:p-4">
|
||||
|
||||
@if (CascadingState.IsSignedIn)
|
||||
@@ -79,10 +89,10 @@
|
||||
</AuthorizeView>
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout">
|
||||
<DButton class="justify-start" OnClick="Logout" data-logout>
|
||||
<i class="ion-md-log-out text-error"></i>
|
||||
<span>@Localizer["Logout"]</span>
|
||||
</NavLink>
|
||||
</DButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -102,7 +112,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark">
|
||||
<DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark" class="touch-target"
|
||||
aria-label="@Localizer["Dark mode"]" title="@Localizer["Dark mode"]">
|
||||
<UncheckedIcon>
|
||||
<i class="ion-md-sunny"></i>
|
||||
</UncheckedIcon>
|
||||
@@ -116,21 +127,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden">
|
||||
<a class="md:hidden grid place-items-center h-11 w-11" href="" aria-label="@Localizer["Home"]" title="@Localizer["Home"]">
|
||||
<img alt="" class="h-[30px]" src="imgs/icon-512.png">
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="md:hidden">
|
||||
<a class="block rounded-full neo-raised neo-2xs" href="settings">
|
||||
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full"
|
||||
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
|
||||
</a>
|
||||
</div>
|
||||
<a class="md:hidden grid place-items-center h-11 w-11" href="settings" aria-label="@Localizer["Settings"]" title="@Localizer["Settings"]">
|
||||
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full neo-raised neo-2xs"
|
||||
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
string menuToggle = "hidden";
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
[Inject] OverlayService Overlays { get; set; }
|
||||
|
||||
bool isMenuOpen;
|
||||
IDisposable overlay;
|
||||
|
||||
// open, the Back button closes it
|
||||
void SetMenu(bool open)
|
||||
{
|
||||
isMenuOpen = open;
|
||||
if (open && overlay is null)
|
||||
overlay = Overlays.Open(() => InvokeAsync(() =>
|
||||
{
|
||||
SetMenu(false);
|
||||
StateHasChanged();
|
||||
}));
|
||||
else if (!open && overlay is not null)
|
||||
{
|
||||
overlay.Dispose();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
bool isPersonaMenuOpen;
|
||||
|
||||
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
|
||||
@@ -144,20 +175,48 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
IsLoading = false;
|
||||
if (!CascadingState.Status.IsMobileMedia())
|
||||
menuToggle = default;
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
void OnLocationChanged(object sender, LocationChangedEventArgs e)
|
||||
{
|
||||
if (!isMenuOpen)
|
||||
return;
|
||||
SetMenu(false);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
void CloseMenu() => SetMenu(false);
|
||||
|
||||
void OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
SetMenu(false);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
Navigation.LocationChanged -= OnLocationChanged;
|
||||
overlay?.Dispose();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task SwitchPersona(string personaId)
|
||||
{
|
||||
SetMenu(false);
|
||||
var result = await CascadingState.SwitchPersona(personaId);
|
||||
if (result.IsInvalid)
|
||||
Toast.AddResponseError(result);
|
||||
}
|
||||
|
||||
private void ToggleNavMenu()
|
||||
private void ToggleNavMenu() => SetMenu(!isMenuOpen);
|
||||
|
||||
async Task Logout()
|
||||
{
|
||||
menuToggle = menuToggle is { Length: > 0 } ? default : "hidden";
|
||||
SetMenu(false);
|
||||
if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true))
|
||||
return;
|
||||
Navigation.NavigateTo("logout");
|
||||
}
|
||||
|
||||
protected async Task ResetToOriginalColour()
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
|
||||
@import "./theme.css";
|
||||
@import "./neo.css";
|
||||
@import "./touch.css";
|
||||
@import "./base.css";
|
||||
@import "./feed.css";
|
||||
@@ -5,6 +5,12 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The page never scrolls itself (the feed and the pages scroll inside), so a pull past its top would only make a phone
|
||||
reload the app. Only vertically: a sideways overscroll is the trackpad's back gesture. */
|
||||
html, body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
details > summary::-webkit-details-marker {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
+144
-1
@@ -62,13 +62,25 @@
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. */
|
||||
/* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI
|
||||
only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen
|
||||
it never shows: a tap opens the rows in a sheet instead (NerdTip). */
|
||||
.tooltip-content:has(> .nerd-stats) {
|
||||
border-radius: var(--radius-box);
|
||||
padding: 0.5rem 0.65rem;
|
||||
max-width: min(22rem, 90vw);
|
||||
}
|
||||
|
||||
.tooltip:not(:hover, :focus-within, .tooltip-open) > .tooltip-content:has(> .nerd-stats) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.tooltip-content:has(> .nerd-stats) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.float-tooltip-kap {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
@@ -118,4 +130,135 @@
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The globe's instruments (wwwroot/js/instruments.js): an overlay of chips beside what is drawn in the scene, the
|
||||
frame-rate ring around the globe and a two-line legend. It never takes the pointer. Each chip's colour is its
|
||||
instrument's: the Sun's warning, the twilights' info, the place's accent, the view's primary. */
|
||||
.globe-instruments {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.globe-tag {
|
||||
--tag: var(--color-base-content);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font: 10px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
color: var(--color-base-content);
|
||||
background: color-mix(in oklab, var(--color-base-100) 78%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--tag) 70%, transparent);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.globe-tag-sun,
|
||||
.globe-tag-sun-faint {
|
||||
--tag: var(--color-warning);
|
||||
}
|
||||
|
||||
.globe-tag-twilight {
|
||||
--tag: var(--color-info);
|
||||
}
|
||||
|
||||
/* a place's chip: its place, phase and solar time over its Sun's height and its day */
|
||||
.globe-tag-place {
|
||||
--tag: var(--color-accent);
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
|
||||
.globe-tag-server {
|
||||
--tag: var(--color-primary);
|
||||
}
|
||||
|
||||
.globe-tag-white {
|
||||
--tag: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.globe-tag-orrery {
|
||||
--tag: var(--color-base-content);
|
||||
}
|
||||
|
||||
.globe-tag-sun-faint,
|
||||
.globe-tag-orrery-faint {
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.globe-tag-ring {
|
||||
--tag: var(--color-base-content);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* a place's chip says the phase of its day */
|
||||
.globe-tag-place[data-phase="day"] {
|
||||
--tag: var(--color-warning);
|
||||
}
|
||||
|
||||
.globe-tag-place[data-phase="night"],
|
||||
.globe-tag-place[data-phase="astronomical"] {
|
||||
--tag: var(--color-neutral);
|
||||
}
|
||||
|
||||
.globe-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.globe-ring-track {
|
||||
fill: none;
|
||||
stroke: color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.globe-ring-value {
|
||||
fill: none;
|
||||
stroke: var(--color-success);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.6s ease-out;
|
||||
}
|
||||
|
||||
.globe-ring[data-pace="fair"] .globe-ring-value {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.globe-ring[data-pace="poor"] .globe-ring-value {
|
||||
stroke: var(--color-error);
|
||||
}
|
||||
|
||||
.globe-ring[data-pace="idle"] .globe-ring-track,
|
||||
.globe-ring[data-pace="paused"] .globe-ring-track {
|
||||
stroke-dasharray: 2 6;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.globe-ring-value {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.globe-legend {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
font: 9px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: pre-line;
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.65;
|
||||
text-shadow: 0 0 3px var(--color-base-100);
|
||||
}
|
||||
}
|
||||
+75
-6
@@ -14,6 +14,11 @@
|
||||
each one excludes the variants it must leave alone. :where() keeps those exclusions at specificity (0,1,0).
|
||||
To drop a neo shadow at a call site use shadow-none! or neo-flat!.
|
||||
|
||||
A gradient on a control is painted from its border box (background-origin: border-box). daisyUI keeps a 1px border
|
||||
on its controls, made transparent here, and a background image starts at the padding box and repeats into the
|
||||
border: the strip then showed the far end of the gradient, a thin seam along the edges, as if the control sat in a
|
||||
second frame.
|
||||
|
||||
Never write @utility collapse: it brings back Tailwind's own collapse utility (visibility: collapse), which daisyUI
|
||||
otherwise suppresses, and hides every collapse. DCollapse adds neo-raised in its markup instead. */
|
||||
|
||||
@@ -64,10 +69,12 @@
|
||||
|
||||
@utility neo-gradient {
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-light), var(--neo-grad-dark));
|
||||
background-origin: border-box;
|
||||
}
|
||||
|
||||
@utility neo-gradient-pressed {
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
}
|
||||
|
||||
@utility neo-hue-track {
|
||||
@@ -92,6 +99,7 @@
|
||||
--btn-bg: var(--btn-color, var(--color-base-100));
|
||||
--btn-border: transparent;
|
||||
background-image: linear-gradient(145deg, color-mix(in oklab, var(--btn-bg), white var(--neo-lift)), color-mix(in oklab, var(--btn-bg), black var(--neo-sink)));
|
||||
background-origin: border-box;
|
||||
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
||||
|
||||
&:where(:focus-visible) {
|
||||
@@ -111,11 +119,19 @@
|
||||
}
|
||||
|
||||
/* Text fields: a pressed well, darker while focused, with no focus ring and no border unless a colour or a failed
|
||||
validation asks for one. */
|
||||
validation asks for one.
|
||||
|
||||
Fields (input, select, file-input, textarea) take the boxes' radius (--radius-box, .75rem). daisyUI gives them
|
||||
--radius-field (2rem), which stays the buttons': a pill on a one-line field, and on a textarea a curve that ate into
|
||||
its first and last lines. daisyUI reads the variable, so a rounded-* class at the call site (the composer's) still
|
||||
wins. */
|
||||
@utility input {
|
||||
--radius-field: var(--radius-box);
|
||||
|
||||
&:not(:where(.input-ghost)) {
|
||||
background-color: var(--color-base-100);
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
|
||||
&:focus, &:focus-within {
|
||||
@@ -132,6 +148,8 @@
|
||||
|
||||
/* daisyUI draws the select's arrow with background-image, so the well here is the background colour alone. */
|
||||
@utility select {
|
||||
--radius-field: var(--radius-box);
|
||||
|
||||
&:not(:where(.select-ghost)) {
|
||||
background-color: var(--color-base-100);
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
@@ -160,9 +178,12 @@
|
||||
}
|
||||
|
||||
@utility textarea {
|
||||
--radius-field: var(--radius-box);
|
||||
|
||||
&:not(:where(.textarea-ghost)) {
|
||||
background-color: var(--color-base-100);
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
|
||||
&:focus, &:focus-within {
|
||||
@@ -178,30 +199,68 @@
|
||||
}
|
||||
|
||||
@utility file-input {
|
||||
--radius-field: var(--radius-box);
|
||||
|
||||
&:not(:where(.file-input-ghost)) {
|
||||
background-color: var(--color-base-100);
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
}
|
||||
|
||||
&: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;
|
||||
|
||||
/* 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 {
|
||||
&: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)) {
|
||||
border-color: transparent;
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
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 {
|
||||
&: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;
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
}
|
||||
}
|
||||
@@ -210,6 +269,7 @@
|
||||
&:not(:where(:disabled)) {
|
||||
border-color: transparent;
|
||||
background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light));
|
||||
background-origin: border-box;
|
||||
box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light);
|
||||
|
||||
&::before {
|
||||
@@ -235,18 +295,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* At most the screen's height as the phone shows it (dvh: its browser bars come and go), and a bottom sheet leaves room
|
||||
above it and clears the home indicator. */
|
||||
@utility modal-box {
|
||||
box-shadow: -6px -6px 12px var(--neo-light), 6px 6px 12px var(--neo-dark);
|
||||
max-height: 100dvh;
|
||||
|
||||
:where(.modal-bottom) > & {
|
||||
max-height: calc(100dvh - 3rem);
|
||||
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@utility dropdown-content {
|
||||
background-color: var(--color-base-100);
|
||||
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
||||
|
||||
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stays
|
||||
invisible (opacity 0) but still in the way of the pointer, over whatever it covers: hide it from the pointer too. */
|
||||
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stayed
|
||||
invisible (opacity 0) but in the way of the pointer, and still took room: a closed menu past a card's edge made
|
||||
the feed scroll sideways on a phone. A closed one is not displayed at all. */
|
||||
:where(.dropdown.dropdown-close, .dropdown:not(.dropdown-open, .dropdown-hover:hover, :focus-within)) & {
|
||||
visibility: hidden;
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/* Touch screens.
|
||||
|
||||
A phone's layout uses the width variants (max-md:, md:) at the call site; how big a thing is to a finger uses the
|
||||
pointer: the sizes here sit under @media (pointer: coarse), so a mouse matches none of them and desktop keeps
|
||||
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
||||
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
||||
|
||||
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
|
||||
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
||||
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
||||
areas would overlap. */
|
||||
/* A small text link or button (a name, "edited") keeps its size to the eye and gets a 44px hit area, a transparent box
|
||||
centred on it, on touch only. Not for one that truncates its text: its overflow clips the box, so such a link takes
|
||||
padding instead (pointer-coarse:py-3.5). */
|
||||
@utility touch-target {
|
||||
@media (pointer: coarse) {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: max(100%, 2.75rem);
|
||||
height: max(100%, 2.75rem);
|
||||
translate: -50% -50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A short window: a phone held sideways. The globe's banner would leave the feed no room there. */
|
||||
@custom-variant short (@media (max-height: 30rem));
|
||||
|
||||
@layer utilities {
|
||||
/* At every width: a checkbox's, radio's or toggle's label wraps (daisyUI's .label never does, and a long one ran past
|
||||
a phone's edge), and the control keeps its size beside it. */
|
||||
.label:has(> input[type=checkbox], > input[type=radio], > .toggle) {
|
||||
white-space: normal;
|
||||
text-align: start;
|
||||
|
||||
& > input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* Field text at 16px: iOS zooms into a focused field whose text is smaller, now that zooming is allowed. */
|
||||
:where(.input, .select, .textarea) {
|
||||
--font-size-min: 1rem;
|
||||
}
|
||||
|
||||
.btn:not(.btn-xs, .btn-lg, .btn-xl) {
|
||||
--size: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
--fontsize: .875rem;
|
||||
}
|
||||
|
||||
/* (the ::after is placed against the padding box, 34px inside a 1px border: 5px around it makes 44) */
|
||||
.btn-xs,
|
||||
:is(button, a).badge {
|
||||
--size: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
--fontsize: .875rem;
|
||||
}
|
||||
|
||||
:is(button, a).badge {
|
||||
padding-inline: .75rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
--tab-height: 2.75rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.input:not(.input-lg, .input-xl),
|
||||
.select:not(.select-lg, .select-xl),
|
||||
.file-input:not(.file-input-lg, .file-input-xl) {
|
||||
--size: 2.75rem;
|
||||
}
|
||||
|
||||
.checkbox,
|
||||
.radio {
|
||||
--size: 1.5rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
--size: 1.75rem;
|
||||
}
|
||||
|
||||
.range {
|
||||
--range-thumb-size: 1.75rem;
|
||||
}
|
||||
|
||||
.collapse-title,
|
||||
.label:has(> input[type=checkbox], > input[type=radio], > .toggle) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A sideways strip fades at the edge where more of it waits: the right edge at first, both while scrolled, the left
|
||||
at the end (a scroll-driven animation where the browser has one; without, the right edge only). */
|
||||
@property --fade-start {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --fade-end {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes edge-fade-x {
|
||||
from {
|
||||
--fade-start: 0px;
|
||||
--fade-end: 1.5rem;
|
||||
}
|
||||
|
||||
8% {
|
||||
--fade-start: 1.5rem;
|
||||
}
|
||||
|
||||
92% {
|
||||
--fade-end: 1.5rem;
|
||||
}
|
||||
|
||||
to {
|
||||
--fade-start: 1.5rem;
|
||||
--fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility edge-fade-x {
|
||||
mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: edge-fade-x linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ namespace decePubClient.E2E.Actions
|
||||
await reply.WaitForAsync(new() { Timeout = 15000 });
|
||||
await reply.Locator("footer [title='Other']").First.ClickAsync();
|
||||
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
|
||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ namespace decePubClient.E2E.Actions
|
||||
await choices.Nth(1).FillAsync("coffee");
|
||||
// and in the language picked
|
||||
await page.Locator("[data-compose-language]").First.SelectOptionAsync("it");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).Last.ClickAsync();
|
||||
// (the composer's own submit: a card's time is a "Post" button too, its nerd stats)
|
||||
await page.Locator("form:has(textarea[data-compose]) button[type=submit]").First.ClickAsync();
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
||||
"the poll was never published");
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace decePubClient.E2E.Actions
|
||||
(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue<DateTime>().ToUniversalTime()
|
||||
is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
|
||||
await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
|
||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
|
||||
await session.AssertGuards();
|
||||
|
||||
@@ -14,6 +14,13 @@ namespace decePubClient.E2E.Actions
|
||||
var persona = Pasture.Personas().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
// a deletion the stream tells (late federated Deletes from the tests before) takes its post out at once
|
||||
var deletions = 0;
|
||||
page.WebSocket += (_, socket) => socket.FrameReceived += (_, frame) =>
|
||||
{
|
||||
if (frame.Text?.Contains("\"event\":\"delete\"") == true)
|
||||
Interlocked.Increment(ref deletions);
|
||||
};
|
||||
await Results.Cell("client.action.feed-memory", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
@@ -25,6 +32,7 @@ namespace decePubClient.E2E.Actions
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = root.scrollHeight");
|
||||
await Pasture.Eventually(async () => int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") > 20, 10);
|
||||
var count = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
|
||||
var deletedBefore = Volatile.Read(ref deletions);
|
||||
|
||||
var homeReads = 0;
|
||||
page.Request += (_, request) =>
|
||||
@@ -33,12 +41,14 @@ namespace decePubClient.E2E.Actions
|
||||
homeReads++;
|
||||
};
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = 0");
|
||||
await page.Locator("article[data-uri] footer a[href^='expand/']").First.ClickAsync();
|
||||
await page.Locator("article[data-uri] footer a[title='Expand']").First.ClickAsync();
|
||||
await page.WaitForURLAsync("**/expand/**");
|
||||
await page.GoBackAsync();
|
||||
await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 10000 });
|
||||
|
||||
Assert.True(int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") >= count, "the feed came back shorter than it was left");
|
||||
var back = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
|
||||
var deleted = Volatile.Read(ref deletions) - deletedBefore;
|
||||
Assert.True(back >= count - deleted, $"the feed came back with {back} posts, left with {count} ({deleted} deleted meanwhile)");
|
||||
Assert.Equal(0, homeReads);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
|
||||
@@ -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}})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,18 @@ namespace decePubClient.E2E.Actions
|
||||
var text = $"streamed {Guid.NewGuid():N}"[..21];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
// the stream counts once the app has subscribed to the home feed on it (it opens after the first page is read,
|
||||
// later on a busy page)
|
||||
var subscribed = new TaskCompletionSource();
|
||||
page.WebSocket += (_, socket) =>
|
||||
{
|
||||
if (socket.Url.Contains("/api/v1/streaming"))
|
||||
socket.FrameSent += (_, frame) =>
|
||||
{
|
||||
if (frame.Text?.Contains("\"subscribe\"") == true && frame.Text.Contains("\"user\""))
|
||||
subscribed.TrySetResult();
|
||||
};
|
||||
};
|
||||
JsonNode post = null;
|
||||
try
|
||||
{
|
||||
@@ -24,10 +36,12 @@ namespace decePubClient.E2E.Actions
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
subscribed = new();
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
// the stream opens once the first page is shown
|
||||
await Task.Delay(1500);
|
||||
await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
// (and the server has taken the subscription in)
|
||||
await Task.Delay(500);
|
||||
|
||||
post = await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "unlisted" });
|
||||
var fresh = page.GetByRole(AriaRole.Button, new() { Name = "1 new" });
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace decePubClient.E2E.Actions
|
||||
await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-take-off-wall]").ClickAsync();
|
||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue<string>()}") is null, 15),
|
||||
"the post is still on PrivaPub");
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
|
||||
@@ -34,11 +34,12 @@ namespace decePubClient.E2E.Login
|
||||
await session.Page.Locator("nav [title='Switch persona']").First.ClickAsync();
|
||||
await session.Page.Locator("nav").GetByRole(AriaRole.Button).Filter(new() { HasText = sibling.Name ?? sibling.UserName }).First.ClickAsync();
|
||||
|
||||
// the sibling's home, read with another token than the first persona's (whose own home may still be loading when the
|
||||
// switch is asked for)
|
||||
string FirstToken() => session.ApiCalls.FirstOrDefault(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null).Authorization;
|
||||
Assert.True(await Pasture.Eventually(() => Task.FromResult(session.ApiCalls.Skip(before)
|
||||
.Any(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home")), 20), "the sibling's home was never read");
|
||||
var tokens = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null)
|
||||
.Select(c => c.Authorization).Distinct().ToList();
|
||||
Assert.True(tokens.Count >= 2, "both personas' requests went out with one token");
|
||||
.Any(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home" && c.Authorization is not null && c.Authorization != FirstToken())), 20),
|
||||
"the sibling's home was never read with its own token");
|
||||
await session.AssertGuards();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// What phones get stays on phones: at 1440x900 with a mouse the controls keep daisyUI's sizes, measured on probes made
|
||||
// with the app's own classes, and the nav menu stays open beside the feed.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class DesktopUnchangedTests(Browser browser)
|
||||
{
|
||||
const string Probe = """
|
||||
() => ["btn", "btn btn-sm", "btn btn-xs", "input", "input input-sm"].map(classes => {
|
||||
const el = document.createElement(classes.startsWith("input") ? "input" : "button")
|
||||
el.className = classes
|
||||
document.body.append(el)
|
||||
const height = el.getBoundingClientRect().height
|
||||
el.remove()
|
||||
return height
|
||||
})
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Desktop_keeps_its_sizes()
|
||||
{
|
||||
var persona = Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
|
||||
{
|
||||
Assert.False(await page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
|
||||
// btn, btn btn-sm, btn btn-xs, input, input input-sm
|
||||
var heights = await page.EvaluateAsync<double[]>(Probe);
|
||||
Assert.Equal([40, 32, 24, 40, 32], heights.Select(h => Math.Round(h)).ToArray());
|
||||
await Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync();
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures),
|
||||
// what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone
|
||||
// in tests/e2e/out/mobile/<run>/index.html, for the eye.
|
||||
public static class Fit
|
||||
{
|
||||
public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js"));
|
||||
|
||||
static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
|
||||
static readonly object SheetGate = new();
|
||||
|
||||
static string TestsRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output");
|
||||
}
|
||||
|
||||
// what a sweep found, each finding once however many screens showed it
|
||||
public sealed class Report
|
||||
{
|
||||
readonly HashSet<string> seen = [];
|
||||
public List<JsonObject> Overflow { get; } = [];
|
||||
public List<JsonObject> Beyond { get; } = [];
|
||||
public List<JsonObject> Popovers { get; } = [];
|
||||
public List<JsonObject> Small { get; } = [];
|
||||
public List<JsonObject> Tiny { get; } = [];
|
||||
public int Screens { get; set; }
|
||||
|
||||
public void Take(JsonNode measured, int screen)
|
||||
{
|
||||
Add(Overflow, measured["overflow"], "overflow", screen);
|
||||
Add(Beyond, measured["beyond"], "beyond", screen);
|
||||
Add(Popovers, measured["popovers"], "popover", screen);
|
||||
Add(Small, measured["small"], "small", screen);
|
||||
Add(Tiny, measured["tiny"], "tiny", screen);
|
||||
}
|
||||
|
||||
void Add(List<JsonObject> into, JsonNode found, string kind, int screen)
|
||||
{
|
||||
foreach (var item in found!.AsArray().Select(i => i!.AsObject()))
|
||||
{
|
||||
var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}";
|
||||
if (!seen.Add(key))
|
||||
continue;
|
||||
var copy = Clean(item.DeepClone()).AsObject();
|
||||
copy["screen"] = screen;
|
||||
into.Add(copy);
|
||||
}
|
||||
}
|
||||
|
||||
// Playwright marks the objects it hands back with $id; the findings don't need them
|
||||
static JsonNode Clean(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
obj.Remove("$id");
|
||||
foreach (var (_, value) in obj)
|
||||
Clean(value);
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
foreach (var value in array)
|
||||
Clean(value);
|
||||
return node;
|
||||
}
|
||||
|
||||
public string Json() => new JsonObject
|
||||
{
|
||||
["screens"] = Screens,
|
||||
["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray())
|
||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()");
|
||||
|
||||
public static async Task<Report> Sweep(IPage page, Support.Phone phone, string slug, int screens)
|
||||
{
|
||||
var dir = Path.Combine(RunDir, phone.Key, slug);
|
||||
Directory.CreateDirectory(dir);
|
||||
var report = new Report();
|
||||
await Settle(page);
|
||||
await page.EvaluateAsync("() => window.__fit.primary()");
|
||||
for (var screen = 0; screen < screens; screen++)
|
||||
{
|
||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("() => window.__fit.measure()")).GetRawText())!;
|
||||
report.Take(measured, screen);
|
||||
await page.ScreenshotAsync(new()
|
||||
{
|
||||
Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide
|
||||
});
|
||||
report.Screens = screen + 1;
|
||||
if (!await page.EvaluateAsync<bool>("() => window.__fit.step(0.8)"))
|
||||
break;
|
||||
await Settle(page);
|
||||
}
|
||||
await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json());
|
||||
ContactSheet();
|
||||
return report;
|
||||
}
|
||||
|
||||
// one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only
|
||||
public static async Task<Report> Now(IPage page, string scope = null)
|
||||
{
|
||||
await Settle(page);
|
||||
var report = new Report();
|
||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("scope => window.__fit.measure(scope)", scope)).GetRawText())!;
|
||||
report.Take(measured, 0);
|
||||
report.Screens = 1;
|
||||
return report;
|
||||
}
|
||||
|
||||
// nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole
|
||||
public static void AssertFits(Report report)
|
||||
{
|
||||
var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}")
|
||||
.Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}"))
|
||||
.Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}"))
|
||||
.ToList();
|
||||
Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12)));
|
||||
}
|
||||
|
||||
// every target a thumb can reach has a 44px hit area, and no control's text is under 12px
|
||||
public static void AssertTouchable(Report report)
|
||||
{
|
||||
var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}")
|
||||
.Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}"))
|
||||
.ToList();
|
||||
Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12)));
|
||||
}
|
||||
|
||||
// every route as a row, every phone as a column: the first screen and how many findings
|
||||
static void ContactSheet()
|
||||
{
|
||||
lock (SheetGate)
|
||||
{
|
||||
var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList();
|
||||
var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList();
|
||||
var html = new StringBuilder("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
|
||||
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
|
||||
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
|
||||
foreach (var phone in phones)
|
||||
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
|
||||
html.Append("</tr>");
|
||||
foreach (var route in routes)
|
||||
{
|
||||
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
|
||||
foreach (var phone in phones)
|
||||
{
|
||||
var dir = Path.Combine(RunDir, phone, route);
|
||||
html.Append("<td>");
|
||||
if (File.Exists(Path.Combine(dir, "violations.json")))
|
||||
{
|
||||
var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!;
|
||||
var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count;
|
||||
var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count;
|
||||
var link = $"{phone}/{route}";
|
||||
html.Append($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
|
||||
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
|
||||
.Append($"<a href=\"{link}/violations.json\">findings</a>");
|
||||
}
|
||||
html.Append("</td>");
|
||||
}
|
||||
html.Append("</tr>");
|
||||
}
|
||||
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// What a thumb does on the narrowest phone, step by step: every menu, sheet and dialog that opens is whole on the
|
||||
// screen and its targets have 44px hit areas, and each one closes the way a phone user expects. One cell each,
|
||||
// client.mobile.flow.*.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class FlowTests(Browser browser)
|
||||
{
|
||||
static readonly Phone Phone = Phone.Narrow360;
|
||||
|
||||
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
|
||||
static async Task Flow(Session session, string name, Func<IPage, Task> steps)
|
||||
{
|
||||
var page = session.Page;
|
||||
await Results.Cell($"client.mobile.flow.{name}", "privapub", Phone.Key, async () =>
|
||||
{
|
||||
await steps(page);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
// what opened is whole (not cut, inside the screen) and its targets are thumb-sized; the rest of the page is the
|
||||
// sweep's
|
||||
static async Task AssertWhole(IPage page, string scope)
|
||||
{
|
||||
var report = await Fit.Now(page, scope);
|
||||
Assert.True(report.Popovers.Count == 0, "cut or outside: " + string.Join("\n", report.Popovers.Select(p => p.ToJsonString())));
|
||||
Fit.AssertTouchable(report);
|
||||
}
|
||||
|
||||
static ILocator Card(IPage page, string has = null) =>
|
||||
(has is null ? page.Locator("#feed-scroll article[data-uri]") : page.Locator($"#feed-scroll article[data-uri]:has({has})")).First;
|
||||
|
||||
[Fact]
|
||||
public async Task The_menu_closes_after_a_choice_an_outside_tap_and_escape()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "nav", async page =>
|
||||
{
|
||||
var toggle = page.Locator("[data-nav-toggle]");
|
||||
var panel = page.Locator("[data-nav-panel]");
|
||||
await toggle.TapAsync();
|
||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
||||
await AssertWhole(page, "[data-nav-panel]");
|
||||
await panel.Locator("a[href='explore']").TapAsync();
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
|
||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
||||
|
||||
await toggle.TapAsync();
|
||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
||||
var size = page.ViewportSize!;
|
||||
await page.Touchscreen.TapAsync(size.Width - 12, size.Height - 40);
|
||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
||||
|
||||
await toggle.TapAsync();
|
||||
await Assertions.Expect(panel).ToBeVisibleAsync();
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await Assertions.Expect(panel).ToBeHiddenAsync();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_dialog_rises_from_the_bottom_and_closes_from_its_button()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "dialog", async page =>
|
||||
{
|
||||
var card = Card(page, "[data-liked-by]");
|
||||
if (await card.CountAsync() == 0)
|
||||
Assert.Skip("no post in the home feed has been liked");
|
||||
await card.ScrollIntoViewIfNeededAsync();
|
||||
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
|
||||
await AssertWhole(page, "[data-sheet]");
|
||||
await card.Locator("[data-liked-by]").TapAsync();
|
||||
var dialog = page.Locator(".modal-open .modal-box").First;
|
||||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
||||
await AssertWhole(page, ".modal-open .modal-box");
|
||||
var box = (await dialog.BoundingBoxAsync())!;
|
||||
Assert.True(box.Y + box.Height >= page.ViewportSize!.Height - 2, "the dialog doesn't rise from the bottom of the screen");
|
||||
await page.Locator(".modal-open [data-modal-close]").TapAsync();
|
||||
await Assertions.Expect(page.Locator(".modal-open")).ToHaveCountAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_post_is_written_in_a_sheet_from_a_scrolled_feed()
|
||||
{
|
||||
var persona = Persona();
|
||||
var text = $"from a phone {Guid.NewGuid():N}"[..24];
|
||||
await using var session = await SignIn.As(browser, persona, Phone);
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Flow(session, "compose", async page =>
|
||||
{
|
||||
await page.EvaluateAsync("() => document.getElementById('feed-scroll').scrollTo({ top: 1500, behavior: 'instant' })");
|
||||
await page.Locator("[data-compose-open]").TapAsync();
|
||||
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
|
||||
await Assertions.Expect(sheet).ToBeVisibleAsync();
|
||||
await AssertWhole(page, "[data-sheet]");
|
||||
Assert.True(await page.EvaluateAsync<bool>("() => document.activeElement?.matches('textarea[data-compose]') === true"),
|
||||
"the composer's words don't have the focus");
|
||||
await page.Keyboard.TypeAsync(text);
|
||||
await sheet.Locator("button[type=submit]").TapAsync();
|
||||
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
||||
"the post was never published");
|
||||
made = latest!["id"]!.GetValue<string>();
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_is_written_in_a_sheet()
|
||||
{
|
||||
var persona = Persona();
|
||||
var text = $"a phone reply {Guid.NewGuid():N}"[..24];
|
||||
await using var session = await SignIn.As(browser, persona, Phone);
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Flow(session, "reply", async page =>
|
||||
{
|
||||
var card = Card(page, "footer button[title='Reply']");
|
||||
await card.Locator("footer button[title='Reply']").TapAsync();
|
||||
var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First;
|
||||
await Assertions.Expect(sheet).ToBeVisibleAsync();
|
||||
await AssertWhole(page, "[data-sheet]");
|
||||
await sheet.Locator("textarea[data-compose]").FillAsync(text);
|
||||
await sheet.Locator("button[type=submit]").TapAsync();
|
||||
await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 });
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
||||
"the reply was never published");
|
||||
made = latest!["id"]!.GetValue<string>();
|
||||
Assert.NotNull(latest["in_reply_to_id"]?.GetValue<string>());
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reaction_is_picked_from_a_sheet()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "react", async page =>
|
||||
{
|
||||
var card = Card(page, "footer [title='React']");
|
||||
var uri = await card.GetAttributeAsync("data-uri");
|
||||
card = page.Locator($"#feed-scroll article[data-uri='{uri}']").First;
|
||||
await card.Locator("footer [title='React'] > [role=button]").TapAsync();
|
||||
await AssertWhole(page, "[data-sheet]");
|
||||
await card.Locator("[data-sheet] button", new() { HasText = "😮" }).First.TapAsync();
|
||||
var notice = page.Locator("[data-public-notice].modal-open");
|
||||
if (await notice.IsVisibleAsync() || await Pasture.Eventually(() => notice.IsVisibleAsync(), 2))
|
||||
{
|
||||
await AssertWhole(page, "[data-public-notice] .modal-box");
|
||||
await page.Locator("[data-public-notice-yes]").TapAsync();
|
||||
}
|
||||
var mine = card.Locator("[data-reactions] button.btn-active", new() { HasText = "😮" });
|
||||
await Assertions.Expect(mine).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
// and taken back
|
||||
await mine.TapAsync();
|
||||
await Assertions.Expect(mine).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_globe_opens_full_screen_from_its_banner_and_closes()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "globe", async page =>
|
||||
{
|
||||
await page.Locator("button[title='Globe']").TapAsync();
|
||||
var expand = page.Locator("[data-globe-expand]");
|
||||
await Assertions.Expect(expand).ToBeVisibleAsync();
|
||||
await expand.TapAsync();
|
||||
var full = page.Locator("aside[role=dialog]");
|
||||
await Assertions.Expect(full).ToBeVisibleAsync();
|
||||
var box = (await full.BoundingBoxAsync())!;
|
||||
var size = page.ViewportSize!;
|
||||
Assert.True(Math.Abs(box.Width - size.Width) < 2 && Math.Abs(box.Height - size.Height) < 2,
|
||||
$"the globe is {box.Width}x{box.Height}, not the screen's {size.Width}x{size.Height}");
|
||||
await AssertWhole(page, "aside[role=dialog]");
|
||||
await page.Locator("[data-globe-close]").TapAsync();
|
||||
await Assertions.Expect(full).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(expand).ToBeVisibleAsync();
|
||||
});
|
||||
}
|
||||
|
||||
// a phone held sideways keeps its feed: no banner squeezing it, the globe full screen from the tabs instead
|
||||
[Fact]
|
||||
public async Task A_phone_held_sideways_keeps_its_feed()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7Landscape);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.mobile.flow.landscape", "privapub", Phone.Pixel7Landscape.Key, async () =>
|
||||
{
|
||||
Assert.False(await page.Locator("[data-globe-expand]").IsVisibleAsync(), "the globe's banner squeezes the feed");
|
||||
var feedHeight = await page.EvaluateAsync<double>("() => document.getElementById('feed-scroll').clientHeight");
|
||||
Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
|
||||
Assert.True(await page.EvaluateAsync<bool>("() => document.scrollingElement.scrollWidth <= innerWidth + 1"), "the page scrolls sideways");
|
||||
await page.Locator("[data-globe-full]").TapAsync();
|
||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
|
||||
await page.Locator("[data-globe-close]").TapAsync();
|
||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_post_asks_first()
|
||||
{
|
||||
var persona = Persona();
|
||||
var text = $"to be deleted {Guid.NewGuid():N}"[..26];
|
||||
var made = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
||||
var id = made["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, persona, Phone);
|
||||
try
|
||||
{
|
||||
await Flow(session, "delete", async page =>
|
||||
{
|
||||
var card = page.Locator($"#feed-scroll article[data-uri='{made["uri"]}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
var more = card.Locator("footer [title='Other'] > [role=button]");
|
||||
await more.TapAsync();
|
||||
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
|
||||
var confirm = page.Locator("[data-confirm].modal-open");
|
||||
await Assertions.Expect(confirm).ToBeVisibleAsync();
|
||||
await AssertWhole(page, "[data-confirm] .modal-box");
|
||||
await page.Locator("[data-confirm-no]").TapAsync();
|
||||
await Assertions.Expect(confirm).ToHaveCountAsync(0);
|
||||
Assert.NotNull(await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"));
|
||||
|
||||
await more.TapAsync();
|
||||
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
|
||||
await page.Locator("[data-confirm-yes]").TapAsync();
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{id}") is null, 15), "the post is still there");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
// a tap on a post (in its margin, not on a link or a control) opens its thread
|
||||
[Fact]
|
||||
public async Task A_tap_on_a_post_opens_its_thread()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "thread", async page =>
|
||||
{
|
||||
var card = Card(page, "a[data-thread-link]");
|
||||
var box = (await card.BoundingBoxAsync())!;
|
||||
await card.TapAsync(new() { Position = new() { X = box.Width - 5, Y = box.Height * 0.35f } });
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath.StartsWith("/expand/"), new() { Timeout = 15000 });
|
||||
Assert.Empty(await page.Locator("[data-globe-pinned]").AllAsync());
|
||||
});
|
||||
}
|
||||
|
||||
// a picture opens in the app, the whole screen, and closes
|
||||
[Fact]
|
||||
public async Task A_picture_opens_in_the_app()
|
||||
{
|
||||
var expected = (await Pasture.Town("expect")).AsArray();
|
||||
var personas = Pasture.Personas();
|
||||
var item = expected.FirstOrDefault(i => i!["alts"]!.AsArray().Count > 0
|
||||
&& i["readers"]!.AsArray().Any(r => personas.Any(p => p.UserName == (string)r)));
|
||||
Assert.SkipWhen(item is null, "the town holds no post with pictures");
|
||||
var reader = personas.First(p => item!["readers"]!.AsArray().Any(r => (string)r == p.UserName));
|
||||
await using var session = await SignIn.As(browser, reader, Phone);
|
||||
await Flow(session, "picture", async page =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{item!["id"]}");
|
||||
var card = page.Locator($"article[data-uri='{item["uri"]}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" });
|
||||
for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++)
|
||||
await hidden.First.TapAsync();
|
||||
await card.Locator("button[data-picture]").First.TapAsync();
|
||||
var viewer = page.Locator("[data-media-viewer].modal-open .modal-box");
|
||||
await Assertions.Expect(viewer).ToBeVisibleAsync();
|
||||
var box = (await viewer.BoundingBoxAsync())!;
|
||||
var size = page.ViewportSize!;
|
||||
Assert.True(box.Width >= size.Width - 2 && box.Height >= size.Height - 2, $"the picture viewer is {box.Width}x{box.Height}");
|
||||
await AssertWhole(page, "[data-media-viewer] .modal-box");
|
||||
if (await page.Locator("[data-picture-next]:enabled").CountAsync() > 0)
|
||||
await page.Locator("[data-picture-next]").TapAsync();
|
||||
await page.Locator("[data-media-viewer] [data-modal-close]").TapAsync();
|
||||
await Assertions.Expect(page.Locator("[data-media-viewer].modal-open")).ToHaveCountAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
// the Back button closes what is open, one at a time, and stays on the page
|
||||
[Fact]
|
||||
public async Task Back_closes_what_is_open()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone);
|
||||
await Flow(session, "back", async page =>
|
||||
{
|
||||
var home = page.Url;
|
||||
// a page to go back to, so a Back that isn't held would leave the feed
|
||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
||||
await page.Locator("[data-nav-panel] a[href='explore']").TapAsync();
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
|
||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
||||
await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync();
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/");
|
||||
await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync();
|
||||
|
||||
// (history.back(), as the Back button does: a Back held to close an overlay loads nothing, which GoBackAsync would
|
||||
// wait for)
|
||||
async Task BackCloses(ILocator open, string what)
|
||||
{
|
||||
await Assertions.Expect(open).ToBeVisibleAsync();
|
||||
await page.EvaluateAsync("() => history.back()");
|
||||
await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 });
|
||||
Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}");
|
||||
}
|
||||
|
||||
await page.Locator("[data-nav-toggle]").TapAsync();
|
||||
await BackCloses(page.Locator("[data-nav-panel]"), "menu");
|
||||
|
||||
var card = Card(page, "footer [title='Other']");
|
||||
await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
|
||||
await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet");
|
||||
|
||||
await page.Locator("[data-compose-open]").TapAsync();
|
||||
await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer");
|
||||
|
||||
await page.Locator("button[title='Globe']").TapAsync();
|
||||
await page.Locator("[data-globe-expand]").TapAsync();
|
||||
await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe");
|
||||
|
||||
// with nothing open, Back leaves the feed as before
|
||||
await page.EvaluateAsync("() => history.back()");
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 });
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// Every page at every portrait phone: nothing scrolls sideways or lies past the screen, every open menu and dialog is
|
||||
// whole, every target has a 44px hit area and no control's text is under 12px. Two cells per page and phone,
|
||||
// client.mobile.fit.* and client.mobile.touch.*; the screenshots and findings go to tests/e2e/out/mobile/.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class RouteSweepTests(Browser browser)
|
||||
{
|
||||
public static TheoryData<string, string> Cases()
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
foreach (var phone in Phone.Portrait)
|
||||
foreach (var route in Route.All)
|
||||
data.Add(phone.Key, route.Slug);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public async Task Fits_a_phone(string phoneKey, string slug)
|
||||
{
|
||||
var phone = Phone.ByKey(phoneKey);
|
||||
var route = Route.Get(slug);
|
||||
var persona = route.Persona();
|
||||
await using var session = route.SignedOut ? await browser.Open(phone: phone) : await SignIn.As(browser, persona, phone);
|
||||
try
|
||||
{
|
||||
await route.Open(session.Page, persona);
|
||||
}
|
||||
catch (Exception e) when (e is not Xunit.Sdk.SkipException)
|
||||
{
|
||||
await SignIn.Evidence(session.Page, $"open-{phone.Key}-{slug}");
|
||||
throw;
|
||||
}
|
||||
var report = await Fit.Sweep(session.Page, phone, slug, route.Screens);
|
||||
if (!route.Gating)
|
||||
return;
|
||||
|
||||
var problems = new List<string>();
|
||||
foreach (var (feature, check) in new (string, Action<Fit.Report>)[]
|
||||
{
|
||||
($"client.mobile.fit.{slug}", Fit.AssertFits), ($"client.mobile.touch.{slug}", Fit.AssertTouchable)
|
||||
})
|
||||
try
|
||||
{
|
||||
await Results.Cell(feature, "privapub", phone.Key, () =>
|
||||
{
|
||||
check(report);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
problems.Add(e.Message);
|
||||
}
|
||||
await session.AssertGuards();
|
||||
Assert.True(problems.Count == 0, string.Join("\n\n", problems));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// The pages the phone sweep visits, each opened as a reader would get there: signed in as a town persona (the one with
|
||||
// sibling personas, unless the page shows another's post), or signed out for the login page. Screens is how far down
|
||||
// the page's main scroller the sweep measures.
|
||||
public sealed record Route(string Slug, Func<IPage, Pasture.Persona, Task> Open, Func<Pasture.Persona> Reader = null,
|
||||
int Screens = 8, bool SignedOut = false, bool Gating = true)
|
||||
{
|
||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
||||
|
||||
static Pasture.Persona Sweeper() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
|
||||
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
||||
|
||||
public Pasture.Persona Persona() => (Reader ?? Sweeper)();
|
||||
|
||||
public static readonly Route[] All =
|
||||
[
|
||||
new("feed-home", (_, _) => Task.CompletedTask),
|
||||
new("feed-local", Tab(1), Screens: 6),
|
||||
new("feed-federated", Tab(2), Screens: 6),
|
||||
new("feed-nearby", Tab(3), Screens: 4),
|
||||
new("feed-groups", Tab(4), Screens: 6),
|
||||
new("feed-direct", Tab(5), Screens: 6),
|
||||
new("feed-notifications", Tab(6), Screens: 6),
|
||||
new("feed-favourites", Tab(7), Screens: 4),
|
||||
new("feed-bookmarks", Tab(8), Screens: 4),
|
||||
new("feed-lists", Tab(9), Screens: 4),
|
||||
Thread("thread-poll", i => i["poll"] is JsonArray),
|
||||
Thread("thread-pictures", i => i["alts"]!.AsArray().Count > 0),
|
||||
Thread("thread-titled", i => i["title"] is not null),
|
||||
Thread("thread-quote", i => i["quote"] is not null),
|
||||
Thread("thread-warning", i => i["cw"] is not null),
|
||||
Thread("thread-replies", i => (string)i["kind"] == "thread"),
|
||||
new("profile-own", async (page, persona) => await Visit(page, $"/account/{persona.LocalId}"), Screens: 10),
|
||||
new("profile-followed", async (page, persona) =>
|
||||
{
|
||||
var followed = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=1"))?.AsArray().FirstOrDefault();
|
||||
if (followed is null)
|
||||
Assert.Skip("the persona follows nobody");
|
||||
await Visit(page, $"/account/{followed["id"]}");
|
||||
}, Screens: 10),
|
||||
new("search", async (page, persona) =>
|
||||
{
|
||||
var other = Pasture.Personas().First(p => p.Root != persona.Root);
|
||||
await Visit(page, $"/search?q={Uri.EscapeDataString(other.UserName)}");
|
||||
}),
|
||||
new("explore-hashtags", async (page, _) => await Visit(page, "/explore")),
|
||||
new("explore-posts", ExploreTab(1)),
|
||||
new("explore-links", ExploreTab(2)),
|
||||
new("explore-people", ExploreTab(3)),
|
||||
new("explore-followed", ExploreTab(4)),
|
||||
new("lists", async (page, _) => await Visit(page, "/lists")),
|
||||
new("groups", async (page, _) => await Visit(page, "/groups")),
|
||||
new("filters", async (page, _) => await Visit(page, "/filters")),
|
||||
new("scheduled", async (page, _) => await Visit(page, "/scheduled")),
|
||||
new("notifications-filtered", async (page, _) => await Visit(page, "/notifications/filtered")),
|
||||
new("settings", async (page, _) =>
|
||||
{
|
||||
await Visit(page, "/settings");
|
||||
await OpenEveryCollapse(page);
|
||||
}, Screens: 30),
|
||||
new("administration", async (page, _) =>
|
||||
{
|
||||
await Visit(page, "/administration");
|
||||
await OpenEveryCollapse(page);
|
||||
}, () => Admin, Screens: 30),
|
||||
new("login", async (page, _) => await Visit(page, "/login"), SignedOut: true),
|
||||
new("styleguide", async (page, _) => await Visit(page, "/styleguide"), SignedOut: true, Screens: 40, Gating: false)
|
||||
];
|
||||
|
||||
public static Route Get(string slug) => All.Single(r => r.Slug == slug);
|
||||
|
||||
static async Task Visit(IPage page, string path)
|
||||
{
|
||||
await page.GotoAsync(Pasture.Site + path);
|
||||
await Fit.Settle(page);
|
||||
}
|
||||
|
||||
// the feed's tab at this place: Home, Local, Federation, Nearby, Groups, Direct, Notifications, Favourites,
|
||||
// Bookmarks, Lists (FeedTabs.Always)
|
||||
static Func<IPage, Pasture.Persona, Task> Tab(int index) => async (page, _) =>
|
||||
{
|
||||
await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
|
||||
await Fit.Settle(page);
|
||||
};
|
||||
|
||||
static Func<IPage, Pasture.Persona, Task> ExploreTab(int index) => async (page, _) =>
|
||||
{
|
||||
await Visit(page, "/explore");
|
||||
await page.Locator("[role=tablist]").First.Locator("[role=tab]").Nth(index).TapAsync();
|
||||
await Fit.Settle(page);
|
||||
};
|
||||
|
||||
// a town post of the kind wanted, opened as its thread by a persona it was for
|
||||
static Route Thread(string slug, Func<JsonNode, bool> wanted)
|
||||
{
|
||||
JsonNode Pick() => Expected.Value.FirstOrDefault(i => wanted(i!) && ReaderOf(i) is not null);
|
||||
return new(slug, async (page, _) =>
|
||||
{
|
||||
if (Pick() is not { } item)
|
||||
{
|
||||
Assert.Skip($"the town holds no post for {slug}");
|
||||
return;
|
||||
}
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{item["id"]}");
|
||||
await page.Locator($"article[data-uri='{item["uri"]}']").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Fit.Settle(page);
|
||||
}, () => Pick() is { } item ? ReaderOf(item) : Sweeper(), Screens: 10);
|
||||
}
|
||||
|
||||
static Pasture.Persona ReaderOf(JsonNode item)
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
return item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == (string)r)).FirstOrDefault(p => p is not null);
|
||||
}
|
||||
|
||||
static async Task OpenEveryCollapse(IPage page)
|
||||
{
|
||||
var closed = page.Locator(".collapse-title[aria-expanded=false]");
|
||||
for (var i = 0; i < 20 && await closed.CountAsync() > 0; i++)
|
||||
{
|
||||
await closed.First.TapAsync();
|
||||
await Fit.Settle(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// The phone tests' measurements (Mobile/Fit.cs), added to every page of a phone session as an init script. Each call
|
||||
// returns plain data, so no element handle outlives a scroll: Virtualize recycles the feed's cards.
|
||||
// settle() waits for fonts, for the page to stop changing, two frames and the pictures in view
|
||||
// primary() marks the page's main scroller, the one the sweep steps through
|
||||
// step(f) scrolls it by f of its height; false at its end
|
||||
// measure(s) what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text;
|
||||
// with a selector, the targets inside what it matches only (an open menu or dialog)
|
||||
window.__fit = (() => {
|
||||
const scrolls = value => value === "auto" || value === "scroll"
|
||||
const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
let styles = new Map()
|
||||
const style = el => {
|
||||
let found = styles.get(el)
|
||||
if (!found)
|
||||
styles.set(el, found = getComputedStyle(el))
|
||||
return found
|
||||
}
|
||||
|
||||
// a short name for an element: its id, else its first data-* attribute, else tags and classes up to three levels
|
||||
function name(el, depth = 0) {
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body)
|
||||
return "document"
|
||||
if (el.id)
|
||||
return "#" + el.id
|
||||
const data = [...el.attributes].find(a => a.name.startsWith("data-") && !a.name.startsWith("data-fit"))
|
||||
const own = el.tagName.toLowerCase() + (data ? `[${data.name}]` : [...el.classList].slice(0, 3).map(c => "." + c).join(""))
|
||||
const parent = el.parentElement
|
||||
return data || depth >= 2 || !parent || parent === document.body ? own : `${name(parent, depth + 1)} > ${own}`
|
||||
}
|
||||
|
||||
const text = el => (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || "")
|
||||
.trim().replace(/\s+/g, " ").slice(0, 40)
|
||||
|
||||
// the card an element is in (its ActivityPub id), for finding it again
|
||||
const card = el => el.closest("[data-uri]")?.getAttribute("data-uri") ?? null
|
||||
|
||||
function describe(el, box) {
|
||||
return {
|
||||
element: name(el), text: text(el), card: card(el),
|
||||
box: [box.left, box.top, box.width, box.height].map(Math.round)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollers() {
|
||||
const found = [document.scrollingElement]
|
||||
for (const el of document.body.querySelectorAll("*")) {
|
||||
const s = style(el)
|
||||
if ((scrolls(s.overflowX) || scrolls(s.overflowY)) && el.clientWidth > 0 && el.clientHeight > 0 && shown(el))
|
||||
found.push(el)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// the part of an element's box that can be seen: cut by every ancestor that clips, up to the screen for a fixed one
|
||||
function visibleRect(el) {
|
||||
const own = el.getBoundingClientRect()
|
||||
const box = { left: own.left, top: own.top, right: own.right, bottom: own.bottom }
|
||||
if (style(el).position !== "fixed")
|
||||
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
||||
const s = style(a)
|
||||
if (s.overflowX !== "visible" || s.overflowY !== "visible") {
|
||||
const clip = a.getBoundingClientRect()
|
||||
if (s.overflowX !== "visible") {
|
||||
box.left = Math.max(box.left, clip.left)
|
||||
box.right = Math.min(box.right, clip.right)
|
||||
}
|
||||
if (s.overflowY !== "visible") {
|
||||
box.top = Math.max(box.top, clip.top)
|
||||
box.bottom = Math.min(box.bottom, clip.bottom)
|
||||
}
|
||||
}
|
||||
if (s.position === "fixed")
|
||||
break
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// the part of the screen an element's clipping ancestors let through: where a tap can reach it at all
|
||||
function clipOf(el) {
|
||||
const box = { left: 0, top: 0, right: innerWidth, bottom: innerHeight }
|
||||
if (style(el).position === "fixed")
|
||||
return box
|
||||
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
||||
const s = style(a)
|
||||
if (s.overflowX !== "visible" || s.overflowY !== "visible") {
|
||||
const clip = a.getBoundingClientRect()
|
||||
box.left = Math.max(box.left, clip.left)
|
||||
box.right = Math.min(box.right, clip.right)
|
||||
box.top = Math.max(box.top, clip.top)
|
||||
box.bottom = Math.min(box.bottom, clip.bottom)
|
||||
}
|
||||
if (s.position === "fixed")
|
||||
break
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// an element inside a nested clipping box (a sideways strip, a truncated name) doesn't widen the scroller holding it
|
||||
function clippedWithin(el, scroller) {
|
||||
for (let a = el.parentElement; a && a !== scroller; a = a.parentElement)
|
||||
if (style(a).overflowX !== "visible")
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// sideways scroll in the document or in a scroller that isn't meant to scroll sideways (data-scroll-x), with the
|
||||
// element reaching furthest right
|
||||
function overflow() {
|
||||
const found = []
|
||||
for (const scroller of scrollers()) {
|
||||
if (scroller.hasAttribute?.("data-scroll-x"))
|
||||
continue
|
||||
const extra = scroller.scrollWidth - scroller.clientWidth
|
||||
if (extra <= 1)
|
||||
continue
|
||||
const limit = scroller === document.scrollingElement ? innerWidth : scroller.getBoundingClientRect().right
|
||||
let worst = null, right = limit + 0.5
|
||||
for (const el of scroller.querySelectorAll("*")) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.right > right && !el.closest("[data-scroll-x]") && !clippedWithin(el, scroller)) {
|
||||
worst = el
|
||||
right = r.right
|
||||
}
|
||||
}
|
||||
found.push({
|
||||
element: name(scroller), extra: Math.round(extra),
|
||||
culprit: worst ? { ...describe(worst, worst.getBoundingClientRect()), past: Math.round(right - limit) } : null
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// what can be seen of it lies past the screen's sides: the outermost such element only
|
||||
function beyond() {
|
||||
const found = [], reported = []
|
||||
for (const el of document.body.querySelectorAll("*")) {
|
||||
if (found.length >= 30)
|
||||
break
|
||||
if (el.closest("[data-scroll-x]") !== null && !el.hasAttribute("data-scroll-x"))
|
||||
continue
|
||||
const own = el.getBoundingClientRect()
|
||||
if (own.width <= 1 && own.height <= 1 || reported.some(r => r.contains(el)) || !shown(el))
|
||||
continue
|
||||
const seen = visibleRect(el)
|
||||
if (seen.right - seen.left <= 0.5 || seen.bottom - seen.top <= 0.5)
|
||||
continue
|
||||
if (seen.right > innerWidth + 1 || seen.left < -1) {
|
||||
reported.push(el)
|
||||
found.push(describe(el, own))
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// open menus, dialogs, sheets and tips must be whole: inside the screen and not cut by what holds them
|
||||
function popovers() {
|
||||
const found = []
|
||||
for (const el of document.querySelectorAll(".dropdown-content, .modal-open > .modal-box, [data-sheet], [data-nav-panel], .tooltip-content")) {
|
||||
if (!shown(el))
|
||||
continue
|
||||
const own = el.getBoundingClientRect()
|
||||
if (own.width < 1 || own.height < 1)
|
||||
continue
|
||||
const seen = visibleRect(el)
|
||||
const outside = own.left < -1 || own.right > innerWidth + 1 || own.top < -1 || own.bottom > innerHeight + 1
|
||||
const cut = seen.left > own.left + 1 || seen.right < own.right - 1 || seen.top > own.top + 1 || seen.bottom < own.bottom - 1
|
||||
if (outside || cut)
|
||||
found.push({ ...describe(el, own), outside, cut })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const candidates = "a[href], button, select, textarea, summary, [role=button], [role=tab], [role=link], [tabindex='0'], "
|
||||
+ "input:not([type=hidden]), label:has(> input[type=checkbox]), label:has(> input[type=radio]), label:has(> .toggle)"
|
||||
|
||||
function exempt(el) {
|
||||
return el.tagName === "A" && el.closest(".post-content") !== null
|
||||
|| el.matches("video, audio, :disabled, [aria-disabled=true], .btn-disabled, .modal-backdrop > button")
|
||||
|| el.closest("[aria-hidden=true], [data-touch-exempt], [inert]") !== null
|
||||
// a checkbox, radio or toggle inside a label is measured as its label
|
||||
|| el.matches("input[type=checkbox], input[type=radio]") && el.closest("label") !== null
|
||||
}
|
||||
|
||||
// a control floating over the page (the round New post button) covers what scrolls under it, by design: a probe that
|
||||
// lands on one says nothing about the target's own room
|
||||
function floating(hit, el) {
|
||||
for (let a = hit; a && a !== document.body; a = a.parentElement)
|
||||
if (style(a).position === "fixed")
|
||||
return !a.contains(el)
|
||||
return false
|
||||
}
|
||||
|
||||
// a tap at this point reaches the element: on it, inside it, on its label's control, or on a label holding it
|
||||
function reaches(el, x, y) {
|
||||
const hit = document.elementFromPoint(x, y)
|
||||
if (!hit)
|
||||
return false
|
||||
if (hit === el || el.contains(hit))
|
||||
return true
|
||||
if (el.tagName === "LABEL" && el.control && (hit === el.control || el.control.contains(hit)))
|
||||
return true
|
||||
const label = hit.closest("label")
|
||||
return label !== null && label.contains(el)
|
||||
}
|
||||
|
||||
// a target needs a 44x44 CSS px hit area (Apple's guideline, WCAG 2.5.5): the centre and four points 21px away must
|
||||
// reach it, so a neighbour reaching into that square fails it as much as a small box does. Only targets a tap can reach
|
||||
// now are judged; one under an overlay or out of view is judged at another step
|
||||
function targets(scope) {
|
||||
const small = [], tiny = []
|
||||
const within = scope ? [...document.querySelectorAll(scope)].flatMap(s => [...s.querySelectorAll(candidates)]) : document.querySelectorAll(candidates)
|
||||
for (const el of within) {
|
||||
if (exempt(el) || !shown(el))
|
||||
continue
|
||||
const own = el.getBoundingClientRect()
|
||||
if (own.width < 1 || own.height < 1)
|
||||
continue
|
||||
const x = own.left + own.width / 2, y = own.top + own.height / 2
|
||||
if (x < 0 || x >= innerWidth || y < 0 || y >= innerHeight)
|
||||
continue
|
||||
const seen = visibleRect(el)
|
||||
if (x < seen.left || x > seen.right || y < seen.top || y > seen.bottom || !reaches(el, x, y))
|
||||
continue
|
||||
if (el.matches("input[type=range]")) {
|
||||
if (own.height < 28 || own.width < 120)
|
||||
small.push({ ...describe(el, own), failed: ["range"] })
|
||||
continue
|
||||
}
|
||||
// a probe beyond what the scroller shows (a card half under the feed's top edge) can't be tapped there anyway
|
||||
const clip = clipOf(el)
|
||||
const failed = [], hit = {}
|
||||
for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) {
|
||||
const px = Math.min(clip.right - 1, Math.max(clip.left, x + dx)), py = Math.min(clip.bottom - 1, Math.max(clip.top, y + dy))
|
||||
if (!reaches(el, px, py)) {
|
||||
const other = document.elementFromPoint(px, py)
|
||||
if (other && floating(other, el))
|
||||
continue
|
||||
failed.push(side)
|
||||
hit[side] = other ? name(other) : null
|
||||
}
|
||||
}
|
||||
if (failed.length > 0)
|
||||
small.push({ ...describe(el, own), failed, hit })
|
||||
if (text(el) && el.innerText?.trim() && parseFloat(style(el).fontSize) < 12)
|
||||
tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
|
||||
}
|
||||
return { small, tiny }
|
||||
}
|
||||
|
||||
function measure(scope) {
|
||||
styles = new Map()
|
||||
const { small, tiny } = targets(scope)
|
||||
const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny }
|
||||
styles = new Map()
|
||||
return result
|
||||
}
|
||||
|
||||
function primary() {
|
||||
styles = new Map()
|
||||
document.querySelectorAll("[data-fit-primary]").forEach(e => e.removeAttribute("data-fit-primary"))
|
||||
let best = null, area = 0
|
||||
for (const el of scrollers()) {
|
||||
if (el.scrollHeight <= el.clientHeight + 4)
|
||||
continue
|
||||
const size = el.clientWidth * el.clientHeight
|
||||
if (size > area) {
|
||||
best = el
|
||||
area = size
|
||||
}
|
||||
}
|
||||
best ??= document.scrollingElement
|
||||
best.setAttribute("data-fit-primary", "")
|
||||
styles = new Map()
|
||||
return name(best)
|
||||
}
|
||||
|
||||
function step(fraction) {
|
||||
const el = document.querySelector("[data-fit-primary]") ?? document.scrollingElement
|
||||
const before = el.scrollTop
|
||||
el.scrollTo({ top: before + el.clientHeight * fraction, behavior: "instant" })
|
||||
return el.scrollTop > before + 1
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await document.fonts.ready
|
||||
await new Promise(resolve => {
|
||||
let timer
|
||||
const done = () => {
|
||||
observer.disconnect()
|
||||
clearTimeout(timer)
|
||||
clearTimeout(cap)
|
||||
resolve()
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(done, 400)
|
||||
})
|
||||
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true })
|
||||
timer = setTimeout(done, 400)
|
||||
const cap = setTimeout(done, 8000)
|
||||
})
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const until = Date.now() + 3000
|
||||
const inView = img => {
|
||||
const r = img.getBoundingClientRect()
|
||||
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth
|
||||
}
|
||||
while (Date.now() < until && [...document.images].some(img => inView(img) && !img.complete))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
return { settle, primary, step, measure }
|
||||
})()
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Render
|
||||
{
|
||||
// The globe's instruments (wwwroot/js/instruments.js): drawn in the desktop column and in a phone's full screen with
|
||||
// the Sun where it should be, only the day/night line in a phone's banner, and an idle globe that draws nothing.
|
||||
// Screenshots for the eye go to tests/e2e/out/globe/.
|
||||
public sealed class GlobeTests(Browser browser)
|
||||
{
|
||||
static readonly string OutDir = Path.Combine(Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.FullName, "out", "globe");
|
||||
|
||||
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
|
||||
static async Task<JsonElement> Stats(IPage page) =>
|
||||
await page.EvaluateAsync<JsonElement>("async () => (await import('./js/globe.js')).stats()");
|
||||
|
||||
static async Task Live(IPage page)
|
||||
{
|
||||
await page.Locator("[data-globe-state=live]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).TryGetProperty("instruments", out var i) && i.ValueKind == JsonValueKind.Object, 15),
|
||||
"the instruments never reported");
|
||||
}
|
||||
|
||||
static async Task Photo(IPage page, ILocator what, string name)
|
||||
{
|
||||
Directory.CreateDirectory(OutDir);
|
||||
await page.WaitForTimeoutAsync(1500);
|
||||
await what.ScreenshotAsync(new() { Path = Path.Combine(OutDir, name + ".png") });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_desktop_column_draws_the_instruments_with_the_sun_in_place()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona());
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.globe", "privapub", "desktop", async () =>
|
||||
{
|
||||
await Live(page);
|
||||
// a located post, written here from Milano and read back in Nearby, pinned: the globe shows its moment, its
|
||||
// place and its day
|
||||
await page.Context.GrantPermissionsAsync(["geolocation"]);
|
||||
await page.Context.SetGeolocationAsync(new() { Latitude = 45.4642f, Longitude = 9.19f });
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
var text = $"the globe's instruments {Guid.NewGuid():N}"[..32];
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await page.Locator("button[title='Located post']").ClickAsync();
|
||||
var submit = page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
||||
await Assertions.Expect(submit).ToBeEnabledAsync();
|
||||
await submit.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("textarea[data-compose]")).ToHaveCountAsync(0);
|
||||
await page.Locator("[role=tab][title='Nearby']").ClickAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Use my position" }).ClickAsync();
|
||||
var card = page.Locator("#feed-scroll article[data-lat][data-post-at]", new() { HasText = text }).First;
|
||||
await card.WaitForAsync(new() { Timeout = 20000 });
|
||||
// the view flies to it first (with the pointer off the feed: a hovered post holds the view), then the card is pinned
|
||||
await page.Mouse.MoveAsync(2, 2);
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var view = await Stats(page);
|
||||
return view.GetProperty("centroidLatitude").ValueKind == JsonValueKind.Number
|
||||
&& Math.Abs(view.GetProperty("latitude").GetDouble() - view.GetProperty("centroidLatitude").GetDouble()) < 1;
|
||||
}, 10), "the globe never looked at the located post");
|
||||
var box = (await card.BoundingBoxAsync())!;
|
||||
await card.ClickAsync(new() { Position = new() { X = box.Width - 6, Y = box.Height * 0.35f } });
|
||||
await Assertions.Expect(page.Locator("[data-globe-pinned]")).ToHaveCountAsync(1);
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("chips").GetInt32() >= 6, 10),
|
||||
"the pinned post's instruments have few chips");
|
||||
var stats = await Stats(page);
|
||||
var instruments = stats.GetProperty("instruments");
|
||||
Assert.True(instruments.GetProperty("built").GetBoolean(), $"the instruments weren't built: {instruments}");
|
||||
Assert.Equal("full", instruments.GetProperty("detail").GetString());
|
||||
Assert.True(instruments.GetProperty("subsolarError").GetDouble() < 0.01, $"the Sun is drawn {instruments.GetProperty("subsolarError")} units off");
|
||||
var globe = page.Locator("aside").First;
|
||||
await Photo(page, globe, "desktop-light");
|
||||
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop.json"), (await Stats(page)).ToString());
|
||||
await page.EvaluateAsync("() => document.documentElement.setAttribute('data-theme', 'neo-dark')");
|
||||
await Photo(page, globe, "desktop-dark");
|
||||
// turned to the subsolar point, by dragging (about 3 px a degree; dragging left looks east, up looks south), from a
|
||||
// little west and north of it, so that its ray stands out: the Sun's own instruments
|
||||
var canvas = (await globe.Locator("canvas").First.BoundingBoxAsync())!;
|
||||
var (cx, cy) = (canvas.X + canvas.Width / 2, canvas.Y + canvas.Height / 2);
|
||||
for (var step = 0; step < 8; step++)
|
||||
{
|
||||
var now = await Stats(page);
|
||||
var dLng = (now.GetProperty("sun").GetProperty("longitude").GetDouble() - 18 - now.GetProperty("longitude").GetDouble() + 540) % 360 - 180;
|
||||
var dLat = now.GetProperty("sun").GetProperty("latitude").GetDouble() + 12 - now.GetProperty("latitude").GetDouble();
|
||||
if (Math.Abs(dLng) < 8 && Math.Abs(dLat) < 8)
|
||||
break;
|
||||
await page.Mouse.MoveAsync(cx, cy);
|
||||
await page.Mouse.DownAsync();
|
||||
await page.Mouse.MoveAsync(cx - (float)Math.Clamp(dLng * 3, -200, 200), cy + (float)Math.Clamp(dLat * 3, -200, 200), new() { Steps = 10 });
|
||||
await page.Mouse.UpAsync();
|
||||
await page.WaitForTimeoutAsync(800);
|
||||
}
|
||||
await Photo(page, globe, "desktop-sun");
|
||||
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop-sun.json"), (await Stats(page)).ToString());
|
||||
// and taken back by its author, the one thing a located post's card offers
|
||||
await card.Locator("[data-located-delete]").ClickAsync();
|
||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_phone_banner_draws_little_and_its_full_screen_everything()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.globe", "privapub", "phone", async () =>
|
||||
{
|
||||
await page.Locator("button[title='Globe']").TapAsync();
|
||||
await Live(page);
|
||||
Assert.Equal("banner", (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString());
|
||||
await page.Locator("[data-globe-expand]").TapAsync();
|
||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "full", 10),
|
||||
"the full screen globe has the banner's instruments");
|
||||
// the camera moves back for a screen taller than wide, so the globe fits across it
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("altitude").GetDouble() > 3.5, 10),
|
||||
"the full screen globe kept the banner's camera");
|
||||
Assert.True((await Stats(page)).GetProperty("instruments").GetProperty("built").GetBoolean(), "the instruments weren't built");
|
||||
await Photo(page, page.Locator("aside[role=dialog]"), "phone-full");
|
||||
// and closed, the banner again
|
||||
await page.Locator("[data-globe-close]").TapAsync();
|
||||
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "banner", 10),
|
||||
"the banner kept the full screen's instruments");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
// with reduced motion nothing turns or flies: once settled, no frame is drawn, the instruments' neither
|
||||
[Fact]
|
||||
public async Task An_idle_globe_draws_nothing()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.globe-idle", "privapub", "phone", async () =>
|
||||
{
|
||||
await page.Locator("button[title='Globe']").TapAsync();
|
||||
await page.Locator("[data-globe-expand]").TapAsync();
|
||||
await Live(page);
|
||||
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("idle").GetBoolean(), 20), "the globe never settled");
|
||||
var before = await Stats(page);
|
||||
await page.WaitForTimeoutAsync(3000);
|
||||
var after = await Stats(page);
|
||||
Assert.Equal(before.GetProperty("frame").GetInt32(), after.GetProperty("frame").GetInt32());
|
||||
Assert.Equal(before.GetProperty("instruments").GetProperty("frames").GetInt32(), after.GetProperty("instruments").GetProperty("frames").GetInt32());
|
||||
Assert.Equal(0, after.GetProperty("fps").GetInt32());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,21 +28,44 @@ namespace decePubClient.E2E.Support
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
|
||||
public async Task<Session> Open(string storageState = null)
|
||||
// a desktop window of 1440x900, or a phone: touch, a coarse pointer, its size and scale, reduced motion so nothing is
|
||||
// measured halfway through a transition, and the mobile tests' measuring script (Mobile/fit.js) on every page
|
||||
public async Task<Session> Open(string storageState = null, Phone phone = null)
|
||||
{
|
||||
var context = await Chromium.NewContextAsync(new()
|
||||
var options = phone is null
|
||||
? new BrowserNewContextOptions { ViewportSize = new() { Width = 1440, Height = 900 } }
|
||||
: new BrowserNewContextOptions(_playwright.Devices[phone.Preset]) { ReducedMotion = ReducedMotion.Reduce };
|
||||
if (phone is { Width: > 0 })
|
||||
{
|
||||
IgnoreHTTPSErrors = true,
|
||||
Locale = "en-GB",
|
||||
TimezoneId = "Europe/Rome",
|
||||
ViewportSize = new() { Width = 1440, Height = 900 },
|
||||
ServiceWorkers = ServiceWorkerPolicy.Block,
|
||||
StorageStatePath = storageState
|
||||
});
|
||||
return new Session(context, await context.NewPageAsync());
|
||||
options.ViewportSize = new() { Width = phone.Width, Height = phone.Height };
|
||||
options.ScreenSize = new() { Width = phone.Width, Height = phone.Height };
|
||||
}
|
||||
options.IgnoreHTTPSErrors = true;
|
||||
options.Locale = "en-GB";
|
||||
options.TimezoneId = "Europe/Rome";
|
||||
options.ServiceWorkers = ServiceWorkerPolicy.Block;
|
||||
options.StorageStatePath = storageState;
|
||||
var context = await Chromium.NewContextAsync(options);
|
||||
if (phone is not null)
|
||||
await context.AddInitScriptAsync(Mobile.Fit.Script);
|
||||
return new Session(context, await context.NewPageAsync(), phone);
|
||||
}
|
||||
}
|
||||
|
||||
// A phone the tests hold: one of Playwright's device presets (they ship inside its package, nothing is downloaded),
|
||||
// at its own size or at another one
|
||||
public sealed record Phone(string Key, string Preset, int Width = 0, int Height = 0)
|
||||
{
|
||||
public static readonly Phone Narrow360 = new("narrow360", "Pixel 7", 360, 800);
|
||||
public static readonly Phone IPhoneSE3 = new("iphone-se3", "iPhone SE (3rd gen)");
|
||||
public static readonly Phone Pixel7 = new("pixel7", "Pixel 7");
|
||||
public static readonly Phone Pixel7Landscape = new("pixel7-landscape", "Pixel 7 landscape");
|
||||
|
||||
public static readonly Phone[] Portrait = [Narrow360, IPhoneSE3, Pixel7];
|
||||
|
||||
public static Phone ByKey(string key) => new[] { Narrow360, IPhoneSE3, Pixel7, Pixel7Landscape }.Single(p => p.Key == key);
|
||||
}
|
||||
|
||||
// A browser tab and what it did: every request's host, every console error, every 5xx and Blazor's error bar. The
|
||||
// guards hold for every test: the app talks only to its own site and to PrivaPub, and never fails silently.
|
||||
public sealed class Session : IAsyncDisposable
|
||||
@@ -54,10 +77,13 @@ namespace decePubClient.E2E.Support
|
||||
public List<string> ServerErrors { get; } = [];
|
||||
public List<(string Url, string Authorization)> ApiCalls { get; } = [];
|
||||
|
||||
public Session(IBrowserContext context, IPage page)
|
||||
public Phone Phone { get; }
|
||||
|
||||
public Session(IBrowserContext context, IPage page, Phone phone = null)
|
||||
{
|
||||
Context = context;
|
||||
Page = page;
|
||||
Phone = phone;
|
||||
page.Request += (_, request) =>
|
||||
{
|
||||
var uri = new Uri(request.Url);
|
||||
@@ -96,6 +122,15 @@ namespace decePubClient.E2E.Support
|
||||
Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length));
|
||||
}
|
||||
|
||||
// a phone session that answers like a desktop would test desktop sizes without saying so: the page must see touch
|
||||
public async Task AssertPhone()
|
||||
{
|
||||
if (Phone is null)
|
||||
return;
|
||||
Assert.True(await Page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"),
|
||||
$"{Phone.Key} is not emulated as a touch screen");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await Context.CloseAsync();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,20 @@ namespace decePubClient.E2E.Support
|
||||
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
|
||||
public static async Task Mongo(string script)
|
||||
{
|
||||
|
||||
@@ -15,8 +15,12 @@ namespace decePubClient.E2E.Support
|
||||
{
|
||||
var known = await browser.Open(state);
|
||||
await known.Page.GotoAsync(Pasture.Site + "/");
|
||||
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
return known;
|
||||
if (!await Stale(known.Page))
|
||||
{
|
||||
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
return known;
|
||||
}
|
||||
await known.DisposeAsync();
|
||||
}
|
||||
var session = await browser.Open();
|
||||
await Through(session.Page, persona);
|
||||
@@ -25,6 +29,52 @@ namespace decePubClient.E2E.Support
|
||||
return session;
|
||||
}
|
||||
|
||||
// on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe
|
||||
// banner starts closed as a phone's does
|
||||
public static async Task<Session> As(Browser browser, Pasture.Persona persona, Phone phone)
|
||||
{
|
||||
var state = Path.Combine(StateDir, persona.UserName + ".json");
|
||||
if (!File.Exists(state))
|
||||
await (await As(browser, persona)).DisposeAsync();
|
||||
var session = await browser.Open(state, phone);
|
||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
||||
if (await Stale(session.Page))
|
||||
{
|
||||
// the saved sign-in has expired (the page went to /login): sign in again on a desktop and start over
|
||||
await session.DisposeAsync();
|
||||
await (await As(browser, persona, fresh: true)).DisposeAsync();
|
||||
session = await browser.Open(state, phone);
|
||||
}
|
||||
try
|
||||
{
|
||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
||||
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await session.Page.GotoAsync(Pasture.Site + "/");
|
||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Evidence(session.Page, $"signin-{phone.Key}-{persona.UserName}");
|
||||
throw;
|
||||
}
|
||||
await session.AssertPhone();
|
||||
return session;
|
||||
}
|
||||
|
||||
static async Task<bool> Stale(IPage page)
|
||||
{
|
||||
var until = DateTime.UtcNow.AddSeconds(15);
|
||||
while (DateTime.UtcNow < until)
|
||||
{
|
||||
if (new Uri(page.Url).AbsolutePath.StartsWith("/login"))
|
||||
return true;
|
||||
if (await page.Locator("article[data-uri], [data-feed-empty]").CountAsync() > 0)
|
||||
return false;
|
||||
await page.WaitForTimeoutAsync(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static async Task Through(IPage page, Pasture.Persona persona)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="Mobile/fit.js" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Checks of the globe's pure functions against almanac values, dev only (Node's own test runner, nothing installed):
|
||||
// node --test tools/globe/check.mjs
|
||||
// sun.js is the Astronomical Almanac's low-precision formulas, good to about 0.01° between 1950 and 2050.
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { analemma, seasonsOf, sunAt, sunAtPlace } from "../../wwwroot/js/sun.js"
|
||||
|
||||
const near = (actual, expected, tolerance, what) =>
|
||||
assert.ok(Math.abs(actual - expected) <= tolerance, `${what}: ${actual} is not within ${tolerance} of ${expected}`)
|
||||
|
||||
test("the June solstice of 2026 puts the Sun over the Tropic of Cancer", () => {
|
||||
near(sunAt(new Date("2026-06-21T08:24:00Z")).lat, 23.435, 0.01, "declination")
|
||||
})
|
||||
|
||||
test("the March equinox of 2026 puts the Sun over the equator", () => {
|
||||
near(sunAt(new Date("2026-03-20T14:46:00Z")).lat, 0, 0.02, "declination")
|
||||
})
|
||||
|
||||
test("perihelion on 2026-01-03 is the year's nearest Sun", () => {
|
||||
near(sunAt(new Date("2026-01-03T17:15:00Z")).distance, 0.98329, 0.0002, "distance in AU")
|
||||
})
|
||||
|
||||
test("Turin's longest day is about 15 h 38 min", () => {
|
||||
near(sunAtPlace(sunAt(new Date("2026-06-21T12:00:00Z")), 45.07, 7.69).dayLength, 15.63, 0.05, "day length in hours")
|
||||
})
|
||||
|
||||
test("early October is autumn in the north, and the December solstice comes next", () => {
|
||||
const seasons = seasonsOf(sunAt(new Date("2026-10-06T12:00:00Z")))
|
||||
assert.equal(seasons.north, "autumn")
|
||||
assert.equal(seasons.south, "spring")
|
||||
assert.equal(seasons.nextEvent, "december-solstice")
|
||||
})
|
||||
|
||||
test("the analemma spans the tropics and the equation of time", () => {
|
||||
const start = new Date("2026-10-06T12:00:00Z")
|
||||
const points = analemma(start)
|
||||
assert.equal(points.length, 366)
|
||||
assert.equal(points[0].lat, sunAt(start).lat)
|
||||
near(Math.max(...points.map(p => p.lat)), 23.44, 0.05, "northernmost")
|
||||
near(Math.min(...points.map(p => p.lat)), -23.44, 0.05, "southernmost")
|
||||
// at 12:00 UTC the Sun is overhead near the prime meridian, early or late by the equation of time (about ±16 min, ±4°)
|
||||
const longitudes = points.map(p => p.lng)
|
||||
near(Math.max(...longitudes), 3.7, 0.4, "easternmost")
|
||||
near(Math.min(...longitudes), -4.1, 0.4, "westernmost")
|
||||
})
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<title>Index</title>
|
||||
<base href="/" />
|
||||
<link href="vendor/ionicons.min.css" rel="stylesheet" />
|
||||
@@ -16,7 +16,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="block relative h-screen w-full">
|
||||
<div id="app" class="block relative h-dvh w-full">
|
||||
<div class="mx-auto my-auto">Loading...</div>
|
||||
</div>
|
||||
|
||||
|
||||
+24
-11
@@ -61,35 +61,48 @@
|
||||
return pixels
|
||||
}
|
||||
|
||||
function paint(image) {
|
||||
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
|
||||
// paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of
|
||||
// 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
|
||||
image.dataset.blurhashPainted = "1"
|
||||
element.dataset.blurhashPainted = hash
|
||||
try {
|
||||
const pixels = decode(image.dataset.blurhash)
|
||||
const pixels = decode(hash)
|
||||
if (!pixels)
|
||||
return
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
||||
image.style.backgroundImage = `url(${canvas.toDataURL()})`
|
||||
image.style.backgroundSize = "cover"
|
||||
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
|
||||
element.style.backgroundImage = `url(${canvas.toDataURL()})`
|
||||
element.style.backgroundSize = "cover"
|
||||
if (isImage)
|
||||
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
|
||||
} catch (e) {
|
||||
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 => {
|
||||
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)
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches("img[data-blurhash]"))
|
||||
if (node.matches("[data-blurhash]"))
|
||||
paint(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)
|
||||
})()
|
||||
+71
-14
@@ -16,11 +16,14 @@
|
||||
// data-post-id, and .NET only hears that the Sun's moment changed (SunChanged), to show it.
|
||||
|
||||
import { directionOf, seasonsOf, sunAt, sunAtPlace } from "./sun.js"
|
||||
import { createInstruments } from "./instruments.js"
|
||||
|
||||
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
||||
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
||||
const countriesUrl = vendor + "countries.json"
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
|
||||
// a touch screen: a tap on a card opens its thread there (main.js), so cards are pinned with a mouse only
|
||||
const canHover = matchMedia("(hover: hover)")
|
||||
|
||||
let loading = null
|
||||
let state = null
|
||||
@@ -47,6 +50,9 @@ export function create(element, scrollContainerId, reference, options) {
|
||||
element,
|
||||
reference,
|
||||
names: regionNames(options?.language),
|
||||
language: options?.language,
|
||||
labels: options?.labels ?? {},
|
||||
instruments: null,
|
||||
scroller: document.getElementById(scrollContainerId),
|
||||
sun: now,
|
||||
sunPoint: { lat: now.lat, lng: now.lng },
|
||||
@@ -135,12 +141,18 @@ export function stats() {
|
||||
countries: state.countries.length,
|
||||
countryNames: state.countryNames,
|
||||
borders: state.borders.length,
|
||||
sun: sunStats()
|
||||
sun: sunStats(),
|
||||
// for the end-to-end tests: what the instruments drew, and the frames rendered so far (still while idle)
|
||||
instruments: state.instruments?.summary() ?? null,
|
||||
frame: state.globe.renderer().info.render.frame
|
||||
}
|
||||
}
|
||||
|
||||
// The Sun's moment as the overlay shows it: now, or the focused post's moment and what the Sun does at its place.
|
||||
function sunStats() {
|
||||
// The Sun's moment as the panel's caption and its text for screen readers show it: now, or the focused post's moment
|
||||
// and what the Sun does at its place; null until the globe exists.
|
||||
export function sunStats() {
|
||||
if (!state)
|
||||
return null
|
||||
const sun = state.sun
|
||||
const focus = state.focus
|
||||
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null
|
||||
@@ -179,6 +191,7 @@ export function dispose() {
|
||||
state.timers.forEach(timer => clearInterval(timer))
|
||||
if (state.globe) {
|
||||
state.globe.pauseAnimation()
|
||||
state.instruments?.dispose()
|
||||
state.globe._destructor?.()
|
||||
state.element.replaceChildren()
|
||||
}
|
||||
@@ -196,8 +209,18 @@ function resize() {
|
||||
return
|
||||
}
|
||||
state.globe.width(width).height(height)
|
||||
state.instruments?.setSize(width, height)
|
||||
pause(document.visibilityState !== "visible")
|
||||
wake()
|
||||
// a new shape (the phone's banner opened full screen, or closed again) wants the camera at another height
|
||||
const shape = narrowness()
|
||||
if (Math.abs(shape - (state.shape ?? shape)) > 0.05) {
|
||||
if (state.view)
|
||||
scheduleView()
|
||||
else
|
||||
state.globe.pointOfView({ ...state.globe.pointOfView(), altitude: 2.6 * shape }, reducedMotion.matches ? 0 : 600)
|
||||
}
|
||||
state.shape = shape
|
||||
}
|
||||
|
||||
async function build(width, height) {
|
||||
@@ -252,6 +275,12 @@ async function build(width, height) {
|
||||
.pathTransitionDuration(0)
|
||||
.globeImageUrl(textures.day)
|
||||
sharpen(globe)
|
||||
// the nerd stats, drawn in the scene (instruments.js)
|
||||
state.instruments = createInstruments(globe, state.element, { labels: state.labels, language: state.language, colour })
|
||||
state.instruments.setSize(width, height)
|
||||
state.instruments.setFacts({ anisotropy: state.anisotropy })
|
||||
state.instruments.setMoment(state.sun, state.focus)
|
||||
state.instruments.setSun(state.sunPoint)
|
||||
loadNight()
|
||||
loadCountries()
|
||||
const controls = globe.controls()
|
||||
@@ -266,7 +295,8 @@ async function build(width, height) {
|
||||
wake(1500)
|
||||
})
|
||||
listen(state.element, "pointermove", () => wake(1000))
|
||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||
state.shape = narrowness()
|
||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * state.shape }, 0)
|
||||
state.timers.push(setInterval(() => {
|
||||
if (!state.focus)
|
||||
aimSun(sunAt(new Date()))
|
||||
@@ -277,10 +307,12 @@ async function build(width, height) {
|
||||
drawPoints()
|
||||
scheduleView()
|
||||
pause(document.visibilityState !== "visible")
|
||||
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
|
||||
} catch (e) {
|
||||
state.error = e.message
|
||||
console.error(e)
|
||||
window.logFromJs?.(e.message, "globe.build")
|
||||
state.reference?.invokeMethodAsync("GlobeFailed", e.message).catch(() => { })
|
||||
} finally {
|
||||
if (state === current)
|
||||
state.building = false
|
||||
@@ -373,6 +405,7 @@ function keepSharp() {
|
||||
night.needsUpdate = true
|
||||
state.sunUniforms.nightMap.value = night
|
||||
state.sunUniforms.nightReady.value = 1
|
||||
state.instruments?.setFacts({ night: true })
|
||||
wake(500)
|
||||
}
|
||||
}
|
||||
@@ -394,6 +427,8 @@ function aimSun(sun) {
|
||||
if (reducedMotion.matches)
|
||||
state.sunPoint = { lat: sun.lat, lng: sun.lng }
|
||||
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||
state.instruments?.setMoment(sun, state.focus)
|
||||
state.instruments?.setSun(state.sunPoint)
|
||||
// one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels
|
||||
wake(100)
|
||||
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
|
||||
@@ -408,12 +443,14 @@ function stepSun() {
|
||||
if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) {
|
||||
state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng }
|
||||
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||
state.instruments?.setSun(state.sunPoint)
|
||||
}
|
||||
return
|
||||
}
|
||||
const k = Math.abs(dLat) < 0.5 && Math.abs(dLng) < 0.5 ? 1 : 0.12
|
||||
state.sunPoint = { lat: point.lat + dLat * k, lng: point.lng + dLng * k }
|
||||
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||
state.instruments?.setSun(state.sunPoint)
|
||||
wake(300)
|
||||
}
|
||||
|
||||
@@ -461,6 +498,7 @@ async function loadCountries() {
|
||||
})
|
||||
state.countries = data.countries.map(c => ({ kind: "country", ...c }))
|
||||
state.globe.pathsData(state.borders)
|
||||
state.instruments?.setFacts({ countries: state.countries.length, borders: state.borders.length })
|
||||
drawPoints()
|
||||
placeLabels(true)
|
||||
wake(500)
|
||||
@@ -511,7 +549,8 @@ function placeLabels(force = false) {
|
||||
const perDegree = pixelsPerDegree(camera.altitude)
|
||||
const horizon = 1 / (1 + camera.altitude)
|
||||
const view = unit(camera.lat, camera.lng)
|
||||
const taken = []
|
||||
const chips = state.instruments?.boxes() ?? []
|
||||
const taken = [...chips]
|
||||
for (const country of state.countries) {
|
||||
const element = state.labelOf.get(country)
|
||||
if (!element)
|
||||
@@ -535,7 +574,8 @@ function placeLabels(force = false) {
|
||||
}
|
||||
element.style.visibility = shown ? "" : "hidden"
|
||||
}
|
||||
state.countryNames = taken.length
|
||||
state.countryNames = taken.length - chips.length
|
||||
state.instruments?.setFacts({ named: state.countryNames })
|
||||
}
|
||||
|
||||
function unit(lat, lng) {
|
||||
@@ -588,10 +628,10 @@ function watchCards() {
|
||||
hover(null)
|
||||
})
|
||||
// a click on a card (not on its links or controls) pins it: the globe keeps its moment and place until it is
|
||||
// clicked again or another card is pinned
|
||||
// clicked again or another card is pinned. With a mouse only: on a touch screen the tap opens the post's thread
|
||||
listen(scroller, "click", event => {
|
||||
const card = event.target.closest?.("[data-post-id]")
|
||||
if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]"))
|
||||
if (!canHover.matches || !card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]"))
|
||||
return
|
||||
const key = card.dataset.postId
|
||||
scroller.querySelectorAll("[data-globe-pinned]").forEach(c => c.removeAttribute("data-globe-pinned"))
|
||||
@@ -677,6 +717,7 @@ function applyTheme() {
|
||||
return
|
||||
state.colours = null
|
||||
state.globe.atmosphereColor(colour("server", 1))
|
||||
state.instruments?.setColours()
|
||||
drawPoints()
|
||||
wake(2000)
|
||||
}
|
||||
@@ -696,7 +737,12 @@ function colour(kind, alpha = 0.95) {
|
||||
place: read("--color-accent"),
|
||||
country: read("--color-secondary"),
|
||||
nearyou: read("--color-success"),
|
||||
self: read("--color-base-content")
|
||||
self: read("--color-base-content"),
|
||||
// the instruments': the Sun, the twilights, the orrery's lines and the Earth's night side
|
||||
sun: read("--color-warning"),
|
||||
twilight: read("--color-info"),
|
||||
content: read("--color-base-content"),
|
||||
night: read("--color-neutral")
|
||||
}
|
||||
probe.remove()
|
||||
return colours
|
||||
@@ -754,7 +800,11 @@ function drawPoints() {
|
||||
if (!state?.globe)
|
||||
return
|
||||
state.groups = cdnGroups()
|
||||
state.globe.pointsData(aggregate())
|
||||
const points = aggregate()
|
||||
state.globe.pointsData(points)
|
||||
state.instruments?.setPoints({
|
||||
points: points.length, posts: state.points.size, servers: new Set([...state.points.values()].map(p => p.host)).size, cdnGroups: state.groups.length
|
||||
})
|
||||
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
|
||||
drawRings()
|
||||
}
|
||||
@@ -803,12 +853,15 @@ function hover(key) {
|
||||
drawRings()
|
||||
if (!post) {
|
||||
state.globe.arcsData([])
|
||||
state.instruments?.setArc(null)
|
||||
fly(state.view, 900)
|
||||
return
|
||||
}
|
||||
if (!placed(post)) {
|
||||
const group = state.groups?.find(g => g.key === post.cdn)
|
||||
state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : [])
|
||||
const toCdn = group && state.self ? { startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng, label: group.name } : null
|
||||
state.globe.arcsData(toCdn ? [toCdn] : [])
|
||||
state.instruments?.setArc(toCdn)
|
||||
const target = Number.isFinite(post.beforeLat) ? { lat: post.beforeLat, lng: post.beforeLng } : group
|
||||
if (target && distance(state.globe.pointOfView(), target) > 2500)
|
||||
fly({ lat: target.lat, lng: target.lng, altitude: state.globe.pointOfView().altitude }, 900)
|
||||
@@ -817,9 +870,9 @@ function hover(key) {
|
||||
const from = post.src === "place" || post.src === "event"
|
||||
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
|
||||
: state.self
|
||||
state.globe.arcsData(from && distance(from, post) > 30
|
||||
? [{ startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng }]
|
||||
: [])
|
||||
const toPost = from && distance(from, post) > 30 ? { startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng, label: post.label } : null
|
||||
state.globe.arcsData(toPost ? [toPost] : [])
|
||||
state.instruments?.setArc(toPost)
|
||||
const camera = state.globe.pointOfView()
|
||||
if (distance(camera, post) > 2500)
|
||||
fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900)
|
||||
@@ -840,6 +893,7 @@ function updateView() {
|
||||
if (located.length === 0) {
|
||||
state.view = null
|
||||
state.spreadKm = 0
|
||||
state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size })
|
||||
controls.autoRotate = !reducedMotion.matches
|
||||
wake()
|
||||
return
|
||||
@@ -859,6 +913,7 @@ function updateView() {
|
||||
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
|
||||
mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness())
|
||||
state.view = mean
|
||||
state.instruments?.setView(mean, state.spreadKm, located, { located: located.length, visible: state.visible.size })
|
||||
if (!state.hovered)
|
||||
fly(mean, 1200)
|
||||
}
|
||||
@@ -889,6 +944,7 @@ function readRenderer() {
|
||||
const gl = state.globe.renderer().getContext()
|
||||
const info = gl.getExtension("WEBGL_debug_renderer_info")
|
||||
state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
|
||||
state.instruments?.setFacts({ renderer: state.renderer })
|
||||
} catch {
|
||||
state.renderer = null
|
||||
}
|
||||
@@ -912,6 +968,7 @@ function countFrames() {
|
||||
state.fps = state.running ? state.frames : 0
|
||||
state.frames = 0
|
||||
settle()
|
||||
state.instruments?.setFps(state.fps, { idle: state.idle, paused: state.paused, autoRotate: !!state.globe?.controls().autoRotate })
|
||||
}, 1000))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
// The globe's nerd stats drawn where they belong (owner decision 2026-10-06: instruments in the scene, not a table): the
|
||||
// Sun's ray to the point it stands over, the day/night line and the twilights, the declination and the noon meridian;
|
||||
// a focused post's horizon, its Sun's height and its day; the tropics and the polar circles; the view's centre and
|
||||
// reach and the posts in view; the Sun's track through the year; a small orrery with the Earth–Sun distance and the
|
||||
// seasons; and the frame rate as a ring around the globe. Numbers are small chips beside their drawings.
|
||||
//
|
||||
// It costs nothing while the globe is idle: its geometry changes only where globe.js has already woken the globe (the
|
||||
// Sun moving, the view flying, a hover, the theme, a resize), and the chips are placed after each frame globe.gl
|
||||
// renders (scene.onAfterRender). three.js's classes come from objects globe.gl already made: the bundle does not expose
|
||||
// THREE, and defining it would mix two copies. Where they can't be found, only the ring and the legend remain.
|
||||
import { analemma, seasonsOf, sunAtPlace } from "./sun.js"
|
||||
|
||||
const RAD = Math.PI / 180
|
||||
const R = 100//the globe's radius in scene units
|
||||
const DoubleSide = 2//three.js's DoubleSide: the bundle does not expose THREE
|
||||
const BANNER = 320//a canvas less tall than this is a phone's banner: the day/night line and the subsolar point only
|
||||
const PERIHELION = 103//the Earth's heliocentric longitude at perihelion (the Sun's ≈283°; it drifts 1.7° a century)
|
||||
const ECCENTRICITY = 0.117//the orbit's 0.0167, seven times: drawn true it would look like a circle
|
||||
|
||||
export function createInstruments(globe, host, options) {
|
||||
const labels = options?.labels ?? {}
|
||||
const language = options?.language || undefined
|
||||
const colour = options?.colour
|
||||
const text = (key, ...args) => (labels[key] ?? key).replace(/\{(\d)\}/g, (_, i) => args[+i] ?? "")
|
||||
const decimals = digits => new Intl.NumberFormat(language, { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||||
const fixed2 = decimals(2), fixed1 = decimals(1), whole = decimals(0), au = decimals(5)
|
||||
const coordinates = (lat, lng) => `${fixed2.format(lat)}, ${fixed2.format(lng)}`
|
||||
|
||||
const overlay = document.createElement("div")
|
||||
overlay.className = "globe-instruments"
|
||||
overlay.setAttribute("aria-hidden", "true")
|
||||
host.appendChild(overlay)
|
||||
const ring = ringElement(overlay)
|
||||
const legend = document.createElement("div")
|
||||
legend.className = "globe-legend"
|
||||
overlay.appendChild(legend)
|
||||
const description = host.parentElement?.querySelector("[data-globe-view-description]") ?? null
|
||||
|
||||
let size = { width: host.clientWidth, height: host.clientHeight }
|
||||
let full = size.height >= BANNER
|
||||
let failed = false//the chips threw once: they stay where they are
|
||||
let tries = 0//renders that looked for three.js's classes; globe.gl adds its globe on its first update, after this
|
||||
let frames = 0
|
||||
let cameraText = ""
|
||||
let cameraAt = 0
|
||||
const facts = { points: 0, posts: 0, servers: 0, cdnGroups: 0, countries: 0, named: 0, borders: 0, night: false, anisotropy: 0, renderer: null }
|
||||
const fps = { value: 0, idle: false, paused: false, autoRotate: false }
|
||||
const chips = []
|
||||
let placed = []
|
||||
|
||||
// what is drawn, and what it says: built on the first render that finds the globe (see attach)
|
||||
const scene = globe.scene()
|
||||
let three = null, root = null, parts = null, tags = null, missing = null
|
||||
const previous = scene.onAfterRender
|
||||
scene.onAfterRender = (...args) => {
|
||||
previous?.(...args)
|
||||
frame()
|
||||
}
|
||||
const before = scene.onBeforeRender
|
||||
scene.onBeforeRender = (...args) => {
|
||||
before?.(...args)
|
||||
if (!parts && tries >= 0)
|
||||
attach()
|
||||
placeOrrery()
|
||||
}
|
||||
|
||||
let sun = null, point = null, focus = null, view = null, spreadKm = 0, arc = null, track = [], lastView = null, orreryBox = null
|
||||
let legendBox = null
|
||||
const Vector3 = globe.camera().position.constructor
|
||||
const temp = new Vector3()
|
||||
const temp2 = new Vector3()
|
||||
|
||||
function buildParts() {
|
||||
const lines = alpha => new three.LineBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false })
|
||||
const fills = (alpha, side = 0) => new three.MeshBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false, side })
|
||||
const hud = material => {
|
||||
material.depthTest = false
|
||||
return material
|
||||
}
|
||||
const part = {}
|
||||
|
||||
// the Sun: built around +Z, pointed at the subsolar point
|
||||
part.sunRig = group("YXZ")
|
||||
part.marker = segments(dashed(circle(1.5, R * 1.003, 48), false), lines(0.95), "sun")
|
||||
part.dot = mesh(sphere(0.9, 12, 8), fills(1), "sun")
|
||||
part.dot.position.set(0, 0, R * 1.004)
|
||||
part.ray = polyline([v(0, 0, R * 1.004), v(0, 0, R * 1.45)], lines(0.9), "sun")
|
||||
part.beam = mesh(beam(0.6, 2.6, R, R * 1.45, 10), fills(0.16, DoubleSide), "sun")
|
||||
part.bead = mesh(sphere(2.2, 16, 12), fills(0.95), "sun")
|
||||
part.bead.position.set(0, 0, R * 1.45)
|
||||
part.terminator = mesh(band(89.825, 90.175, R * 1.0025, 180), fills(0.55, DoubleSide), "white")
|
||||
part.twilight = [84, 96, 102, 108].map(angle => segments(dashed(circle(angle, R * 1.002, 180), true), lines(0.6), "twilight"))
|
||||
part.meridian = segments(dashed(greatCircleX(R * 1.002, 180), true), lines(0.45), "sun")
|
||||
part.sunRig.add(part.marker, part.dot, part.ray, part.beam, part.bead, part.terminator, ...part.twilight, part.meridian)
|
||||
|
||||
// the declination: the subsolar meridian from the equator to the Sun's latitude
|
||||
part.meridianRig = group("XYZ")
|
||||
part.declination = polyline(arcPoints(0, 0, 24), lines(0.9), "sun")
|
||||
part.meridianRig.add(part.declination)
|
||||
|
||||
// the Earth's own lines: tropics, polar circles, the CDN ring at 68°N
|
||||
part.earthRig = group("XYZ")
|
||||
part.tropics = [23.44, -23.44, 66.56, -66.56].map(lat => segments(dashed(latitudeCircle(lat, R * 1.0015, 240), true), lines(0.22), "white"))
|
||||
part.cdnRing = segments(dashed(latitudeCircle(68, R * 1.0015, 240), true), lines(0.55), "server")
|
||||
part.earthRig.add(...part.tropics, part.cdnRing)
|
||||
|
||||
// the Sun's year: where it stands over at this hour each day, the stretch to the next equinox or solstice brighter
|
||||
part.year = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.3), "sun")
|
||||
part.yearNext = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.85), "sun")
|
||||
part.yearRig = group("XYZ")
|
||||
part.yearRig.add(part.year, part.yearNext)
|
||||
|
||||
// a focused post's place: built around +Z, pointed at it; its Sun line, elevation and day in the scene's frame
|
||||
part.placeRig = group("YXZ")
|
||||
part.horizon = segments(dashed(circle(2, R * 1.002, 48), false), lines(0.9), "place")
|
||||
part.pole = polyline([v(0, 0, R * 1.001), v(0, 0, R * 1.07)], lines(0.9), "place")
|
||||
part.placeRig.add(part.horizon, part.pole)
|
||||
part.placeWorld = group("XYZ")
|
||||
part.sunLine = polyline([v(0, 0, 0), v(0, 0, 1)], lines(0.95), "sun")
|
||||
part.sunLineBelow = segments(dashed(Array.from({ length: 13 }, () => v(0, 0, 0)), false), lines(0.6), "sun")
|
||||
part.elevation = polyline(Array.from({ length: 17 }, () => v(0, 0, 0)), lines(0.9), "place")
|
||||
part.dayLit = polyline(Array.from({ length: 121 }, () => v(0, 0, 0)), lines(0.95), "sun")
|
||||
part.dayDark = segments(Array.from({ length: 240 }, () => v(0, 0, 0)), lines(0.4), "twilight")
|
||||
part.placeWorld.add(part.sunLine, part.sunLineBelow, part.elevation, part.dayLit, part.dayDark)
|
||||
|
||||
// the view: its centre, its reach, and a ring around each placed post in view
|
||||
part.viewRig = group("YXZ")
|
||||
part.cross = segments([v(-2.2, 0, R * 1.002), v(2.2, 0, R * 1.002), v(0, -2.2, R * 1.002), v(0, 2.2, R * 1.002)], lines(0.9), "server")
|
||||
part.spread = segments(dashed(circle(10, R * 1.002, 128), true), lines(0.65), "server")
|
||||
part.viewRig.add(part.cross, part.spread)
|
||||
part.inView = segments(Array.from({ length: 32 * 24 }, () => v(0, 0, 0)), lines(0.85), "place")
|
||||
part.inView.geometry.setDrawRange(0, 0)
|
||||
|
||||
// the orrery: the Sun, the orbit (eccentricity drawn sevenfold), the Earth lit on its Sun's side with its axis
|
||||
// fixed in space, ticks at the equinoxes and solstices; placed in the camera's corner before each frame
|
||||
part.orrery = group("XYZ")
|
||||
part.orrerySun = mesh(sphere(0.17, 16, 12), hud(fills(1)), "sun")
|
||||
part.orbit = polyline(Array.from({ length: 145 }, (_, i) => orbitPoint(i * 2.5)), hud(lines(0.6)), "content")
|
||||
part.ticks = segments([0, 90, 180, 270].flatMap(l => [orbitPoint(l, 0.93), orbitPoint(l, 1.07)]), hud(lines(0.8)), "content")
|
||||
part.earth = group("XYZ")
|
||||
part.earthDay = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(1)), "server")
|
||||
part.earthNight = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(0.9)), "night")
|
||||
part.earthNight.rotation.x = Math.PI
|
||||
part.earth.add(part.earthDay, part.earthNight)
|
||||
const tilt = 23.44 * RAD
|
||||
part.axis = polyline([v(-Math.sin(tilt) * 0.2, 0, -Math.cos(tilt) * 0.2), v(Math.sin(tilt) * 0.2, 0, Math.cos(tilt) * 0.2)], hud(lines(0.9)), "content")
|
||||
part.orrery.add(part.orrerySun, part.orbit, part.ticks, part.earth, part.axis)
|
||||
part.orrery.traverse(o => o.renderOrder = 20)
|
||||
part.orreryTilt = new (globe.camera().quaternion.constructor)().setFromAxisAngle(v(1, 0, 0), -1.05)
|
||||
|
||||
root.add(part.sunRig, part.meridianRig, part.earthRig, part.yearRig, part.placeRig, part.placeWorld, part.viewRig, part.inView, part.orrery)
|
||||
return part
|
||||
}
|
||||
|
||||
// the chips: what each says, where, and which goes first. Where is a point in the scene (hidden behind the globe), the
|
||||
// same never hidden ("always"), or a point on screen whose chip starts there ("screen", the orrery's); a lifted chip
|
||||
// sits that many pixels above its point, clear of what is drawn there.
|
||||
const chip = (name, priority, anchor, mode = "scene", lift = 0) => {
|
||||
const element = document.createElement("div")
|
||||
element.className = `globe-tag globe-tag-${name}`
|
||||
element.style.visibility = "hidden"
|
||||
overlay.appendChild(element)
|
||||
const made = { name, priority, anchor, mode, lift, element, text: "", width: 0, height: 0, x: NaN, y: NaN, shown: false }
|
||||
chips.push(made)
|
||||
return made
|
||||
}
|
||||
const say = (target, value) => {
|
||||
value ??= ""
|
||||
if (target.text === value)
|
||||
return
|
||||
target.text = value
|
||||
target.element.textContent = value
|
||||
target.width = target.element.offsetWidth
|
||||
target.height = target.element.offsetHeight
|
||||
}
|
||||
|
||||
const ringTag = chip("ring", 0, null)
|
||||
|
||||
function makeTags() {
|
||||
const made = {
|
||||
// the orrery's, first: nothing else is drawn in its corner
|
||||
distance: chip("orrery", 1, () => belowOrrery(0), "screen"),
|
||||
seasons: chip("orrery", 1, () => belowOrrery(1), "screen"),
|
||||
next: chip("orrery", 2, () => belowOrrery(2), "screen"),
|
||||
apsis: chip("orrery", 3, () => belowOrrery(3), "screen"),
|
||||
scale: chip("orrery-faint", 3, () => orreryBox ? { x: orreryBox.right + 4, y: orreryBox.middle } : null, "screen"),
|
||||
subsolar: chip("sun", 4, () => worldOf(parts.sunRig, 0, 0, R * 1.004), "scene", 12),
|
||||
place: chip("place", 5, () => focus && Number.isFinite(focus.lat) ? coords(focus.lat, focus.lng, 0.002) : null, "scene", 16),
|
||||
terminator: chip("white", 6, () => nearestOn(parts.sunRig, 90, R * 1.004)),
|
||||
view: chip("server", 9, () => view ? worldOf(parts.viewRig, 0, -3.2, R * 1.004) : null),
|
||||
spread: chip("server", 10, () => view && spreadKm > 0 ? nearestOn(parts.viewRig, Math.min(80, spreadKm / 6371 / RAD), R * 1.004) : null),
|
||||
arc: chip("server", 11, () => arcTop()),
|
||||
irradiance: chip("sun", 12, () => worldOf(parts.sunRig, 0, 0, R * 1.52)),
|
||||
declination: chip("sun", 13, () => sun ? worldOf(parts.meridianRig, 0, R * Math.sin(sun.lat * RAD / 2), R * Math.cos(sun.lat * RAD / 2)) : null),
|
||||
golden: chip("twilight", 14, () => nearestOn(parts.sunRig, 84, R * 1.004)),
|
||||
civil: chip("twilight", 15, () => nearestOn(parts.sunRig, 96, R * 1.004)),
|
||||
nautical: chip("twilight", 16, () => nearestOn(parts.sunRig, 102, R * 1.004)),
|
||||
astronomical: chip("twilight", 17, () => nearestOn(parts.sunRig, 108, R * 1.004)),
|
||||
noon: chip("sun", 18, () => worldOf(parts.sunRig, 0, R * Math.sin(25 * RAD), R * Math.cos(25 * RAD))),
|
||||
midnight: chip("sun", 19, () => worldOf(parts.sunRig, 0, R * Math.sin(205 * RAD), R * Math.cos(205 * RAD))),
|
||||
yearNext: chip("sun", 20, () => track.length > 0 ? coords(track.at(-1).lat, track.at(-1).lng, 0.02) : null),
|
||||
year: chip("sun-faint", 21, () => yearTop()),
|
||||
cancer: chip("white", 22, () => cameraLongitude(23.44)),
|
||||
capricorn: chip("white", 23, () => cameraLongitude(-23.44)),
|
||||
arctic: chip("white", 24, () => cameraLongitude(66.56)),
|
||||
antarctic: chip("white", 25, () => cameraLongitude(-66.56)),
|
||||
cdn: chip("server", 26, () => facts.cdnGroups > 0 ? cameraLongitude(68) : null)
|
||||
}
|
||||
chips.sort((a, b) => a.priority - b.priority)
|
||||
say(made.terminator, text("day/night line"))
|
||||
say(made.golden, text("golden hour"))
|
||||
say(made.civil, text("civil twilight"))
|
||||
say(made.nautical, text("nautical twilight"))
|
||||
say(made.astronomical, text("astronomical twilight"))
|
||||
say(made.noon, text("solar noon"))
|
||||
say(made.midnight, text("solar midnight"))
|
||||
say(made.year, text("this hour, all year"))
|
||||
say(made.cancer, `${text("Tropic of Cancer")} · ${text("axial tilt")} ${fixed2.format(23.44)}°`)
|
||||
say(made.capricorn, text("Tropic of Capricorn"))
|
||||
say(made.arctic, `${text("Arctic Circle")} ${fixed2.format(66.56)}°`)
|
||||
say(made.antarctic, text("Antarctic Circle"))
|
||||
say(made.scale, text("not to scale"))
|
||||
return made
|
||||
}
|
||||
|
||||
// three.js's classes, once globe.gl has put its globe in the scene: the parts built and told what globe.js already
|
||||
// said. Looked for before each render until found; after 120 renders without them only the ring and the legend remain.
|
||||
function attach() {
|
||||
const found = classesOf(globe)
|
||||
if (typeof found === "string") {
|
||||
missing = found
|
||||
if (++tries < 120)
|
||||
return
|
||||
tries = -1
|
||||
console.warn(`globe.instruments: ${missing}; only the ring and the legend remain`)
|
||||
return
|
||||
}
|
||||
three = found
|
||||
try {
|
||||
root = new three.Group()
|
||||
parts = buildParts()
|
||||
scene.add(root)
|
||||
tags = makeTags()
|
||||
setColours()
|
||||
refresh()
|
||||
if (point)
|
||||
setSun(point)
|
||||
if (sun)
|
||||
setMoment(sun, focus)
|
||||
if (lastView)
|
||||
setView(...lastView)
|
||||
setArc(arc)
|
||||
setPoints({})
|
||||
} catch (e) {
|
||||
tries = -1
|
||||
parts = null
|
||||
root?.removeFromParent()
|
||||
console.error(e)
|
||||
window.logFromJs?.(e.message, "globe.instruments.attach")
|
||||
}
|
||||
}
|
||||
|
||||
setSize(size.width, size.height)
|
||||
|
||||
// --- what globe.js tells it ---
|
||||
|
||||
function setSize(width, height) {
|
||||
size = { width, height }
|
||||
full = height >= BANNER
|
||||
overlay.dataset.detail = full ? "full" : "banner"
|
||||
ring.svg.style.display = full ? "" : "none"
|
||||
legend.style.display = full ? "" : "none"
|
||||
measureLegend()
|
||||
refresh()
|
||||
}
|
||||
|
||||
// what shows: in a banner the day/night line and the subsolar point only; the place and the view when there are some
|
||||
function refresh() {
|
||||
if (!parts)
|
||||
return
|
||||
for (const object of [parts.ray, parts.beam, parts.bead, ...parts.twilight, parts.meridian, parts.meridianRig, parts.earthRig, parts.yearRig,
|
||||
parts.inView, parts.orrery])
|
||||
object.visible = full
|
||||
parts.placeRig.visible = parts.placeWorld.visible = full && !!focus && Number.isFinite(focus.lat)
|
||||
parts.viewRig.visible = full && !!view
|
||||
parts.cdnRing.visible = facts.cdnGroups > 0
|
||||
}
|
||||
|
||||
// the subsolar point as it eases towards the Sun's (every frame while it travels)
|
||||
function setSun(eased) {
|
||||
point = eased
|
||||
if (!parts)
|
||||
return
|
||||
parts.sunRig.rotation.set(-eased.lat * RAD, eased.lng * RAD, 0, "YXZ")
|
||||
parts.meridianRig.rotation.set(0, eased.lng * RAD, 0)
|
||||
rewrite(parts.declination, arcPoints(0, eased.lat, 24))
|
||||
drawPlace()
|
||||
}
|
||||
|
||||
// a new moment (now, or the focused post's) and the place it is focused on
|
||||
function setMoment(moment, focused) {
|
||||
sun = moment
|
||||
focus = focused
|
||||
if (!parts)
|
||||
return
|
||||
say(tags.subsolar, `${text("subsolar point")} ${coordinates(moment.lat, moment.lng)}`)
|
||||
const deviation = (moment.irradiance - 1) * 100
|
||||
say(tags.irradiance, `${text("irradiance")} ${whole.format(moment.irradiance * 1361)} W/m² (${deviation >= 0 ? "+" : "−"}${fixed1.format(Math.abs(deviation))}%)`)
|
||||
say(tags.declination, `${text("declination")} ${moment.lat >= 0 ? "+" : "−"}${fixed2.format(Math.abs(moment.lat))}°`)
|
||||
// the year from this moment, at this hour
|
||||
const seasons = seasonsOf(moment)
|
||||
track = analemma(moment.date)
|
||||
rewrite(parts.year, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
|
||||
// the nearest and farthest the Earth comes in the year ahead, to the day: whichever is first
|
||||
let nearest = 0, farthest = 0
|
||||
track.forEach((p, day) => {
|
||||
if (p.distance < track[nearest].distance)
|
||||
nearest = day
|
||||
if (p.distance > track[farthest].distance)
|
||||
farthest = day
|
||||
})
|
||||
say(tags.apsis, nearest <= farthest
|
||||
? text("{0} in {1} days", text("perihelion"), whole.format(nearest))
|
||||
: text("{0} in {1} days", text("aphelion"), whole.format(farthest)))
|
||||
const days = Math.max(1, Math.min(365, seasons.nextEventDays))
|
||||
track = track.slice(0, days + 1)
|
||||
rewrite(parts.yearNext, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
|
||||
parts.yearNext.geometry.setDrawRange(0, track.length)
|
||||
say(tags.yearNext, eventName(seasons.nextEvent))
|
||||
// the orrery: the Earth where it is on its orbit (the Sun's ecliptic longitude, seen from the other side)
|
||||
const earthAt = orbitXYZ(moment.eclipticLongitude + 180, 1)
|
||||
parts.earth.position.set(...earthAt)
|
||||
parts.axis.position.set(...earthAt)
|
||||
say(tags.distance, `${au.format(moment.distance)} AU · ${whole.format(moment.distance * 149597870.7)} km`)
|
||||
say(tags.seasons, text("{0} in the north, {1} in the south", seasonName(seasons.north), seasonName(seasons.south)))
|
||||
say(tags.next, text("{0} in {1} days", eventName(seasons.nextEvent), whole.format(seasons.nextEventDays)))
|
||||
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(moment, focus.lat, focus.lng) : null
|
||||
refresh()
|
||||
if (place) {
|
||||
parts.placeRig.rotation.set(-focus.lat * RAD, focus.lng * RAD, 0, "YXZ")
|
||||
const phase = place.phase === "day" ? text("daylight") : place.phase === "night" ? text("night")
|
||||
: text("{0}, {1}", text(`${place.phase === "golden" ? "golden hour" : `${place.phase} twilight`}`), text(place.morning ? "morning" : "evening"))
|
||||
const day = place.dayLength >= 24 ? text("the Sun never sets") : place.dayLength <= 0 ? text("the Sun never rises")
|
||||
: `${text("day length")} ${Math.floor(place.dayLength)} h ${String(Math.round(place.dayLength % 1 * 60)).padStart(2, "0")} min`
|
||||
say(tags.place, [
|
||||
[focus.label || coordinates(focus.lat, focus.lng), phase, text("solar time {0}", place.solarTime)].join(" · "),
|
||||
`${text("elevation")} ${place.elevation >= 0 ? "" : "−"}${fixed1.format(Math.abs(place.elevation))}° · ${day}`
|
||||
].join("\n"))
|
||||
tags.place.element.dataset.phase = place.phase
|
||||
} else
|
||||
say(tags.place, "")
|
||||
drawPlace()
|
||||
}
|
||||
|
||||
// the view's centre and reach (null with no placed post in view), and the placed posts in view
|
||||
function setView(centre, reachKm, inView, counts) {
|
||||
lastView = [centre, reachKm, inView, counts]
|
||||
view = centre
|
||||
spreadKm = reachKm
|
||||
if (description)
|
||||
description.textContent = centre
|
||||
? `${text("centre of view")} ${coordinates(centre.lat, centre.lng)}; ${text("spread")} ${whole.format(reachKm)} km; ${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
|
||||
: `${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
|
||||
if (!parts)
|
||||
return
|
||||
refresh()
|
||||
if (centre) {
|
||||
parts.viewRig.rotation.set(-centre.lat * RAD, centre.lng * RAD, 0, "YXZ")
|
||||
rewrite(parts.spread, dashed(circle(Math.max(0.5, Math.min(80, reachKm / 6371 / RAD)), R * 1.002, 128), true))
|
||||
say(tags.view, `${text("centre of view")} ${coordinates(centre.lat, centre.lng)} · ${text("in view")} ${counts.located}/${counts.visible}`)
|
||||
say(tags.spread, `${text("spread")} ${whole.format(reachKm)} km`)
|
||||
} else {
|
||||
say(tags.view, "")
|
||||
say(tags.spread, "")
|
||||
}
|
||||
// a small ring around each placed post in view
|
||||
const points = parts.inView.geometry.attributes.position
|
||||
let n = 0
|
||||
for (const post of inView.slice(0, 32))
|
||||
for (const [a, b] of ringSegments(post.lat, post.lng, 1, 12)) {
|
||||
points.setXYZ(n++, a.x, a.y, a.z)
|
||||
points.setXYZ(n++, b.x, b.y, b.z)
|
||||
}
|
||||
points.needsUpdate = true
|
||||
parts.inView.geometry.setDrawRange(0, n)
|
||||
}
|
||||
|
||||
// the hovered post's arc ({ startLat, startLng, endLat, endLng, label }), or none
|
||||
function setArc(hovered) {
|
||||
arc = hovered
|
||||
if (!tags)
|
||||
return
|
||||
say(tags.arc, hovered ? `${hovered.label ? `${hovered.label} · ` : ""}${whole.format(greatCircleKm(hovered))} km` : "")
|
||||
}
|
||||
|
||||
function setPoints(counts) {
|
||||
Object.assign(facts, counts)
|
||||
refresh()
|
||||
if (tags)
|
||||
say(tags.cdn, facts.cdnGroups > 0 ? `${text("CDN groups")} ${facts.cdnGroups}` : "")
|
||||
drawLegend()
|
||||
}
|
||||
|
||||
function setFacts(more) {
|
||||
Object.assign(facts, more)
|
||||
drawLegend()
|
||||
}
|
||||
|
||||
// the frame rate, every second; the DOM only, so an idle globe stays idle
|
||||
function setFps(value, state) {
|
||||
Object.assign(fps, { value }, state)
|
||||
const pace = fps.paused ? "paused" : fps.idle ? "idle" : value >= 50 ? "good" : value >= 30 ? "fair" : "poor"
|
||||
ring.svg.dataset.pace = pace
|
||||
ring.value.style.strokeDasharray = pace === "idle" || pace === "paused" ? `0 ${ring.length}` : `${ring.length * Math.min(1, value / 60)} ${ring.length}`
|
||||
const rate = fps.paused ? text("paused") : fps.idle ? text("idle") : `${value} fps`
|
||||
say(ringTag, [rate, fps.autoRotate ? text("auto-rotate") : null, cameraText].filter(Boolean).join(" · "))
|
||||
}
|
||||
|
||||
function setColours() {
|
||||
if (!parts || !colour)
|
||||
return
|
||||
const paint = kind => {
|
||||
const rgb = colour(kind, 1).match(/[\d.]+/g)?.slice(0, 3).join(",") ?? "128,128,128"
|
||||
return `rgb(${rgb})`
|
||||
}
|
||||
root.traverse(object => {
|
||||
if (object.userData.paint && object.material?.color)
|
||||
object.material.color.setStyle(object.userData.paint === "white" ? "rgb(255,255,255)" : paint(object.userData.paint))
|
||||
})
|
||||
}
|
||||
|
||||
// the boxes the chips took, which the country names keep clear of
|
||||
function boxes() {
|
||||
return placed
|
||||
}
|
||||
|
||||
function summary() {
|
||||
let error = null
|
||||
if (parts && point) {
|
||||
// as the rig stands now, not as the last frame drew it
|
||||
parts.sunRig.updateMatrixWorld(true)
|
||||
const at = worldOf(parts.sunRig, 0, 0, R)
|
||||
const want = globe.getCoords(point.lat, point.lng, 0)
|
||||
error = Math.hypot(at.x - want.x, at.y - want.y, at.z - want.z)
|
||||
}
|
||||
return { built: !!parts, missing: parts ? null : missing, tries, detail: full ? "full" : "banner", frames, chips: chips.filter(c => c.shown).length, subsolarError: error }
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
tries = -1
|
||||
root?.traverse(object => {
|
||||
object.geometry?.dispose?.()
|
||||
object.material?.dispose?.()
|
||||
})
|
||||
root?.removeFromParent()
|
||||
scene.onAfterRender = () => { }
|
||||
scene.onBeforeRender = () => { }
|
||||
overlay.remove()
|
||||
}
|
||||
|
||||
// --- after each frame globe.gl renders: the chips placed, and the ring around the globe ---
|
||||
|
||||
function frame() {
|
||||
if (failed)
|
||||
return
|
||||
try {
|
||||
frames++
|
||||
const camera = globe.camera()
|
||||
const now = performance.now()
|
||||
if (now - cameraAt > 100) {
|
||||
cameraAt = now
|
||||
const pov = globe.pointOfView()
|
||||
const next = `${text("camera")} ${coordinates(pov.lat, pov.lng)} @ ${fixed2.format(pov.altitude)}`
|
||||
if (next !== cameraText) {
|
||||
cameraText = next
|
||||
setFps(fps.value, fps)
|
||||
}
|
||||
}
|
||||
const taken = [[0, 0, size.width, 44]]
|
||||
if (orreryBox)
|
||||
taken.push(Object.assign([0, orreryBox.top - 4, orreryBox.right, orreryBox.bottom + 4], { orrery: true }))
|
||||
if (legendBox)
|
||||
taken.push(legendBox)
|
||||
for (const target of chips) {
|
||||
if (target === ringTag)
|
||||
continue
|
||||
const at = full && target.text && target.anchor ? target.anchor() : null
|
||||
if (!at || (target.mode === "scene" && behindGlobe(at, camera.position))) {
|
||||
hide(target)
|
||||
continue
|
||||
}
|
||||
let x = 0, y = 0
|
||||
if (target.mode === "screen") {
|
||||
x = at.x + target.width / 2
|
||||
y = at.y
|
||||
} else {
|
||||
temp2.copy(at).project(camera)
|
||||
if (temp2.z > 1) {
|
||||
hide(target)
|
||||
continue
|
||||
}
|
||||
x = (temp2.x + 1) / 2 * size.width
|
||||
y = (1 - temp2.y) / 2 * size.height - (target.lift ? target.lift + target.height / 2 : 0)
|
||||
// a point on the canvas whose chip would run past its edge: the chip slides back inside
|
||||
if (x >= 0 && x <= size.width)
|
||||
x = Math.max(target.width / 2 + 3, Math.min(size.width - target.width / 2 - 3, x))
|
||||
}
|
||||
const box = [x - target.width / 2 - 2, y - target.height / 2 - 1, x + target.width / 2 + 2, y + target.height / 2 + 1]
|
||||
// the orrery's own chips may sit on it
|
||||
const own = target.name.startsWith("orrery")
|
||||
if (box[0] < 0 || box[1] < 0 || box[2] > size.width || box[3] > size.height || taken.some(b => !(own && b.orrery) && overlap(b, box))) {
|
||||
hide(target)
|
||||
continue
|
||||
}
|
||||
taken.push(box)
|
||||
show(target, x, y)
|
||||
}
|
||||
placeRing(camera, taken)
|
||||
placed = taken
|
||||
} catch (e) {
|
||||
failed = true
|
||||
console.error(e)
|
||||
window.logFromJs?.(e.message, "globe.instruments.frame")
|
||||
}
|
||||
}
|
||||
|
||||
// the ring hugs the globe's outline on screen: its centre projected, its radius the globe's apparent one
|
||||
function placeRing(camera, taken) {
|
||||
if (!full)
|
||||
return
|
||||
temp2.set(0, 0, 0).project(camera)
|
||||
const cx = (temp2.x + 1) / 2 * size.width, cy = (1 - temp2.y) / 2 * size.height
|
||||
const distance = camera.position.length()
|
||||
const half = Math.asin(Math.min(1, R / distance))
|
||||
const radius = size.height / 2 * Math.tan(half) / Math.tan(camera.fov * RAD / 2) + 6
|
||||
if (Math.abs(radius - ring.radius) > 0.5 || Math.abs(cx - ring.cx) > 0.5 || Math.abs(cy - ring.cy) > 0.5) {
|
||||
ring.radius = radius
|
||||
ring.cx = cx
|
||||
ring.cy = cy
|
||||
for (const circleElement of [ring.track, ring.value]) {
|
||||
circleElement.setAttribute("cx", cx.toFixed(1))
|
||||
circleElement.setAttribute("cy", cy.toFixed(1))
|
||||
circleElement.setAttribute("r", radius.toFixed(1))
|
||||
}
|
||||
ring.value.setAttribute("transform", `rotate(-90 ${cx.toFixed(1)} ${cy.toFixed(1)})`)
|
||||
ring.length = 2 * Math.PI * radius
|
||||
setFps(fps.value, fps)
|
||||
}
|
||||
// its words at its foot, or above the globe's foot when the ring runs off the canvas
|
||||
const y = Math.min(cy + radius + 4 + ringTag.height / 2, size.height - ringTag.height / 2 - 2)
|
||||
const box = [cx - ringTag.width / 2, y - ringTag.height / 2, cx + ringTag.width / 2, y + ringTag.height / 2]
|
||||
if (ringTag.text && !taken.some(b => overlap(b, box))) {
|
||||
taken.push(box)
|
||||
show(ringTag, cx, y)
|
||||
} else
|
||||
hide(ringTag)
|
||||
}
|
||||
|
||||
// the orrery in the camera's top-left corner, tilted towards the reader, before each frame
|
||||
// the orrery in the top left corner, below the moment's caption, sized in pixels: its orbit seen from 60° above the
|
||||
// ecliptic is half as tall as it is wide; its chips stack under it
|
||||
function placeOrrery() {
|
||||
if (!parts || !parts.orrery.visible) {
|
||||
orreryBox = null
|
||||
return
|
||||
}
|
||||
const camera = globe.camera()
|
||||
const depth = 30
|
||||
const perPixel = 2 * depth * Math.tan(camera.fov * RAD / 2) / size.height
|
||||
const radius = Math.max(52, Math.min(96, size.width * 0.15))
|
||||
const cx = 12 + radius * 1.25, cy = 56 + radius * 0.6
|
||||
orreryBox = { left: 12, top: cy - radius * 0.6, right: cx + radius * 1.25, bottom: cy + radius * 0.6, middle: cy }
|
||||
parts.orrery.position.set((cx - size.width / 2) * perPixel, (size.height / 2 - cy) * perPixel, -depth).applyQuaternion(camera.quaternion).add(camera.position)
|
||||
parts.orrery.quaternion.copy(camera.quaternion).multiply(parts.orreryTilt)
|
||||
parts.orrery.scale.setScalar(radius * perPixel)
|
||||
parts.orrery.updateMatrixWorld(true)
|
||||
// the Earth's lit half faces the Sun
|
||||
parts.orrerySun.getWorldPosition(temp)
|
||||
parts.earth.lookAt(temp)
|
||||
parts.earth.updateMatrixWorld(true)
|
||||
}
|
||||
|
||||
// --- the focused place's Sun line, elevation and day, as the Sun eases ---
|
||||
|
||||
function drawPlace() {
|
||||
if (!parts || !point || !focus || !Number.isFinite(focus.lat) || !sun)
|
||||
return
|
||||
const n = unitOf(focus.lat, focus.lng)
|
||||
const s = unitOf(point.lat, point.lng)
|
||||
const at = n.clone().multiplyScalar(R * 1.002)
|
||||
const up = n.dot(s)
|
||||
const elevation = Math.asin(Math.max(-1, Math.min(1, up))) / RAD
|
||||
const tip = s.clone().multiplyScalar(9).add(at)
|
||||
rewrite(parts.sunLine, [at, tip])
|
||||
parts.sunLine.visible = elevation >= 0
|
||||
parts.sunLineBelow.visible = elevation < 0
|
||||
if (elevation < 0)
|
||||
rewrite(parts.sunLineBelow, Array.from({ length: 13 }, (_, i) => s.clone().multiplyScalar(9 * i / 12).add(at)))
|
||||
// the arc from the horizon to the Sun's direction, 6 units out
|
||||
const flat = s.clone().addScaledVector(n, -up)
|
||||
if (flat.lengthSq() < 1e-6)
|
||||
flat.copy(perpendicular(n))
|
||||
flat.normalize()
|
||||
rewrite(parts.elevation, Array.from({ length: 17 }, (_, i) => {
|
||||
const t = elevation * RAD * i / 16
|
||||
return flat.clone().multiplyScalar(Math.cos(t) * 6).addScaledVector(n, Math.sin(t) * 6).add(at)
|
||||
}))
|
||||
// the day at the place's latitude: lit around the Sun's meridian for the day's length, dark dashes for the night
|
||||
const place = sunAtPlace(sun, focus.lat, focus.lng)
|
||||
const half = Math.max(0, Math.min(180, place.dayLength * 7.5))
|
||||
rewrite(parts.dayLit, Array.from({ length: 121 }, (_, i) => toVector(coords(focus.lat, point.lng - half + 2 * half * i / 120, 0.003))))
|
||||
parts.dayLit.visible = half > 0
|
||||
const dark = []
|
||||
const nightSpan = 360 - 2 * half
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const a = point.lng + half + nightSpan * i / 120
|
||||
const b = point.lng + half + nightSpan * (i + 0.5) / 120
|
||||
dark.push(toVector(coords(focus.lat, a, 0.003)), toVector(coords(focus.lat, b, 0.003)))
|
||||
}
|
||||
rewrite(parts.dayDark, dark)
|
||||
parts.dayDark.visible = nightSpan > 0
|
||||
}
|
||||
|
||||
// --- anchors ---
|
||||
|
||||
function arcTop() {
|
||||
if (!arc)
|
||||
return null
|
||||
const a = unitOf(arc.startLat, arc.startLng), b = unitOf(arc.endLat, arc.endLng)
|
||||
const middle = a.clone().add(b)
|
||||
if (middle.lengthSq() < 1e-6)
|
||||
return null
|
||||
middle.normalize()
|
||||
const angle = Math.acos(Math.max(-1, Math.min(1, a.dot(b))))
|
||||
return temp.copy(middle).multiplyScalar(R * (1 + angle / 2 * 0.4 * 1.1))
|
||||
}
|
||||
|
||||
function yearTop() {
|
||||
const positions = parts.year.geometry.attributes.position
|
||||
let best = 0
|
||||
for (let i = 1; i < positions.count; i++)
|
||||
if (positions.getY(i) > positions.getY(best))
|
||||
best = i
|
||||
return temp.set(positions.getX(best), positions.getY(best) * 1.004, positions.getZ(best))
|
||||
}
|
||||
|
||||
// the orrery's chips, one under another below it
|
||||
function belowOrrery(slot) {
|
||||
return orreryBox ? { x: orreryBox.left, y: orreryBox.bottom + 12 + slot * 20 } : null
|
||||
}
|
||||
|
||||
function cameraLongitude(lat) {
|
||||
return coords(lat, globe.pointOfView().lng, 0.004)
|
||||
}
|
||||
|
||||
// the point of a circle (polar angle from the rig's +Z) nearest the camera
|
||||
function nearestOn(rig, angle, radius) {
|
||||
temp.copy(globe.camera().position)
|
||||
rig.worldToLocal(temp)
|
||||
const phi = Math.atan2(temp.y, temp.x)
|
||||
return worldOf(rig, radius * Math.sin(angle * RAD) * Math.cos(phi), radius * Math.sin(angle * RAD) * Math.sin(phi), radius * Math.cos(angle * RAD))
|
||||
}
|
||||
|
||||
function worldOf(object, x, y, z) {
|
||||
return temp.set(x, y, z).applyMatrix4(object.matrixWorld)
|
||||
}
|
||||
|
||||
function coords(lat, lng, altitude) {
|
||||
const at = globe.getCoords(lat, lng, altitude)
|
||||
return temp.set(at.x, at.y, at.z)
|
||||
}
|
||||
|
||||
function toVector(at) {
|
||||
return v(at.x, at.y, at.z)
|
||||
}
|
||||
|
||||
// three-globe's frame: x = cos lat · sin lng, y = sin lat, z = cos lat · cos lng
|
||||
function unitOf(lat, lng) {
|
||||
return v(Math.cos(lat * RAD) * Math.sin(lng * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lng * RAD))
|
||||
}
|
||||
|
||||
function perpendicular(n) {
|
||||
return Math.abs(n.y) < 0.9 ? v(0, 1, 0).cross(n) : v(1, 0, 0).cross(n)
|
||||
}
|
||||
|
||||
function ringSegments(lat, lng, radiusDeg, steps) {
|
||||
const n = unitOf(lat, lng)
|
||||
const e = perpendicular(n).normalize()
|
||||
const u = n.clone().cross(e)
|
||||
const a = radiusDeg * RAD
|
||||
const points = Array.from({ length: steps }, (_, i) => {
|
||||
const t = 2 * Math.PI * i / steps
|
||||
return n.clone().multiplyScalar(Math.cos(a)).addScaledVector(e, Math.sin(a) * Math.cos(t)).addScaledVector(u, Math.sin(a) * Math.sin(t)).multiplyScalar(R * 1.003)
|
||||
})
|
||||
return points.map((p, i) => [p, points[(i + 1) % steps]])
|
||||
}
|
||||
|
||||
function greatCircleKm(a) {
|
||||
const p = unitOf(a.startLat, a.startLng), q = unitOf(a.endLat, a.endLng)
|
||||
return 6371 * Math.acos(Math.max(-1, Math.min(1, p.dot(q))))
|
||||
}
|
||||
|
||||
function behindGlobe(point, camera) {
|
||||
const dx = point.x - camera.x, dy = point.y - camera.y, dz = point.z - camera.z
|
||||
const a = dx * dx + dy * dy + dz * dz
|
||||
const b = 2 * (camera.x * dx + camera.y * dy + camera.z * dz)
|
||||
const c = camera.x * camera.x + camera.y * camera.y + camera.z * camera.z - R * R * 0.998
|
||||
const discriminant = b * b - 4 * a * c
|
||||
if (discriminant <= 0)
|
||||
return false
|
||||
const t = (-b - Math.sqrt(discriminant)) / (2 * a)
|
||||
return t > 0 && t < 0.999
|
||||
}
|
||||
|
||||
function show(target, x, y) {
|
||||
if (!target.shown) {
|
||||
target.shown = true
|
||||
target.element.style.visibility = ""
|
||||
}
|
||||
// a chip placed for the first time has no x nor y (NaN), which no comparison is true of
|
||||
if (!(Math.abs(x - target.x) < 0.5 && Math.abs(y - target.y) < 0.5)) {
|
||||
target.x = x
|
||||
target.y = y
|
||||
target.element.style.transform = `translate(${(x - target.width / 2).toFixed(1)}px, ${(y - target.height / 2).toFixed(1)}px)`
|
||||
}
|
||||
}
|
||||
|
||||
function hide(target) {
|
||||
if (target.shown) {
|
||||
target.shown = false
|
||||
target.element.style.visibility = "hidden"
|
||||
}
|
||||
}
|
||||
|
||||
function drawLegend() {
|
||||
const lines = [
|
||||
`${text("points")} ${facts.points} · ${text("posts")} ${facts.posts} · ${text("servers")} ${facts.servers}${facts.cdnGroups > 0 ? ` · ${text("CDN groups")} ${facts.cdnGroups}` : ""}`,
|
||||
[`${text(facts.night ? "day and night maps" : "day map")} 4096×2048`, facts.anisotropy ? text("{0}× anisotropic", facts.anisotropy) : null,
|
||||
facts.countries ? `${text("countries")} ${facts.named}/${facts.countries}` : null,
|
||||
facts.borders ? `${text("borders")} ${facts.borders} ${text("lines")}` : null].filter(Boolean).join(" · "),
|
||||
facts.renderer ?? ""
|
||||
].filter(Boolean)
|
||||
const value = lines.join("\n")
|
||||
if (legend.textContent === value)
|
||||
return
|
||||
legend.textContent = value
|
||||
measureLegend()
|
||||
}
|
||||
|
||||
// the legend's box, which the chips keep clear of; measured when its words or the canvas change, never per frame
|
||||
function measureLegend() {
|
||||
legendBox = full && legend.textContent ? [legend.offsetLeft, legend.offsetTop - 4, legend.offsetLeft + legend.offsetWidth, size.height] : null
|
||||
}
|
||||
|
||||
function seasonName(season) {
|
||||
return text(season)
|
||||
}
|
||||
|
||||
function eventName(next) {
|
||||
return text({ "march-equinox": "March equinox", "june-solstice": "June solstice", "september-equinox": "September equinox" }[next] ?? "December solstice")
|
||||
}
|
||||
|
||||
// --- geometry, built with three's own classes ---
|
||||
|
||||
function v(x, y, z) {
|
||||
return new three.Vector3(x, y, z)
|
||||
}
|
||||
|
||||
function group(order) {
|
||||
const made = new three.Group()
|
||||
made.rotation.order = order
|
||||
return made
|
||||
}
|
||||
|
||||
function painted(object, kind) {
|
||||
object.userData.paint = kind
|
||||
object.frustumCulled = false
|
||||
return object
|
||||
}
|
||||
|
||||
function polyline(points, material, kind) {
|
||||
return painted(new three.Line(new three.BufferGeometry().setFromPoints(points), material), kind)
|
||||
}
|
||||
|
||||
function segments(points, material, kind) {
|
||||
return painted(new three.LineSegments(new three.BufferGeometry().setFromPoints(points), material), kind)
|
||||
}
|
||||
|
||||
function mesh(geometry, material, kind) {
|
||||
return painted(new three.Mesh(geometry, material), kind)
|
||||
}
|
||||
|
||||
// a line's points written in place (same count), or its geometry made again when the count changed
|
||||
function rewrite(object, points) {
|
||||
const positions = object.geometry.attributes.position
|
||||
if (positions && positions.count === points.length) {
|
||||
points.forEach((p, i) => positions.setXYZ(i, p.x, p.y, p.z))
|
||||
positions.needsUpdate = true
|
||||
return
|
||||
}
|
||||
object.geometry.dispose()
|
||||
object.geometry = new three.BufferGeometry().setFromPoints(points)
|
||||
}
|
||||
|
||||
// a circle at an angle from +Z
|
||||
function circle(angle, radius, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const t = 2 * Math.PI * i / steps
|
||||
return v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD))
|
||||
})
|
||||
}
|
||||
|
||||
// the great circle through +Z and +Y (x = 0): in the Sun's rig, the meridian of noon and midnight
|
||||
function greatCircleX(radius, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const t = 2 * Math.PI * i / steps
|
||||
return v(0, radius * Math.sin(t), radius * Math.cos(t))
|
||||
})
|
||||
}
|
||||
|
||||
function latitudeCircle(lat, radius, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const lng = 2 * Math.PI * i / steps
|
||||
return v(radius * Math.cos(lat * RAD) * Math.sin(lng), radius * Math.sin(lat * RAD), radius * Math.cos(lat * RAD) * Math.cos(lng))
|
||||
})
|
||||
}
|
||||
|
||||
// the meridian at longitude 0 from one latitude to another
|
||||
function arcPoints(from, to, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const lat = (from + (to - from) * i / steps) * RAD
|
||||
return v(0, R * 1.003 * Math.sin(lat), R * 1.003 * Math.cos(lat))
|
||||
})
|
||||
}
|
||||
|
||||
// a polyline as segments, every other one left out (or, solid, every one kept)
|
||||
function dashed(points, gaps) {
|
||||
const pairs = []
|
||||
for (let i = 0; i < points.length - 1; i += gaps ? 2 : 1)
|
||||
pairs.push(points[i], points[i + 1])
|
||||
return pairs
|
||||
}
|
||||
|
||||
function setIndexed(points, index) {
|
||||
const geometry = new three.BufferGeometry().setFromPoints(points)
|
||||
geometry.setIndex(index)
|
||||
return geometry
|
||||
}
|
||||
|
||||
// a band between two angles from +Z
|
||||
function band(inner, outer, radius, steps) {
|
||||
const points = []
|
||||
const index = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = 2 * Math.PI * i / steps
|
||||
for (const angle of [inner, outer])
|
||||
points.push(v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD)))
|
||||
if (i < steps)
|
||||
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
|
||||
}
|
||||
return setIndexed(points, index)
|
||||
}
|
||||
|
||||
// a tapered tube along +Z
|
||||
function beam(from, to, start, end, sides) {
|
||||
const points = []
|
||||
const index = []
|
||||
for (let i = 0; i <= sides; i++) {
|
||||
const t = 2 * Math.PI * i / sides
|
||||
points.push(v(from * Math.cos(t), from * Math.sin(t), start), v(to * Math.cos(t), to * Math.sin(t), end))
|
||||
if (i < sides)
|
||||
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
|
||||
}
|
||||
return setIndexed(points, index)
|
||||
}
|
||||
|
||||
// a sphere (or a cap of it, to a polar angle from +Z)
|
||||
function sphere(radius, around, down, cap = Math.PI) {
|
||||
const points = []
|
||||
const index = []
|
||||
for (let j = 0; j <= down; j++) {
|
||||
const polar = cap * j / down
|
||||
for (let i = 0; i <= around; i++) {
|
||||
const t = 2 * Math.PI * i / around
|
||||
points.push(v(radius * Math.sin(polar) * Math.cos(t), radius * Math.sin(polar) * Math.sin(t), radius * Math.cos(polar)))
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < down; j++)
|
||||
for (let i = 0; i < around; i++) {
|
||||
const a = j * (around + 1) + i, b = a + around + 1
|
||||
index.push(a, b, a + 1, b, b + 1, a + 1)
|
||||
}
|
||||
return setIndexed(points, index)
|
||||
}
|
||||
|
||||
// the orbit's point at a heliocentric longitude, from the Sun at the focus (the ellipse's scale is 1)
|
||||
function orbitXYZ(longitude, out = 1) {
|
||||
const anomaly = (longitude - PERIHELION) * RAD
|
||||
const r = (1 - ECCENTRICITY * ECCENTRICITY) / (1 + ECCENTRICITY * Math.cos(anomaly)) * out
|
||||
return [r * Math.cos(longitude * RAD), r * Math.sin(longitude * RAD), 0]
|
||||
}
|
||||
|
||||
function orbitPoint(longitude, out = 1) {
|
||||
return v(...orbitXYZ(longitude, out))
|
||||
}
|
||||
|
||||
return { setSize, setSun, setMoment, setView, setArc, setPoints, setFacts, setFps, setColours, boxes, summary, dispose }
|
||||
}
|
||||
|
||||
// three.js's classes, from objects globe.gl made: its globe mesh in its group, the graticules (a LineSegments with its
|
||||
// LineBasicMaterial, added hidden), an empty Mesh for its default geometry and material, the camera's Vector3. Each is
|
||||
// checked by three's own is* flags; what is missing, as words, when one isn't there.
|
||||
function classesOf(globe) {
|
||||
try {
|
||||
const scene = globe.scene()
|
||||
const globeMaterial = globe.globeMaterial()
|
||||
let earth = null, graticules = null
|
||||
scene.traverse(object => {
|
||||
if (!earth && object.isMesh && object.material === globeMaterial)
|
||||
earth = object
|
||||
if (!graticules && object.isLineSegments)
|
||||
graticules = object
|
||||
})
|
||||
if (!earth?.parent?.isGroup)
|
||||
return "no globe mesh in a group"
|
||||
if (!graticules?.material?.isLineBasicMaterial)
|
||||
return "no graticules"
|
||||
const Mesh = earth.constructor
|
||||
const Group = earth.parent.constructor
|
||||
const LineSegments = graticules.constructor
|
||||
const Line = Object.getPrototypeOf(LineSegments)
|
||||
const LineBasicMaterial = graticules.material.constructor
|
||||
const probe = new Mesh()
|
||||
const BufferGeometry = probe.geometry.constructor
|
||||
const MeshBasicMaterial = probe.material.constructor
|
||||
const Vector3 = globe.camera().position.constructor
|
||||
const line = new Line()
|
||||
const ok = line.isLine && !line.isLineSegments && new Group().isGroup && probe.isMesh && new BufferGeometry().isBufferGeometry
|
||||
&& new MeshBasicMaterial().isMeshBasicMaterial && new Vector3().isVector3
|
||||
for (const made of [probe, line]) {
|
||||
made.geometry.dispose()
|
||||
made.material.dispose()
|
||||
}
|
||||
return ok ? { Mesh, Group, LineSegments, Line, LineBasicMaterial, BufferGeometry, MeshBasicMaterial, Vector3 } : "a class without its is* flag"
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return e.message
|
||||
}
|
||||
}
|
||||
|
||||
// the frame-rate ring: a track and a value arc, an SVG over the canvas
|
||||
function ringElement(overlay) {
|
||||
const ns = "http://www.w3.org/2000/svg"
|
||||
const svg = document.createElementNS(ns, "svg")
|
||||
svg.setAttribute("class", "globe-ring")
|
||||
const track = document.createElementNS(ns, "circle")
|
||||
track.setAttribute("class", "globe-ring-track")
|
||||
const value = document.createElementNS(ns, "circle")
|
||||
value.setAttribute("class", "globe-ring-value")
|
||||
svg.append(track, value)
|
||||
overlay.appendChild(svg)
|
||||
return { svg, track, value, radius: 0, cx: 0, cy: 0, length: 0 }
|
||||
}
|
||||
|
||||
function overlap(a, b) {
|
||||
return a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]
|
||||
}
|
||||
@@ -65,6 +65,15 @@ export function seasonsOf(sun) {
|
||||
}
|
||||
}
|
||||
|
||||
// Where the Sun is overhead at this same UTC time on each of the coming days (the first is the date itself): traced on
|
||||
// the globe, the analemma, its figure-8 the year's declination and the equation of time.
|
||||
export function analemma(date, days = 366) {
|
||||
const points = []
|
||||
for (let day = 0; day < days; day++)
|
||||
points.push(sunAt(new Date(date.getTime() + day * 86400000)))
|
||||
return points
|
||||
}
|
||||
|
||||
// The Sun's direction in the frame of three.js's SphereGeometry, the frame the globe's shader draws in: texture u is
|
||||
// 0 at longitude −180°, and a point is (−cos 2πu · cos lat, sin lat, sin 2πu · cos lat).
|
||||
export function directionOf(sun) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+58
-1
@@ -123,9 +123,66 @@ window.clearAppBadge = () => {
|
||||
window.logFromJs(e.message, "clearAppBadge")
|
||||
}
|
||||
}
|
||||
// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its
|
||||
// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes.
|
||||
document.addEventListener("click", event => {
|
||||
try {
|
||||
if (window.matchMedia("(hover: hover)").matches || !(event.target instanceof Element))
|
||||
return
|
||||
const card = event.target.closest("[data-thread]")
|
||||
if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button], .dropdown, .modal, [data-sheet]"))
|
||||
return
|
||||
if (!window.getSelection()?.isCollapsed)
|
||||
return
|
||||
card.querySelector("a[data-thread-link]")?.click()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.logFromJs(e.message, "openThread")
|
||||
}
|
||||
})
|
||||
// The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell
|
||||
// them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the
|
||||
// capture phase, so it runs before Blazor's own listener.
|
||||
let backAt = 0
|
||||
window.addEventListener("popstate", () => backAt = Date.now(), { capture: true })
|
||||
window.tookBack = () => {
|
||||
const back = Date.now() - backAt < 1000
|
||||
backAt = 0
|
||||
return back
|
||||
}
|
||||
window.canHover = () => {
|
||||
try {
|
||||
return window.matchMedia("(hover: hover)").matches
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.logFromJs(e.message, "canHover")
|
||||
return true
|
||||
}
|
||||
}
|
||||
// the selected tab of a sideways-scrolling strip, brought into its middle
|
||||
window.revealActive = strip => {
|
||||
try {
|
||||
strip?.querySelector("[aria-selected=true]")?.scrollIntoView({ inline: "center", block: "nearest" })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.logFromJs(e.message, "revealActive")
|
||||
}
|
||||
}
|
||||
// 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 = () => {
|
||||
try {
|
||||
return ((window.innerWidth > 0) ? window.innerWidth : screen.width) <= 768
|
||||
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.logFromJs(e.message, "isMobileMedia")
|
||||
|
||||
Vendored
+11
@@ -27,3 +27,14 @@ few areas without a code (Kosovo, Somaliland, Northern Cyprus, …).
|
||||
|
||||
The service worker leaves this folder out of the offline precache (`service-worker.published.js`): the feed page
|
||||
loads it the first time it shows the globe.
|
||||
|
||||
**Before upgrading globe.gl**, check what the app relies on inside the bundle, since it never sees `window.THREE`:
|
||||
- **`wwwroot/js/instruments.js`** takes three.js's classes from objects globe.gl made (`classesOf`):
|
||||
- the globe mesh, whose material is `globeMaterial()`, inside a `Group`;
|
||||
- the graticules, a `LineSegments` with a `LineBasicMaterial`, added hidden;
|
||||
- an empty `Mesh`, for `BufferGeometry` and `MeshBasicMaterial`;
|
||||
- the camera's `Vector3`.
|
||||
|
||||
It also relies on the scene's `onBeforeRender` and `onAfterRender` running on every frame. If any of these change,
|
||||
the instruments fall back to the ring and the legend, and the e2e `GlobeTests` fail on `built`.
|
||||
- **`wwwroot/js/globe.js`** relies on the texture class of the day map and on the globe material's shader chunks.
|
||||
Reference in new issue
Block a user