Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,32 @@ 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/`.
|
||||
- **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`.
|
||||
@@ -128,6 +154,57 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
||||
- 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 and selects 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 +260,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
|
||||
@@ -215,7 +293,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
|
||||
@@ -327,8 +405,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 +437,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))]
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
-10
@@ -17,18 +17,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 +37,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>
|
||||
}
|
||||
@@ -131,16 +132,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>
|
||||
@@ -293,6 +296,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"]
|
||||
@@ -340,6 +344,18 @@
|
||||
_ => 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+145
-51
@@ -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">
|
||||
<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>
|
||||
<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,7 +620,7 @@
|
||||
<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"
|
||||
@@ -569,6 +637,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 +659,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 +707,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 +767,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>
|
||||
@@ -705,10 +778,11 @@
|
||||
|
||||
RenderFragment Media(MediaAttachment media, bool several) => 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>,
|
||||
</button>,
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></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>,
|
||||
@@ -885,8 +959,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 +982,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 +999,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 +1054,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 || ShowClose)
|
||||
{
|
||||
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
|
||||
@if (Heading is not null)
|
||||
{
|
||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
||||
<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; }
|
||||
|
||||
+121
-115
@@ -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>
|
||||
|
||||
@if (stats?.Sun is { } sun)
|
||||
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)
|
||||
{
|
||||
<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>
|
||||
@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>
|
||||
|
||||
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||
@if (sun is not null)
|
||||
{
|
||||
<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>
|
||||
</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>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
else if (error is { Length: > 0 })
|
||||
{
|
||||
<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-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>
|
||||
}
|
||||
|
||||
<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)
|
||||
@if (Expanded)
|
||||
{
|
||||
<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>
|
||||
<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
|
||||
{
|
||||
<span>@Localizer["loading"]…</span>
|
||||
<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>
|
||||
}
|
||||
</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. *@
|
||||
@* 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>
|
||||
}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
+50
-13
@@ -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">
|
||||
<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" />
|
||||
</div>
|
||||
}
|
||||
</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;
|
||||
|
||||
@@ -50,6 +50,8 @@ builder.Services.AddOptions()
|
||||
.AddScoped<DiscoveryService>()
|
||||
.AddScoped<StreamingService>()
|
||||
.AddScoped<PublicNoticeService>()
|
||||
.AddScoped<ConfirmService>()
|
||||
.AddScoped<OverlayService>()
|
||||
.AddScoped<MediaService>()
|
||||
.AddScoped<GroupService>()
|
||||
.AddScoped<InstanceService>()
|
||||
|
||||
+102
-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,106 @@
|
||||
<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>
|
||||
</root>
|
||||
+102
-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,106 @@
|
||||
<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>
|
||||
</root>
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using decePubClient.Models;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
@@ -41,6 +42,15 @@ 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");
|
||||
|
||||
// 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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,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);
|
||||
|
||||
+34
-1
@@ -1,5 +1,10 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@* 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 />
|
||||
|
||||
@@ -12,3 +17,31 @@
|
||||
|
||||
<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();
|
||||
}
|
||||
+77
-18
@@ -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"
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</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);
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -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,9 +199,12 @@
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -194,6 +218,7 @@
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +227,7 @@
|
||||
&:not(:where(:checked, [aria-checked="true"], :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 +236,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 +262,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,157 @@
|
||||
/* 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) {
|
||||
--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]!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,19 +28,42 @@ 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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,13 @@ namespace decePubClient.E2E.Support
|
||||
{
|
||||
var known = await browser.Open(state);
|
||||
await known.Page.GotoAsync(Pasture.Site + "/");
|
||||
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);
|
||||
Directory.CreateDirectory(StateDir);
|
||||
@@ -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>
|
||||
|
||||
|
||||
+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) {
|
||||
|
||||
+47
-1
@@ -123,9 +123,55 @@ 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)
|
||||
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