33 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 9d662f702b The feed memory test counts the deletions it was told of
Build / Build (push) Successful in 30s
Deploy / decepub.thepra.dev (push) Successful in 1m33s
In one full run (241 of 242 passing) the home feed came back from a thread with fewer posts than it was left with, and
passed three times alone. The feed takes a post out at once when the stream says it was deleted, and earlier tests do
delete posts in that persona's home, but PrivaPub's log shows no federated Delete at that minute, so the cause is not
proven. The test now allows for the deletions the stream reported during the trip and says the three numbers when it
fails; with none reported, a shorter feed still fails, and coming back still may not read the home feed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:55:30 +02:00
thepraandClaude Opus 5.5 e94e32d198 The globe's nerd stats are instruments in the scene
Owner decision 2026-10-06: the globe tells its numbers where they belong instead of in a table. instruments.js adds
one three.js group to globe.gl's scene and a DOM overlay of chips:
- the Sun: a ray and a bead over the subsolar point (with the irradiance), the day/night line, the golden hour and
  the three twilights, the declination, the noon and midnight meridian, and the analemma at this hour, its stretch to
  the next equinox or solstice brighter;
- a focused post's place: its horizon, its Sun's line and height, and its day lit on its latitude, with one chip for
  the place, the phase, the solar time, the elevation and the day's length;
- the tropics and polar circles (the axial tilt), 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, with the Earth–Sun distance,
  the seasons, the next equinox or solstice and the next perihelion or aphelion stacked under it;
- the frame rate as a ring around the globe; the rest (points, textures, countries, renderer) as a legend.

The chips are placed after each frame globe.gl draws, clear of each other, the moment's caption, the orrery and the
legend, and slide back inside the canvas at its edge; country names keep clear of them. Geometry changes only inside
a wake globe.js already made, so an idle globe still draws nothing. three.js's classes come from globe.gl's own
objects, looked for before each render until its globe is in the scene. A phone's banner draws the day/night line
and the subsolar point; full screen draws everything, and a globe built in the banner now moves its camera back when
opened full screen.

GlobePanel loses both tooltips and the one-second poll that read every post into a table; the Sun's rows stay as
screen-reader text, and a failed start says so. Seven keys only the tooltips used are gone.

GlobeTests (desktop with a located post written from Milano and pinned, in both themes and turned to the subsolar
point; a phone's banner, full screen and back; an idle globe drawing no frame) pass. Under SwiftShader the instruments
cost 1–3 ms of a ~50 ms frame and under 0.1 ms of script; FeedPerfTests' heaps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:15:06 +02:00
thepraandClaude Opus 5.5 e88f4afc45 A located post's author deletes it
A located post's card had no action at all, so whoever wrote one (with this device's position) could not take it back
from the app. The persona's own located card now has Delete, which asks first, without the line about other servers
(a located post never left PrivaPub), and goes through /clientapi/post/delete, since the Mastodon API looks past
located posts. The globe's end-to-end test deletes the post it writes this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:14:53 +02:00
thepraandClaude Opus 5.5 eade4e399a The feed doesn't flash "Empty" while the persona is switched to
LoadSession said the session was loaded before SwitchPersona had set the persona, so a render in between (any
CascadingState change) showed the feed a loaded session without a persona, and it said "Empty" until the persona came.
The session counts as loaded once the persona is known, or couldn't be used. StreamingTests took that flash for the
feed and, on a slower start, posted before the stream was open; it now waits until the app has subscribed to the home
stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:14:42 +02:00
thepraandClaude Opus 5.5 a3461c3ece Explore's hashtags are thumb-sized on a wide phone
On a 412px phone a hashtag's name and its week fit on one line, and the button that opens its feed was 40px tall; on
touch it is at least 44px, centred. The last finding of the full phone sweep (238 passed, 1 failed, the rest of the
suite unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:51:17 +02:00
thepraandClaude Opus 5.5 ed3bf57e5b Small screens, small things; CLAUDE.md on phones
On touch the "on the wall of" link gets a 44px hit area (it truncates, so padding), an event's venue address wraps,
and on a phone toasts sit above the round New post button. A table's scroll box and the administration's statistics
strip are marked as strips that scroll sideways on purpose (data-scroll-x), which the phone sweep allows. CLAUDE.md gains
a Phones section: width variants for layout and the pointer for hit sizes, touch.css, the 44px rule, the bottom sheets
(ResponsiveBottom, SheetOnNarrow, PhoneSheet), Back closing overlays (IDOverlayHost, OverlayService), ConfirmService,
what a tap does on touch, the feed on a phone; and the phone tests under Testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 b66d7cf545 Tests click exactly what they mean
A card's time is a "Post" button now (its nerd stats open with a tap), so the poll test submits through the composer's
own submit button instead of the last button named Post; the feed-memory test opens a thread from the Expand button,
not from the first expand/ link, which on a desktop is now the phone-only Thread entry. The Back flow presses Back with
history.back(): a Back held to close an overlay loads nothing, which GoBackAsync waited for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 47ff60f57b The feed doesn't say "Empty" before its persona is known
On opening, the feed showed "Empty" until the session had been read and a persona chosen, then loaded: a flash of a
wrong answer on every start (and the end-to-end sign-in took it for a loaded feed). CascadingState says once the session
has been read (IsSessionLoaded), and until then a feed with no persona yet is loading; a login without any persona still
gets its empty feed. The persona-switch test waits for the sibling's home read with another token than the first
persona's, instead of any home read after the switch was asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 18331531b8 The Sun's year is a pure function too, checked against the almanac
sun.js gains analemma(date, days): where the Sun is overhead at the same UTC time on each coming day, the figure-8 the
globe will draw. tools/globe/check.mjs checks the pure functions against almanac values with Node's own test runner
(dev only, nothing installed): the 2026 June solstice's declination, the March equinox, perihelion's distance, Turin's
longest day, the season in October, and the analemma spanning the tropics and the equation of time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:51:35 +02:00
thepraandClaude Opus 5.5 f1ac5eaad2 Back closes what is open
On a phone the Back button left the page under an open menu, sheet or dialog. An open overlay now says so (new
IDOverlayHost, cascaded by MainLayout; OverlayService keeps them newest last): DModal and DDropdown through the
cascade, so the D components stay free of app services, and the nav menu, PhoneSheet and the full-screen globe through
the service. MainLayout's location-changing handler lets Back close the newest overlay instead of leaving the page; a
link followed or the app's own navigation (signing out after its question, say) closes them all and goes on. Blazor
can't tell Back from the app's navigation, so a capture-phase popstate listener in main.js marks Back
(AppStatusService.TookBack). No history entries are pushed. Phone flow: Back closes the menu, a card's More sheet, the
composer and the full-screen globe one after another without leaving the feed, and with nothing open goes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:49:49 +02:00
thepraandClaude Opus 5.5 5522fe4fbb Pictures open in the app
A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens
MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the
post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the
file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:44:22 +02:00
thepraandClaude Opus 5.5 7b1bfa71c0 On touch, a tap on a post opens its thread
On a phone the only way into a post's thread was a small Expand button (and now an entry in More), while a tap on the
post pinned it to a globe that is usually hidden there, outlining the card. Where the pointer can't hover, a tap on a
post that isn't on its links, controls or menus and doesn't end a text selection opens its thread through the card's
own thread link (main.js, data-thread on the card); cards are pinned to the globe with a mouse only. The globe's corner
stats have 12px text on touch. Phone flow: a tap in a post's margin opens its thread and pins nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:39:10 +02:00
thepraandClaude Opus 5.5 da8f4ffcab A thread's indented posts fit its column
The posts before and after the opened one are indented 1.5rem, and they kept their full width besides (w-full beat the
w-auto meant to undo it), so on a phone the thread scrolled sideways by the indent. They are capped at the column's
width less the indent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:36:03 +02:00
thepraandClaude Opus 5.5 89cef25def Nerd stats open with a tap
On a touch screen a nerd-stats tooltip showed only through the browser faking hover, unreliably, and couldn't be
scrolled. Where the pointer can't hover (AppStatusService.CanHover), a tap on a NerdTip now opens its rows in a sheet
from the bottom; the trigger is focusable, says it opens a dialog, and is at least 44px on touch. A closed tooltip takes
no room any more (daisyUI only made it transparent, and one past a card's edge made a phone's feed scroll sideways),
and on touch it never shows. The rows are a component of their own (NerdRows), at 12px on phones. A card's More menu
has a Details entry on phones that gathers the post's, its author's, its place's or CDN's and its counts' rows in one
sheet. A picture's tip doesn't open on a tap, the picture does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:36:03 +02:00
thepraandClaude Opus 5.5 765220059d A poll's options are thumb-sized, and floating buttons don't fail what scrolls under them
On touch a poll's options are at least 44px tall with 8px between them, and a long option wraps. The phone sweep
ignores a probe that lands on a fixed floating control (the round New post button covers the card under it by design)
instead of failing the target for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:31:07 +02:00
thepraandClaude Opus 5.5 dd38002682 Feed tabs say what they are
Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes
an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every
tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and
on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one);
the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:31:07 +02:00
thepraandClaude Opus 5.5 1c9773bab0 What can't be undone asks first
Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving
a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now
ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in
MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it
deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute,
unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists,
filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a
scheduled post confirm; a phone flow deletes a post after cancelling once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:28:22 +02:00
thepraandClaude Opus 5.5 931b7fcff1 The globe opens full screen; a phone held sideways keeps its feed
On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close
button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out
of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short:
variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen.
isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a
phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and
closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:23:14 +02:00
thepraandClaude Opus 5.5 8f15d0d5c1 Phone flows: the menu, a dialog, a new post, a reply and a reaction by thumb
FlowTests drive the narrowest phone through what landed so far, each opened menu, sheet or dialog checked to be whole
on the screen with 44px targets (fit.js measures inside a scope now): the menu closes after a choice, an outside tap
and Escape; "Liked by" rises from the bottom and closes from its button; a new post is written in the sheet the round
button opens from a scrolled feed, with the words focused; a reply is written in a sheet; a reaction is picked from the
React sheet, through the one-time public notice, and taken back. Cells client.mobile.flow.*. The menu's dark-mode
toggle gets a 44px hit area and a name, and the public notice dialog marks itself for the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:19:59 +02:00
thepraandClaude Opus 5.5 071bcce4b0 Writing on a phone: the composer rises as a sheet
On a phone the composer opened at the top of the feed whether or not the feed was scrolled there, and a reply, quote
or edit opened inside its card's 228px column. New PhoneSheet shows them as a sheet from the bottom of the screen below
md, over a dimmed page, with a heading and a close button (a tap outside or Escape closes it too); from md they stay in
place as before. On a phone a new post starts from a round button under the thumb (the feed keeps room for it at the
bottom), and the tabs' Post button shows from md. Opening the composer scrolls the feed to its top, and the composer's
words take the focus (DTextarea.Element). Picking a suggested mention or hashtag keeps the focus, and the phone's
keyboard, in the words (DButton.KeepFocus, and the words are focused again). The character count sits under the words
instead of over their last line, and the visibility menu names each choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:15:54 +02:00
thepraandClaude Opus 5.5 b3bb2a28c3 A profile's Lists menu is never cut off
The profile card clipped its overflow, so the Lists menu (where a followed account is put into lists) was cut at the
card's edge at every width, and on a phone could also run past the screen's left side. The card no longer clips (its
header picture rounds the top corners itself), and on a phone the menu opens as a sheet from the bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:14:03 +02:00
thepraandClaude Opus 5.5 d2ab5e7b40 Explore's tabs no longer vanish under a long list
The tab strip sat in a flex column taller than the page, and a sideways-scrolling box may shrink to nothing there: on a
phone the five tabs had no height at all and couldn't be reached. It keeps its size now (flex-none). Found by the phone
sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 0b237b1951 A card's actions fit a phone
On a phone the card's action row was about 371px wide against a 228px text column, so the feed scrolled sideways and
More and Expand fell off the screen. Below md the author's picture moves from its own column into the card's header,
which gives the text, media and polls the whole width (288px at 360), and the actions are five equal cells: Reply,
Boost, Favourite, React and More. Bookmark and the thread are entries in More there; the downvotes, the counts icon and
Expand show from md. Counts are compacted on phones (1.2k, 12k, 1.2M, GenericExtensions.Compact). React and More open
as sheets from the bottom of the screen, the reactions as a grid of four. The author's name and handle stack and are
14px, the place badge is narrower, and "edited" and the booster's name have 44px hit areas; the booster's name opens
their profile in the app instead of their site. On touch the extra small buttons' text is 14px, and their hit area is
a full 44px (the ::after sits against the padding box, inside the border). Desktop keeps its row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 ffd4acd3d0 Phone tests: expired sign-ins, evidence, and probes that only judge what can be tapped
A saved sign-in whose session expired landed on the login page and every route its persona opened timed out; both the
desktop and the phone sign-in now see that and sign in afresh. A route that fails to open leaves its screenshot and
text, as a failed sign-in does. A target's probes stay inside what its scroller shows (a card half under the feed's
top edge was failed for the part out of sight), a failed probe names what it hit, and the element blamed for sideways
scroll is never one inside a nested clipping box, which can't widen the page. The desktop guard reads its probe sizes
as a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 63f2517f5b Dropdowns open as sheets on phones, come into view, and take no room closed
A closed dropdown's content stayed displayed whenever it carried a display class (flex, grid), only invisible, so it
still took room: the React picker past a card's right edge made the whole feed scroll sideways on a phone (294px at
360px). A closed one is now not displayed at all. DDropdown moves the focus into its content on opening, which also
scrolls a menu cut off by the feed into view, and gives it back to its trigger after Escape or an outside tap. New
SheetOnNarrow makes the content a sheet from the bottom of the screen below md, over a dimmed page, at most 80dvh tall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:46:12 +02:00
thepraandClaude Opus 5.5 f1cb90ac03 The phone menu closes after a choice
On a phone the menu stayed open over the page after a link was tapped, and only the hamburger closed it. It now closes
on any navigation, on a persona switch, on a tap outside it (a dimmed backdrop) and on Escape; its height is capped at
the screen's (dvh) with its own scroll, and its width follows a narrow screen. The hamburger has a name ("Menu") and says
whether the menu is open. The header's logo is a link home and the persona's picture a 44px link to Settings (it was
30px). From md, the sidebar scrolls on its own when the window is short, as on a phone held sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:44:57 +02:00
thepraandClaude Opus 5.5 6731077191 Dialogs close from a button and rise from the bottom on phones
DModal draws a close button (✕, named by CloseText) beside its heading, in a header that stays in view while the box
scrolls; a long list of who liked a post left only the thin margins around the box to tap out of it. A new
ResponsiveBottom placement makes the box a sheet from the bottom below md and keeps it in the middle from md; the
report, liked-by and boosted-by, edit history and public notice dialogs use it. Boxes are at most 100dvh tall, so a
phone's browser bars don't cut them, and a bottom sheet leaves 3rem above itself and clears the home indicator. The
report dialog gets a Cancel button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:43:35 +02:00
thepraandClaude Opus 5.5 87f3f2402a Controls are thumb-sized on touch screens, and labels wrap
Under (pointer: coarse) only, so desktop keeps daisyUI's sizes: buttons, tabs, inputs and selects are 44px tall and
icon buttons and tabs 44px wide; extra small buttons and the badges that act are 36px with a transparent ::after that
gives them a 44px hit area, and the rows of reactions and hashtags leave 8px between them so those areas don't overlap.
Checkboxes and radios are 24px, toggles 28, range thumbs 28, and a toggle's or checkbox's label at least 44px tall. At
every width, the label of a checkbox, radio or toggle now wraps: daisyUI's .label never does, and long ones (Groups,
the profile editor, Settings) ran past a phone's edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:42:20 +02:00
thepraandClaude Opus 5.5 38336f9869 The page zooms, keeps its height on phones and isn't reloaded by a pull
The viewport no longer forbids zooming (maximum-scale=1, user-scalable=no kept text at 10-12px out of reach), and it
covers the screen with the keyboard resizing the content. The app is 100dvh tall instead of 100vh, so its bottom isn't
under a phone browser's bar, and a pull past the top no longer reloads it (overscroll-behavior-y). New Styles/touch.css
holds what a touch screen gets, under (pointer: coarse) only: for now field text at 16px, so iOS doesn't zoom into a
focused field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:40:28 +02:00
thepraandClaude Opus 5.5 63d83810d7 Phones in the end-to-end tests
The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839,
and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if
the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller:
sideways scroll, boxes past the screen's sides, open menus and dialogs 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) and control text
under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore,
lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the
three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact
sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:40:28 +02:00
thepraandClaude Opus 5.5 88e55e671c Every field takes the boxes' radius
Inputs, selects and file inputs follow the textareas: --radius-box (.75rem) instead of daisyUI's --radius-field
(2rem), which stays the buttons', so a pill is something to press and a field is a box to fill. The reason sits once
above the input utility; a rounded-* class at the call site still wins (the composer's warning field keeps its joined
corners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:45:05 +02:00
thepraandClaude Opus 5.5 b7ff917d8c Textareas take the boxes' radius
Every daisyUI field reads --radius-field, 2rem in our themes: a pill on a one-line input, but on a textarea several
lines tall the curve ate into its first and last lines. A textarea now takes --radius-box (.75rem, the cards'); it
changes the variable daisyUI reads, so a rounded-* class at the call site still wins (the composer keeps its 1.4rem).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:42:06 +02:00
thepraandClaude Opus 5.5 24748168c0 Controls lose the seam along their edges
daisyUI keeps a 1px border on its controls, which the neo layer makes transparent, and paints their gradient as a
background image. A background image starts at the padding box and repeats, so the border strip showed the gradient's
far end: a thin seam along each button's, field's and selector's edges, as if it sat in a second frame (most visible on
the nav menu's pills in the dark theme). The gradients are now painted from the border box, on buttons, inputs,
textareas, file inputs, checkboxes, radios, toggles and the neo-gradient utilities. Checked by rendering each control
in both themes at 4x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:39:38 +02:00
72 changed files with 4254 additions and 362 deletions

No files matched your search

+102 -5
View File
@@ -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
+1
View File
@@ -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))]
+1 -1
View File
@@ -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
View File
@@ -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;
+40
View File
@@ -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
View File
@@ -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)
+4 -1
View File
@@ -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);
+69 -12
View File
@@ -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);
}
}
+47 -3
View File
@@ -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);
}
}
+3 -1
View File
@@ -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
}
+3 -2
View File
@@ -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>
}
+4 -1
View File
@@ -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; }
+11
View File
@@ -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);
}
+2
View File
@@ -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()
{
+1
View File
@@ -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
+27 -6
View File
@@ -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
View File
@@ -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
{
+1 -1
View File
@@ -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)
+75
View File
@@ -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();
}
+30
View File
@@ -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;
}
+38 -21
View File
@@ -1,10 +1,32 @@
@inherits LocalizableComponentBase
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@* 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>
</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" />;
}
+59
View File
@@ -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 -2
View File
@@ -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>
+4 -1
View File
@@ -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>
+2 -1
View File
@@ -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">
+9
View File
@@ -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],
+8
View File
@@ -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>
+1
View File
@@ -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";
+2 -2
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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&#x27;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
View File
@@ -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&#x27;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>
+10
View File
@@ -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) =>
+40
View File
@@ -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);
}
}
+64
View File
@@ -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);
}
}
}
}
+11
View File
@@ -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);
+38 -5
View File
@@ -1,6 +1,11 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@implements IDisposable
<div class="relative flex flex-col md:flex-row w-full h-full">
@* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's
Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own
navigation closes them all and goes on. *@
<CascadingValue Value="(IDOverlayHost)Overlays" IsFixed="true">
<div class="relative flex flex-col md:flex-row w-full h-full">
<NavMenu />
<div class="block flex-1 relative w-full h-full">
@@ -8,7 +13,35 @@
@Body
</div>
</div>
</div>
</div>
<ToastHost />
<PublicNoticeHost />
<ToastHost />
<PublicNoticeHost />
<ConfirmHost />
</CascadingValue>
@code {
[Inject] OverlayService Overlays { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] AppStatusService Status { get; set; }
IDisposable changing;
protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
async ValueTask OnLocationChanging(LocationChangingContext context)
{
var back = !context.IsNavigationIntercepted && Status.TookBack();
if (!Overlays.HasOpen)
return;
if (back)
{
context.PreventNavigation();
await Overlays.CloseTop(context.CancellationToken);
return;
}
await Overlays.CloseAll(context.CancellationToken);
}
public void Dispose() => changing?.Dispose();
}
+77 -18
View File
@@ -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()
+1
View File
@@ -15,5 +15,6 @@
@import "./theme.css";
@import "./neo.css";
@import "./touch.css";
@import "./base.css";
@import "./feed.css";
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+157
View File
@@ -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);
}
}
+1
View File
@@ -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]!;
+2 -1
View File
@@ -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");
+1
View File
@@ -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();
+12 -2
View File
@@ -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);
+16 -2
View File
@@ -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" });
+1
View File
@@ -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 () =>
+5 -4
View File
@@ -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();
}
+40
View File
@@ -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);
}
}
}
+182
View File
@@ -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());
}
}
}
}
+363
View File
@@ -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();
}
}
+62
View File
@@ -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));
}
}
}
+134
View File
@@ -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);
}
}
}
}
+313
View File
@@ -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 }
})()
+159
View File
@@ -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);
}
}
}
+46 -11
View File
@@ -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();
}
}
+50
View File
@@ -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
+1
View File
@@ -22,6 +22,7 @@
<ItemGroup>
<Using Include="Xunit" />
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Mobile/fit.js" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
+967
View File
@@ -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]
}
+9
View File
@@ -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
View File
@@ -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")
+11
View File
@@ -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.