37 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 356ea7b430 A picture picked is the form's at once, not once its preview is made
Build / Build (push) Successful in 31s
Deploy / decepub.thepra.dev (push) Successful in 1m35s
Settings' profile kept a picked avatar or header only after the browser had made its small preview, so a Save clicked
meanwhile saved the profile without it and said nothing; the composer added a picture to the post only after its
thumbnail, so a Publish meanwhile left it out. Both now take the file (and the composer starts its upload) as soon as
it is picked, and fill the preview in afterwards; a preview the browser can't make no longer drops the picture,
PrivaPub decides. Found by the e2e suite under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 13:32:48 +02:00
thepraandClaude Opus 5.5 48df316256 Settings takes a persona's whole archive away and brings one back
Data import/export gains the persona's archive (PrivaPub's /clientapi/persona/{id}/archive): made on PrivaPub in
Mastodon's account-archive layout and downloaded through a link; and an archive, its own or Mastodon's, uploaded in
pieces straight from the picked file (js/transfer.js) and imported into the persona, the parts chosen (likes, located
and scheduled posts only when ticked), with progress, a report of what came and what was left out, and Stop. Imported
posts are delivered to no one.

e2e: a town persona's archive downloaded and imported into another, whose profile then shows the posts; the copies are
taken away after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:49:51 +02:00
thepraandClaude Opus 5.5 dc2190e271 Administration backs the server up, downloads, uploads and restores it
A Backups section (for administrators, PrivaPub's /clientapi/admin/backups): the backups kept with their kind, size,
media and the logins deleted since, what runs, the restore waiting and the last restore's report. Back up now; download
for the password through a link good for ten minutes, which the browser saves to disk; upload a downloaded backup in
pieces straight from the picked file (js/transfer.js PUTs file.slice at the offset PrivaPub has, never through .NET's
memory, and carries on after a refused or lost piece); delete after asking; restore for the password and the host typed
out, saying what it keeps and what it loses, then showing PrivaPub restarting until the ended session sends the page to
sign in.

e2e: a backup made, downloaded and uploaded again; and, only with --filter "Category=Restore", a real restore of the
pasture, which signs the town in again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:45:30 +02:00
thepraandClaude Opus 5.5 70cb389537 Media survive failures: uploaded when attached, the server's limits, alt text asked for
The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.

Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:56:59 +02:00
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
97 changed files with 7103 additions and 525 deletions

No files matched your search

+143 -13
View File
@@ -69,6 +69,35 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam
town's report (`out/client.jsonl`).
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
- sideways scroll, unless the strip is marked `data-scroll-x`;
- boxes past the screen's sides;
- open menus, sheets and dialogs that are cut or outside the screen;
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
- control text under 12px.
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
`client.mobile.touch.*`, with the phone as the ref.
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
- `DesktopUnchangedTests` holds desktop's control sizes.
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
turned to the subsolar point.
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
to the banner.
- An idle globe draws no frame.
Photos and stats go to `tests/e2e/out/globe/`.
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
@@ -128,6 +157,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 +263,7 @@ supplies:
messages.
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
`AdditionalError` and `AdditionalErrors`.
- `ConfirmService` asks before what can't be undone (see **Phones**).
**HTTP (`Services/IHttpService.cs`).**
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
@@ -196,6 +277,8 @@ supplies:
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
PrivaPub redirects it).
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
`HttpClient`'s own is off.
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
@@ -215,7 +298,7 @@ supplies:
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
`/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
@@ -231,8 +314,10 @@ supplies:
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
link, participants and status.
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
@@ -262,8 +347,21 @@ supplies:
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
post opened in its thread shows whole.
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
a poll instead of media.
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
- the ids are kept, so a post that fails never uploads its media again;
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
- a description changed after the upload is PUT before the post.
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
the reader turned `PageSettings.RemindAltText` off in Settings → General.
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
(`media_attributes`). Its visibility and poll stay.
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
(`/api/v2/search`, never resolving), once typing pauses.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
@@ -277,14 +375,27 @@ supplies:
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
the client then signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
and adding an admin endpoint waits for the owner, so the Users section stays empty.
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
to disk.
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
has, and carries on from what PrivaPub says after a refused or lost piece.
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
@@ -327,8 +438,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 +470,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
+11
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))]
@@ -57,6 +58,16 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewBackups))]
[JsonSerializable(typeof(ViewBackup))]
[JsonSerializable(typeof(BackupPasswordForm))]
[JsonSerializable(typeof(RestoreBackupForm))]
[JsonSerializable(typeof(ViewBackupTicket))]
[JsonSerializable(typeof(ViewBackupUpload))]
[JsonSerializable(typeof(ViewPersonaArchive))]
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
+448
View File
@@ -0,0 +1,448 @@
@inherits LocalizableComponentBase
@implements IAsyncDisposable
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
and everyone signs in again. A restore never undoes what protects. *@
<div class="flex flex-col gap-3 w-full" data-backups>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (restarting is not null)
{
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
</DAlert>
}
else
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
<i class="ion-md-archive"></i> @Localizer["Back up now"]
</DButton>
@if (backups.Running is { } running)
{
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
</span>
}
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
</div>
@if (backups.Waiting is { } waiting)
{
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
<span>
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
@if (waiting.Error is { Length: > 0 } error)
{
<br /><span class="text-xs">@error</span>
}
</span>
</DAlert>
}
@if (backups.LastRestore is { } last)
{
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
@if (last.Abandoned)
{
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
<p class="text-xs">@last.Error</p>
}
else
{
<p>
<b>@Localizer["Restored {0}", last.Backup]</b>
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
</p>
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
{
<p class="text-xs" data-last-restore-tombstoned>
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
</p>
}
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
}
</div>
}
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
data-backup-upload />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
</DButton>
</div>
@if (uploading)
{
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
}
</div>
@if (backups.Backups.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
}
@foreach (var backup in backups.Backups)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
</div>
<p class="text-xs opacity-70">
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
@(backup.DbOnly
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
@if (backup.MediaMissing > 0)
{
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
}
@if (!backup.Consistent)
{
<span>; @Localizer["not read at one instant"]</span>
}
</p>
@if (backup.RootsDeletedSince > 0)
{
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
<i class="ion-md-download"></i> @Localizer["Download"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
OnClick="() => AskRestore(backup)" data-backup-restore>
<i class="ion-md-refresh"></i> @Localizer["Restore"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
<i class="ion-md-trash"></i> @Localizer["Delete"]
</DButton>
</div>
</div>
}
}
</div>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
CloseText="@Localizer["Close"]" data-backup-download-dialog>
<Heading>@Localizer["Download a backup"]</Heading>
<ChildContent>
@if (ticket is null)
{
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
else
{
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
</a>
}
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
@if (ticket is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
@Localizer["Get the link"]
</DButton>
}
</Actions>
</DModal>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 text-sm">
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
@if (restoring?.RootsDeletedSince > 0)
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
<ErrorViewer AdditionalError="@dialogError" />
</div>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error"
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
OnClick="Restore" data-backup-confirm>
@Localizer["Restore"]
</DButton>
</Actions>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] AppConfiguration Configuration { get; set; }
[Inject] IJSRuntime JS { get; set; }
const string UploadInput = "input[data-backup-upload]";
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
ViewBackups backups = new();
bool busy;
string restarting;
ViewBackup downloading;
ViewBackup restoring;
string password;
string typedHost;
string ticket;
long ticketSize;
string dialogError;
string pickedName;
bool uploading;
long uploaded;
long uploadSize;
double uploadedPercent;
IJSObjectReference transfer;
DotNetObjectReference<BackupsPanel> self;
CancellationTokenSource polling;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
backups = await AdminService.Backups() ?? backups;
if (backups.Running is not null || backups.Waiting is not null)
Poll();
}
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
void Poll()
{
if (polling is not null)
return;
polling = new();
var token = polling.Token;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(PollEvery);
while (await timer.WaitForNextTickAsync(token))
{
var read = await AdminService.Backups(token);
if (read is not null)
backups = read;
await InvokeAsync(StateHasChanged);
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
break;
}
polling = null;
}, token);
}
static DColor KindColor(string kind) => kind switch
{
"nightly" => DColor.Neutral,
"manual" => DColor.Primary,
"pre-deploy" => DColor.Info,
"pre-restore" => DColor.Warning,
"uploaded" => DColor.Accent,
_ => DColor.Neutral
};
string KindName(string kind) => kind switch
{
"nightly" => Localizer["Nightly"],
"manual" => Localizer["Made by hand"],
"pre-deploy" => Localizer["Before a deploy"],
"pre-restore" => Localizer["Before a restore"],
"uploaded" => Localizer["Uploaded"],
_ => kind
};
async Task BackUp()
{
busy = true;
var result = await AdminService.BackUp();
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Running = "backup";
Toast.AddResponseSuccess(Localizer["The backup has started."]);
Poll();
}
async Task Delete(ViewBackup backup)
{
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
return;
busy = true;
var result = await AdminService.DeleteBackup(backup.Id);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
}
void AskDownload(ViewBackup backup)
{
(password, ticket, dialogError) = (null, null, null);
downloading = backup;
}
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
async Task Download()
{
busy = true;
var result = await AdminService.BackupTicket(downloading.Id, password);
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
var granted = (ViewBackupTicket)result.Data;
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
ticketSize = granted.Bytes;
password = null;
dialogError = null;
}
void AskRestore(ViewBackup backup)
{
(password, typedHost, dialogError) = (null, null, null);
restoring = backup;
}
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
async Task Restore()
{
busy = true;
var asked = restoring;
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
CloseRestore();
restarting = asked.Id;
Poll();
}
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
async Task Upload()
{
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
self ??= DotNetObjectReference.Create(this);
var started = await AdminService.StartBackupUpload();
if (started.IsInvalid)
{
Toast.AddResponseError(started);
return;
}
var upload = (ViewBackupUpload)started.Data;
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
if (!sent.Ok)
{
uploading = false;
await AdminService.CancelBackupUpload(upload.Id);
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
return;
}
var finished = await AdminService.FinishBackupUpload(upload.Id);
uploading = false;
if (finished.IsInvalid)
{
Toast.AddResponseError(finished);
return;
}
var backup = (ViewBackup)finished.Data;
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
pickedName = null;
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
}
[JSInvokable]
public Task Progress(long received, long total)
{
(uploaded, uploadSize) = (received, total);
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
return InvokeAsync(StateHasChanged);
}
sealed class UploadOutcome
{
public bool Ok { get; set; }
public int Status { get; set; }
public string Error { get; set; }
}
public async ValueTask DisposeAsync()
{
polling?.Cancel();
self?.Dispose();
if (transfer is null)
return;
try
{
await transfer.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
+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>
+228 -47
View File
@@ -5,8 +5,10 @@
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words
and warning change, its media, visibility and poll stay). *@
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator />
@@ -17,18 +19,19 @@
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput>
}
<div class="relative">
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
<div class="flex flex-col gap-1">
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@Placeholder" rows="3" data-compose />
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
@* under the words, not over their last line *@
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
</div>
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
{
<div class="flex flex-wrap gap-2" data-suggestions>
@foreach (var account in suggestedAccounts)
{
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
@* alt="": the handle is next to it. *@
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="truncate">@("@" + account.Acct)</span>
@@ -36,7 +39,7 @@
}
@foreach (var tag in suggestedTags)
{
<DButton Size="DSize.Xs" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
}
</div>
}
@@ -82,29 +85,61 @@
@if (form.Media.Count > 0)
{
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2" data-compose-media>
@foreach (var media in form.Media)
{
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
@if (media.Preview is { Length: > 0 } preview)
{
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
}
else
{
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
}
<div class="flex flex-col flex-1 min-w-0 gap-1">
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" />
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
<p class="inline-flex items-center gap-2 text-xs">
@switch (media.State)
{
case UploadState.Uploading:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
break;
case UploadState.Processing:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
break;
case UploadState.Failed:
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
@if (media.File is not null)
{
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
}
break;
default:
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
break;
}
</p>
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
</div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
}
</div>
@if (AskingAltText)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
<div class="flex flex-wrap gap-2">
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
</div>
</DAlert>
}
}
@if (form.IsLocated)
@@ -131,16 +166,18 @@
<div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
{
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
</DButton>
}
</ChildContent>
@@ -162,12 +199,12 @@
<i class="ion-md-warning text-base"></i>
</DButton>
@if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll)
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
{
@* A label styled as a button opens the hidden file input. *@
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
disabled="@(form.Media.Count >= MaximumFileCount)" />
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
<i class="ion-md-attach text-base"></i>
@if (form.Media.Count != 0)
{
@@ -233,6 +270,7 @@
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
@@ -293,6 +331,7 @@
if (!typed.Success)
return;
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
focusPending = true;
}
string Placeholder => Editing is not null ? Localizer["Edit your post"]
@@ -313,21 +352,40 @@
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
long MaximumFileSize => form.MediaType switch
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
MediaConfiguration limits = new();
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
bool askingAltText;
bool altTextConfirmed;
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
bool OfKind(string type) => form.MediaType switch
{
MediaType.Images => 16_777_216,
MediaType.Audio => 41_943_040,
_ => 99_614_720
MediaType.Images => type.StartsWith("image/"),
MediaType.Video => type.StartsWith("video/"),
_ => type.StartsWith("audio/")
};
string AcceptedFileTypes => form.MediaType switch
// the server's types of the kind chosen, and the extensions a file picker may know them by
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
MediaType.Video => ".mp4,.m4v,.webm,.mov",
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
_ => default
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
};
// what a file is: the browser's word, else its extension's
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
// a new post starts in the language the persona chose in Settings, else in the reader's
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
@@ -340,18 +398,48 @@
_ => MessageType.Public
};
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
bool focusPending = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await textarea.Element.FocusAsync();
}
protected override async Task OnInitializedAsync()
{
form.GroupId = GroupId;
form.MessageType = DefaultVisibility;
form.Language = DefaultLanguage;
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
form.QuotedStatusId = Quoting?.Id;
limits = await MediaService.Limits();
if (Editing is not null)
{
var source = await StatusService.Source(Editing.Id);
form.Content = source?.Text;
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
form.SpoilerText = source?.SpoilerText;
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
form.Language = Editing.Language ?? form.Language;
form.Sensitive = Editing.Sensitive;
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
{
Id = m.Id,
State = UploadState.Ready,
Posted = true,
AltText = m.Description,
SentAltText = m.Description,
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
FileName = Path.GetFileName(m.Url ?? string.Empty)
})];
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
}
IsLoading = false;
if (InReplyTo is null)
@@ -417,40 +505,121 @@
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
{
if (file.Size > MaximumFileSize)
var type = TypeOf(file);
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
{
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
continue;
}
if (file.Size > MaximumFileSize(type))
{
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
continue;
}
if (await ReadFile(file) is { } media)
form.Media.Add(media);
else
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
form.Media.Add(media);
altTextConfirmed = false;
Start(media);
if (type.StartsWith("image/"))
media.Preview = await Thumbnail(file);
}
}
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
async Task<string> Thumbnail(IBrowserFile file)
{
try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
await fileStream.CopyToAsync(memoryStream, cancellationToken);
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
if (file.ContentType.StartsWith("image"))
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
return media;
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
return default;
}
}
void Start(UploadMedia media)
{
media.Cancel = new();
media.State = UploadState.Uploading;
media.Error = default;
media.Done = Upload(media, media.Cancel.Token);
}
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
{
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (uploaded.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = uploaded.ErrorMessage;
await InvokeAsync(StateHasChanged);
return;
}
var attachment = (MediaAttachment)uploaded.Data;
media.Id = attachment.Id;
media.SentAltText = attachment.Description;
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
await InvokeAsync(StateHasChanged);
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(2000, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var processed = await MediaService.Get(media.Id, cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (processed.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = processed.ErrorMessage;
}
else if (processed.Data is MediaAttachment)
media.State = UploadState.Ready;
await InvokeAsync(StateHasChanged);
}
}
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
void Retry(UploadMedia media) => Start(media);
void Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
}
// the reader chose to publish its pictures without descriptions
async Task PublishAnyway()
{
altTextConfirmed = true;
askingAltText = false;
await Publish();
}
public override async ValueTask DisposeAsync()
{
foreach (var media in form.Media)
media.Cancel?.Cancel();
await base.DisposeAsync();
}
async Task Publish()
{
error = default;
@@ -464,6 +633,18 @@
error = Localizer["A poll needs two choices at least."];
return;
}
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
{
askingAltText = true;
return;
}
// what is still uploading or processing is waited for
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
{
isPublishing = true;
await Task.WhenAll(form.Media.Select(m => m.Done));
isPublishing = false;
}
if (Editing is not null)
{
isPublishing = true;
+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;
}
+175 -65
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">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p>
<div class="flex flex-wrap items-center gap-2 text-xs">
<p class="inline-flex gap-2 items-center opacity-70">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p>
@* its author takes it back here: it has no other action *@
@if (IsOwn && !Compact)
{
<DButton Size="DSize.Xs" class="ml-auto" Disabled="IsDefaultDisabled || isActing" OnClick="Delete" data-located-delete>
<i aria-hidden="true" class="ion-md-trash text-error"></i>
<span>@Localizer["Delete"]</span>
</DButton>
}
</div>
}
@if (status.Privapub?.Approval is "pending" or "rejected")
@@ -220,59 +238,65 @@
@if (!Post.Located && !Compact)
{
<footer class="flex gap-2 mt-1 justify-between">
<div class="flex gap-2">
@* on a phone the actions are five equal cells (Reply, Boost, Favourite, React, More); Bookmark and the thread are in
More, and the downvotes, the counts and Expand show from md *@
<footer class="grid grid-cols-5 gap-1 mt-1 md:flex md:gap-2 md:justify-between">
<div class="contents md:flex md:gap-2">
@if (status.Privapub?.Locked == true)
{
@* its community's moderators locked the thread: no reply is taken *@
<span class="btn btn-sm btn-disabled" title="@Localizer["Locked by its community's moderators"]" data-locked>
<span class="btn btn-sm btn-disabled max-md:w-full" title="@Localizer["Locked by its community's moderators"]" data-locked>
<i aria-hidden="true" class="ion-md-lock text-lg"></i>
@if (status.RepliesCount > 0)
{
<span>@status.RepliesCount</span>
<span class="md:hidden">@status.RepliesCount.Compact()</span>
<span class="hidden md:inline">@status.RepliesCount</span>
}
</span>
}
else
{
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<DButton Size="DSize.Sm" class="max-md:w-full" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
@if (status.RepliesCount > 0)
{
<span>@status.RepliesCount</span>
<span class="md:hidden">@status.RepliesCount.Compact()</span>
<span class="hidden md:inline">@status.RepliesCount</span>
}
</DButton>
}
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
@if (status.ReblogsCount > 0)
{
<span>@status.ReblogsCount</span>
<span class="md:hidden">@status.ReblogsCount.Compact()</span>
<span class="hidden md:inline">@status.ReblogsCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
@if (status.FavouritesCount > 0)
{
<span>@status.FavouritesCount</span>
<span class="md:hidden">@status.FavouritesCount.Compact()</span>
<span class="hidden md:inline">@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
{
<span class="inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
</span>
}
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
title="@Localizer["React"]">
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
@@ -281,20 +305,40 @@
}
</ChildContent>
</DDropdown>
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
<span class="hidden md:contents">
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</span>
</div>
<div class="flex gap-2">
<div class="contents md:flex md:gap-2">
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
<ChildContent>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm justify-start whitespace-nowrap md:hidden" href="@($"expand/{Uri.EscapeDataString(status.Id)}")"
@onclick="OpenConversation" data-thread-link>
<i aria-hidden="true" class="ion-md-expand text-base"></i>
<span>@Localizer["Thread"]</span>
</NavLink>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" data-bookmark-phone>
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
<span>@Localizer["Bookmark"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
<i aria-hidden="true" class="ion-md-stats text-base"></i>
<span>@Localizer["Details"]</span>
</DButton>
@if (status.Url is { Length: > 0 } url)
{
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
@@ -368,7 +412,7 @@
</DDropdown>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
<NavLink class="btn btn-sm hidden md:inline-flex" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
@onclick="OpenConversation">
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
</NavLink>
@@ -376,17 +420,38 @@
</div>
</footer>
@if (isReplying)
{
@* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
<PhoneSheet IsOpen="isReplying" OnClose="() => isReplying = false" Heading="@Localizer["Reply to {0}", "@" + status.Account?.Acct]">
<Composer InReplyTo="status" OnPublished="Replied" />
}
@if (isQuoting)
{
</PhoneSheet>
<PhoneSheet IsOpen="isQuoting" OnClose="() => isQuoting = false" Heading="@Localizer["Quote"]">
<Composer Quoting="status" OnPublished="Quoted" />
}
@if (isEditing)
{
</PhoneSheet>
<PhoneSheet IsOpen="isEditing" OnClose="() => isEditing = false" Heading="@Localizer["Edit"]">
<Composer Editing="status" OnPublished="Edited" />
</PhoneSheet>
@* a phone's way to the nerd stats the card's corners hold on hover, all in one sheet *@
@if (isDetailsOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isDetailsOpen = open" CloseText="@Localizer["Close"]"
data-details-sheet>
<Heading>@Localizer["Details"]</Heading>
<ChildContent>
<div class="flex flex-col gap-4">
<NerdRows Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" />
<NerdRows Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" />
@if (Post.Location is { Source: LocationSource.Cdn } detailsCdn)
{
<NerdRows Heading="@Localizer["The server is behind {0}, so its place is unknown.", detailsCdn.Label]" Rows="CdnRows()" Pending="isReadingServer" />
}
else if (Post.Location is not null)
{
<NerdRows Heading="@Localizer["Place"]" Rows="LocationRows()" />
}
<NerdRows Heading="@Localizer["Counts"]" Rows="CountRows(status)" />
</div>
</ChildContent>
</DModal>
}
@if (isReporting && status.Account is { } reported)
{
@@ -398,7 +463,7 @@
}
@if (isHistoryOpen)
{
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
<Heading>@Localizer["Edit history"]</Heading>
<ChildContent>
<ol class="flex flex-col gap-4 mt-3">
@@ -449,6 +514,7 @@
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; }
@@ -468,6 +534,8 @@
[Parameter] public FilterContext? FilterIn { get; set; }
bool isReplying;
bool isDetailsOpen;
MediaAttachment viewingPicture;
bool isQuoting;
bool isEditing;
bool isActing;
@@ -552,12 +620,13 @@
<div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
@foreach (var media in status.MediaAttachments)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
{
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
@onclick="() => revealedMedia.Add(media.Id)">
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
@* its blurhash shows behind it: the colours, nothing of what it shows *@
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
</button>
}
else
@@ -569,6 +638,11 @@
</div>
}
@if (viewingPicture is { } picture)
{
<MediaViewer Pictures="Pictures(status)" Start="Pictures(status).IndexOf(picture)" OnClose="() => viewingPicture = null" />
}
@if (status.Card is { } card && status.MediaAttachments.Count == 0)
{
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
@@ -586,16 +660,16 @@
@if (status.Poll is { } poll)
{
<div class="flex flex-col gap-1 text-xs" data-poll>
<div class="flex flex-col gap-1 pointer-coarse:gap-2 text-xs" data-poll>
@if (CanVote(poll))
{
@for (var i = 0; i < poll.Options.Count; i++)
{
var index = i;
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 cursor-pointer">
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 pointer-coarse:min-h-11 cursor-pointer">
<input type="@(poll.Multiple ? "checkbox" : "radio")" class="@(poll.Multiple ? "checkbox checkbox-xs" : "radio radio-xs")"
name="@($"poll-{poll.Id}")" checked="@pollChoices.Contains(index)" @onchange="() => ToggleChoice(poll, index)" />
<span>@poll.Options[index].Title</span>
<span class="min-w-0 break-words">@poll.Options[index].Title</span>
</label>
}
<DButton Size="DSize.Xs" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isActing || pollChoices.Count == 0)"
@@ -634,7 +708,7 @@
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
@foreach (var venue in happening.Places)
{
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
<span class="inline-flex gap-2 items-center min-w-0 break-all"><i class="ion-md-pin flex-none"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
}
@if (IsWebLink(happening.OnlineUrl))
{
@@ -694,7 +768,7 @@
@if (status.Tags.Count > 0)
{
<div class="flex flex-wrap gap-1">
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2">
@foreach (var tag in status.Tags)
{
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
@@ -703,21 +777,37 @@
}
</div>;
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
// media that didn't load (gone from their server, or refused): said so, with the way to their original
HashSet<string> unavailableMedia = [];
RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch
{
"image" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
</a>,
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
</button>,
// under reduced motion a gifv waits for a tap (its controls)
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></audio>,
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
</a>
};
RenderFragment Unavailable(MediaAttachment media) =>
@<div class="flex flex-col items-center justify-center gap-2 w-full aspect-video rounded-lg neo-inset neo-2xs text-sm" data-media-unavailable>
<i class="ion-md-alert text-lg opacity-70" aria-hidden="true"></i>
<span>@Localizer["Media unavailable"]</span>
@if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original)
{
<a class="link text-xs pointer-coarse:py-3.5" href="@original" target="_blank" rel="noopener noreferrer">@Localizer["Open original"]</a>
}
</div>;
async Task Act(StatusAction action)
{
isActing = true;
@@ -885,8 +975,12 @@
async Task Delete()
{
// a located post never left PrivaPub: no server is asked
if (!await Confirm.Ask(Localizer["Delete this post?"], Post.Located ? null : Localizer["It goes from here, and the servers that have it are asked to delete it too."],
Localizer["Delete"], true))
return;
isActing = true;
var result = await StatusService.Delete(Status.Id);
var result = Post.Located ? await StatusService.DeleteLocated(Status.Id, CascadingState.PersonaId) : await StatusService.Delete(Status.Id);
isActing = false;
if (result.IsInvalid)
{
@@ -904,6 +998,8 @@
async Task TakeOffWall()
{
if (!await Confirm.Ask(Localizer["Take this post off your wall?"], null, Localizer["Take off my wall"], true))
return;
isActing = true;
var result = await StatusService.Delete(Status.Id);
isActing = false;
@@ -919,6 +1015,10 @@
async Task AccountAction(string action)
{
var name = Status.Account?.Name ?? Status.Account?.Acct;
if (!await Confirm.Ask(action == "block" ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
Localizer[action == "block" ? "Block" : "Mute"], action == "block"))
return;
isActing = true;
var result = await StatusService.AccountAction(Status.Account.Id, action);
isActing = false;
@@ -970,6 +1070,16 @@
await OnReplied.InvokeAsync(reply);
}
static List<MediaAttachment> Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")];
void OpenPicture(MediaAttachment media) => viewingPicture = media;
async Task OpenDetails()
{
isDetailsOpen = true;
await Task.WhenAll(ReadProvenance(), ReadServer());
}
async Task ReadProvenance()
{
if (Status is null || Post.Located)
+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);
}
}
+48 -4
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)
@if (Heading is not null || ShowClose)
{
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
@if (Heading is not null)
{
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
}
else
{
<span class="flex-1"></span>
}
@if (ShowClose)
{
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
@onclick="Close" data-modal-close>
<span aria-hidden="true">✕</span>
</button>
}
</div>
}
@ChildContent
@if (Actions is not null)
@@ -35,11 +53,17 @@
[Parameter] public DModalPlacement Placement { get; set; }
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
[Parameter] public string CloseText { get; set; } = "close";
/// <summary>A visible close button beside the heading.</summary>
[Parameter] public bool ShowClose { get; set; } = true;
[Parameter] public string BoxClass { get; set; }
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
[CascadingParameter] IDOverlayHost Overlays { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
IDisposable overlay;
ElementReference box;
bool wasOpen;
bool focusPending;
@@ -52,6 +76,7 @@
DModalPlacement.Bottom => "modal-bottom",
DModalPlacement.Start => "modal-start",
DModalPlacement.End => "modal-end",
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
_ => null
};
@@ -60,8 +85,26 @@
if (IsOpen && !wasOpen)
focusPending = true;
wasOpen = IsOpen;
TrackOverlay();
}
void TrackOverlay()
{
if (IsOpen && overlay is null)
overlay = Overlays?.Open(() => InvokeAsync(async () =>
{
await SetOpen(false);
StateHasChanged();
}));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public void Dispose() => overlay?.Dispose();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
@@ -78,6 +121,7 @@
{
IsOpen = isOpen;
wasOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+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; }
+128 -122
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>
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
<dl id="@($"{uid}-sun")" class="sr-only">
@if (sun is not null)
{
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt>@row.Label</dt>
<dd>@row.Value</dd>
}
}
</dl>
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
@if (stats?.Sun is { } sun)
@* the moment the Sun is shown at: now, or the focused post's *@
@if (sun is not null)
{
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
<span class="flex items-center gap-1 font-semibold">
<i class="@SunIcon(sun)"></i>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</span>
@if (sun.Phase is { } phase)
{
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
}
else
{
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
}
</div>
</NerdTip>
</div>
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
data-globe-moment>
<i class="@SunIcon(sun)" aria-hidden="true"></i>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</p>
}
else if (error is { Length: > 0 })
{
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
}
@if (Expanded)
{
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
}
else
{
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
</button>
}
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
}
else
{
<span>@Localizer["loading"]…</span>
}
</span>
</NerdTip>
</div>
</div>
@code {
@@ -61,23 +64,85 @@
/// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { get; set; } = [];
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
[Parameter] public bool Expanded { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
[Inject] OverlayService Overlays { get; set; }
ElementReference closeButton;
ElementReference expandButton;
IDisposable overlay;
bool focusClose;
bool focusExpand;
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
static readonly string[] InstrumentKeys =
[
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
];
async Task Open()
{
focusClose = true;
await ExpandedChanged.InvokeAsync(true);
}
async Task Close()
{
focusExpand = true;
await ExpandedChanged.InvokeAsync(false);
}
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
ElementReference element;
IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new();
int? sentFingerprint;
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
bool postsHandedOver = true;
GlobeStats stats;
SunStats sun;
string error;
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
protected override void OnInitialized() => IsLoading = false;
protected override void OnParametersSet() => postsHandedOver = true;
protected override void OnParametersSet()
{
postsHandedOver = true;
// full screen, the Back button closes it
if (Expanded && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(Close));
else if (!Expanded && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusClose && Expanded)
{
focusClose = false;
await closeButton.FocusAsync();
}
if (focusExpand && !Expanded)
{
focusExpand = false;
await expandButton.FocusAsync(preventScroll: true);
}
await base.OnAfterRenderAsync(firstRender);
try
{
@@ -85,10 +150,11 @@
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
statsTimer = new(TimeSpan.FromSeconds(1));
_ = ReadStats(cancellation.Token);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
{
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
});
}
if (postsHandedOver)
{
@@ -148,7 +214,8 @@
: null;
}
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
[JSInvokable]
public async Task SunChanged()
{
@@ -157,7 +224,7 @@
try
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
StateHasChanged();
}
catch (OperationCanceledException)
@@ -168,42 +235,12 @@
}
}
async Task ReadStats(CancellationToken cancellationToken)
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
[JSInvokable]
public void GlobeFailed(string message)
{
try
{
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable<NerdRow> StatsRows()
{
if (stats is null)
yield break;
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
error = message;
StateHasChanged();
}
IEnumerable<NerdRow> SunRows(SunStats sun)
@@ -272,9 +309,9 @@
public override async ValueTask DisposeAsync()
{
overlay?.Dispose();
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.Dispose();
reference?.Dispose();
try
{
@@ -295,37 +332,6 @@
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
sealed class GlobeStats
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Altitude { get; set; }
public int Visible { get; set; }
public int VisibleLocated { get; set; }
public double? CentroidLatitude { get; set; }
public double? CentroidLongitude { get; set; }
public double SpreadKm { get; set; }
public int Points { get; set; }
public int Posts { get; set; }
public int Servers { get; set; }
public int CdnGroups { get; set; }
public int Fps { get; set; }
public string Renderer { get; set; }
public string Texture { get; set; }
public int Anisotropy { get; set; }
public bool AutoRotate { get; set; }
public bool Paused { get; set; }
public bool Idle { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public int Countries { get; set; }
public int CountryNames { get; set; }
public int Borders { get; set; }
public SunStats Sun { get; set; }
public string Error { get; set; }
}
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
sealed class SunStats
{
+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;
}
+39 -22
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">
@ChildContent
</DTooltip>
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
@if (TapOpens)
{
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
@ChildContent
</DTooltip>
}
else
{
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@ChildContent
</DTooltip>
}
@if (isSheetOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
<Heading>@Heading</Heading>
<ChildContent>
<NerdRows Rows="Rows" Pending="Pending" />
</ChildContent>
</DModal>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
@@ -16,8 +38,19 @@
[Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; }
[Parameter] public string Class { get; set; }
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
[Parameter] public bool TapOpens { get; set; } = true;
bool opened;
bool isSheetOpen;
async Task Tap()
{
if (CascadingState.Status.CanHover())
return;
isSheetOpen = true;
await Open();
}
protected override void OnInitialized() => IsLoading = false;
@@ -29,21 +62,5 @@
await OnOpen.InvokeAsync();
}
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>;
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
}
+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>
+5 -2
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>
}
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
<div class="flex justify-end gap-2">
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
+8 -2
View File
@@ -95,9 +95,15 @@
async Task Export(PortableData data)
{
exporting = data;
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
exporting = null;
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv");
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
}
async Task Pick(InputFileChangeEventArgs eventArgs)
@@ -0,0 +1,364 @@
@inherits LocalizableComponentBase
@implements IAsyncDisposable
@* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's
layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from
the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@
<div class="flex flex-col gap-5 w-full" data-archive>
<section class="flex flex-col gap-2" data-archive-export>
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
<p class="text-sm opacity-70">@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
@if (state.ExportState is "queued" or "running")
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
else
{
<i class="ion-md-archive"></i>
}
<span>@Localizer["Make the archive"]</span>
</DButton>
@if (state.ExportState == "ready")
{
@if (link is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
<i class="ion-md-download"></i> @Localizer["Get the link"]
</DButton>
}
else
{
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
</a>
}
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
</span>
}
</div>
@if (state.ExportState == "failed")
{
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
}
</section>
<section class="flex flex-col gap-2" data-archive-import>
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
<p class="text-sm opacity-70">@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]</p>
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
@foreach (var part in Parts)
{
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
}
</div>
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
@if (!Importing)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
data-archive-import-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
</DButton>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
@Localizer["Stop"]
</DButton>
}
</div>
@if (uploading)
{
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
}
@if (state.ImportState is not (null or "none"))
{
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
@if (state.ImportState is "queued" or "running")
{
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
}
<span class="text-sm">
@(state.ImportState switch
{
"queued" => Localizer["Waiting to start."],
"running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal],
"done" => Localizer["Imported."],
"stopped" => Localizer["Stopped."],
_ => Localizer["The import failed: {0}", state.ImportError ?? ""]
})
</span>
@if (state.ImportFrom is { Length: > 0 } from)
{
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
}
<ul class="text-xs tabular-nums">
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
{
<li data-archive-count="@what">@count × @CountName(what)</li>
}
</ul>
</div>
}
</section>
</div>
@code {
[Inject] ArchiveService Archives { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] AppConfiguration Configuration { get; set; }
[Inject] IJSRuntime JS { get; set; }
const string FileInput = "input[data-archive-file]";
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
"notificationpolicy", "pins", "located", "scheduled", "likes"];
ViewPersonaArchive state = new();
string loadedFor;
HashSet<string> chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"];
bool chosenFlag;
bool busy;
string link;
string pickedName;
bool uploading;
long uploaded;
long uploadSize;
double uploadedPercent;
IJSObjectReference transfer;
DotNetObjectReference<PersonaArchivePanel> self;
CancellationTokenSource polling;
bool Importing => uploading || state.ImportState is "queued" or "running";
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
// another persona: its own archive
protected override async Task OnParametersSetAsync()
{
if (!IsLoading && loadedFor != CascadingState.PersonaId)
await LoadData();
}
async Task LoadData()
{
loadedFor = CascadingState.PersonaId;
link = null;
if (loadedFor is null)
return;
state = await Archives.State(loadedFor) ?? new ViewPersonaArchive();
if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running")
Poll();
}
void Poll()
{
if (polling is not null)
return;
polling = new();
var token = polling.Token;
var persona = loadedFor;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(PollEvery);
while (await timer.WaitForNextTickAsync(token))
{
if (persona != loadedFor)
break;
if (await Archives.State(persona, token) is { } read)
state = read;
await InvokeAsync(StateHasChanged);
if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running"))
break;
}
polling = null;
}, token);
}
string PartName(string part) => part switch
{
"posts" => Localizer["Posts and their pictures"],
"profile" => Localizer["Profile: name, words, fields, pictures"],
"follows" => Localizer["Follows, asked again"],
"blocks" => Localizer["Blocks"],
"mutes" => Localizer["Mutes"],
"domainblocks" => Localizer["Blocked servers"],
"lists" => Localizer["Lists"],
"bookmarks" => Localizer["Bookmarks"],
"filters" => Localizer["Filters"],
"tags" => Localizer["Followed hashtags"],
"notificationpolicy" => Localizer["Notification policy"],
"pins" => Localizer["Pinned posts"],
"located" => Localizer["Located posts"],
"scheduled" => Localizer["Scheduled posts"],
"likes" => Localizer["Likes, given again"],
_ => part
};
string CountName(string what) => what switch
{
"posts" => Localizer["posts imported"],
"posts already here" => Localizer["posts already here"],
"posts deleted here" => Localizer["posts deleted here, left deleted"],
"boosts skipped" => Localizer["boosts left out"],
"direct or circle posts skipped" => Localizer["private posts left out"],
"not the archive's own" => Localizer["posts by others left out"],
"not a post" => Localizer["other items left out"],
"media" => Localizer["pictures and files imported"],
"media not in the archive" => Localizer["pictures not in the archive"],
"media refused" => Localizer["pictures refused"],
"located posts" => Localizer["located posts imported"],
"located posts already here" => Localizer["located posts already here"],
"scheduled posts" => Localizer["scheduled posts imported"],
"scheduled posts past or refused" => Localizer["scheduled posts past or refused"],
"followed hashtags" => Localizer["hashtags followed"],
"notification policy" => Localizer["notification policy set"],
"filters" => Localizer["filters imported"],
"pins" => Localizer["posts pinned"],
"blocked servers" => Localizer["servers blocked"],
"blocks" => Localizer["accounts blocked"],
"mutes" => Localizer["accounts muted"],
"follows" => Localizer["follows asked"],
"lists" => Localizer["lists made"],
"list members" => Localizer["list members added"],
"list members not followed" => Localizer["list members not followed"],
"bookmarks" => Localizer["bookmarks imported"],
"likes" => Localizer["likes given"],
"profile" => Localizer["profile updated"],
"pictures refused" => Localizer["pictures refused"],
_ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]],
_ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]],
_ => what
};
void Choose(string part, bool on)
{
if (on)
chosen.Add(part);
else
chosen.Remove(part);
}
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
async Task Export()
{
busy = true;
var result = await Archives.Export(loadedFor);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
state = (ViewPersonaArchive)result.Data;
link = null;
Poll();
}
async Task Link()
{
busy = true;
var result = await Archives.Ticket(loadedFor);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri;
}
async Task Import()
{
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
self ??= DotNetObjectReference.Create(this);
var started = await Archives.StartUpload();
if (started.IsInvalid)
{
Toast.AddResponseError(started);
return;
}
var upload = (ViewArchiveUpload)started.Data;
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri;
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
uploading = false;
if (!sent.Ok)
{
await Archives.CancelUpload(upload.Id);
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
return;
}
var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] });
if (asked.IsInvalid)
{
Toast.AddResponseError(asked);
return;
}
state = (ViewPersonaArchive)asked.Data;
Poll();
}
async Task Stop()
{
var result = await Archives.Stop(loadedFor);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
[JSInvokable]
public Task Progress(long received, long total)
{
(uploaded, uploadSize) = (received, total);
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
return InvokeAsync(StateHasChanged);
}
sealed class UploadOutcome
{
public bool Ok { get; set; }
public int Status { get; set; }
public string Error { get; set; }
}
public async ValueTask DisposeAsync()
{
polling?.Cancel();
self?.Dispose();
if (transfer is null)
return;
try
{
await transfer.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
+72 -25
View File
@@ -18,19 +18,35 @@ else
{
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
<div class="relative w-full">
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
alt="@Localizer["Avatar"]" />
</div>
<div class="flex flex-wrap gap-3 pt-6">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Avatar"]</span>
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
</label>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Avatar"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
@if (HasOwn(account.Avatar) && form.Avatar is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
</label>
@if (HasOwn(account.Header) && form.Header is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
</DButton>
}
</div>
</div>
<label class="flex flex-col gap-1 max-w-md">
@@ -110,11 +126,10 @@ else
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] ILoggingService Logger { get; set; }
// PrivaPub keeps pictures up to 20 MB and resizes them
const long MaximumPictureSize = 20 * 1024 * 1024;
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
Account account;
@@ -122,6 +137,13 @@ else
string loadedPersonaId;
bool isSaving;
List<string> pictureErrors = [];
// the picture types the server takes, read with the account
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
string PictureTypes => string.Join(",", pictureTypes);
// a picture of its own, not the server's default
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
protected override async Task OnParametersSetAsync()
{
@@ -145,6 +167,7 @@ else
{
// the account as the persona sees itself, with its Source
account = await AuthService.CurrentAccount();
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
Reset();
}
@@ -165,32 +188,56 @@ else
{
pictureErrors = [];
var file = eventArgs.File;
if (file.Size > MaximumPictureSize)
if (!pictureTypes.Contains(file.ContentType))
{
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > ProfileForm.MaximumPictureSize)
{
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
return;
}
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
if (isAvatar)
form.Avatar = picture;
else
form.Header = picture;
try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
await fileStream.CopyToAsync(memoryStream);
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
if (isAvatar)
form.Avatar = picture;
else
form.Header = picture;
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
await Logger.ProcessWarning(ex.Message, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
}
}
async Task RemovePicture(bool avatar)
{
isSaving = true;
var result = await AccountService.RemovePicture(avatar);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
account = (Account)result.Data;
CascadingState.PersonaChanged(account);
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
}
async Task Save()
{
isSaving = true;
+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
@@ -21,6 +21,7 @@ namespace decePubClient;
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(SearchResults))]
[JsonSerializable(typeof(MediaAttachment))]
[JsonSerializable(typeof(InstanceV2))]
[JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(List<Notification>))]
[JsonSerializable(typeof(Conversation))]
+23
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";
@@ -41,6 +42,23 @@ public static class APIs
public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview";
public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts";
public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler";
public const string GET_AdminBackups = "clientapi/admin/backups";
public const string POST_AdminBackup = "clientapi/admin/backups";
public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}";
public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket";
public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore";
public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads";
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
}
public static class OAuth
@@ -72,6 +90,11 @@ public static class APIs
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
public const string GET_Notifications = "api/v1/notifications";
public const string POST_Media = "api/v2/media";
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public const string GET_InstanceV2 = "api/v2/instance";
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
public const string DELETE_ProfileHeader = "api/v1/profile/header";
public const string GET_AccountLookup = "api/v1/accounts/lookup";
public const string GET_Relationships = "api/v1/accounts/relationships";
public const string GET_FollowRequests = "api/v1/follow_requests";
+2
View File
@@ -24,6 +24,8 @@ public class ComposeForm
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
public MessageType MessageType { get; set; } = MessageType.Public;
public bool HasContentWarning { get; set; }
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
public bool Sensitive { get; set; }
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string SpoilerText { get; set; }
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
+23
View File
@@ -0,0 +1,23 @@
namespace decePubClient.Models.Mastodon;
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
public class InstanceV2
{
public InstanceConfiguration Configuration { get; set; } = new();
}
public class InstanceConfiguration
{
public MediaConfiguration MediaAttachments { get; set; } = new();
}
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
public class MediaConfiguration
{
public List<string> SupportedMimeTypes { get; set; } = [];
public long ImageSizeLimit { get; set; }
public long VideoSizeLimit { get; set; }
public long ImageMatrixLimit { get; set; }
public long VideoMatrixLimit { get; set; }
public int VideoFrameRateLimit { get; set; }
}
+11 -1
View File
@@ -189,13 +189,23 @@ public class PollForm
public bool Multiple { get; set; }
}
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
public class StatusEditForm
{
public string Status { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public string Language { get; set; }
public List<string> MediaIds { get; set; } = [];
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
}
/// <summary>A posted attachment's new description, sent with the edit.</summary>
public class MediaAttributeForm
{
public string Id { get; set; }
public string Description { get; set; }
}
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
+2
View File
@@ -24,6 +24,8 @@ namespace decePubClient.Models
public bool ThemeIsDarkGray { get; set; } = false;
public bool ShowDonatorBadge { get; set; } = false;
// before publishing pictures without a description, the composer asks once
public bool RemindAltText { get; set; } = true;
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
+7 -3
View File
@@ -91,10 +91,14 @@ namespace decePubClient.Models
static string Flag(bool value) => value ? "true" : "false";
static ByteArrayContent Picture(UploadMedia media)
// PrivaPub keeps pictures up to this size and resizes them
public const long MaximumPictureSize = 20 * 1024 * 1024;
// streamed from the browser's file, read again for each attempt
static StreamContent Picture(UploadMedia media)
{
var picture = new ByteArrayContent(media.Blob);
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
return picture;
}
}
+35 -4
View File
@@ -1,17 +1,48 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.Forms;
namespace decePubClient.Models;
public enum UploadState
{
Uploading,
Processing,
Ready,
Failed
}
/// <summary>
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
/// </summary>
public class UploadMedia
{
public string ContentType { get; set; }
public string FileName { get; set; }
public string AltText { get; set; }
public byte[] Blob { get; set; } = [];
public long Size { get; set; }
[JsonIgnore, Bindable(false)]
public string Base64Preview { get; set; }
public IBrowserFile File { get; set; }
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
[JsonIgnore, Bindable(false)]
public long Size { get; set; }
public string Preview { get; set; }
[JsonIgnore, Bindable(false)]
public UploadState State { get; set; }
[JsonIgnore, Bindable(false)]
public string Id { get; set; }
[JsonIgnore, Bindable(false)]
public string Error { get; set; }
/// <summary>The description the server holds: a different one is sent before the post.</summary>
[JsonIgnore, Bindable(false)]
public string SentAltText { get; set; }
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
[JsonIgnore, Bindable(false)]
public bool Posted { get; set; }
[JsonIgnore, Bindable(false)]
public CancellationTokenSource Cancel { get; set; }
[JsonIgnore, Bindable(false)]
public Task Done { get; set; } = Task.CompletedTask;
}
+15 -1
View File
@@ -4,7 +4,8 @@
<Title>@Localizer["Administration"]</Title>
@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. The
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
up and restore the server. The
other sections wait for PrivaPub endpoints that do not exist yet. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -65,6 +66,19 @@
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<BackupsPanel />
</div>
</ChildContent>
</DCollapse>
</AuthorizeView>
<DCollapse Icon="DCollapseIcon.Arrow">
+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;
+51 -14
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">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</div>
}
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</PhoneSheet>
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
{
@@ -134,9 +143,19 @@
string loadError;
bool isLoadingMore;
bool isComposing;
bool globeExpanded;
// a phone's banner (none when the phone is held sideways: the feed would have no room), the column from lg, or
// full screen
string GlobeClass => globeExpanded
? "fixed inset-0 z-[70] block h-dvh w-full overflow-hidden bg-base-100"
: Filter.ShowGlobe
? "block max-lg:short:hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden"
: "hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden";
(double Latitude, double Longitude)? position;
string positionError;
string loadedPersonaId;
bool waitingForPersona;
FeedQuery lastQuery;
DateTime? lastPollAt;
TimeSpan? lastLoadTook;
@@ -165,9 +184,11 @@
return;
}
// (while the session is still being read the persona isn't known yet: the feed is loading, not empty)
if (CascadingState.PersonaId is null)
{
IsLoading = false;
waitingForPersona = !CascadingState.IsSessionLoaded;
IsLoading = waitingForPersona;
return;
}
@@ -201,11 +222,19 @@
return true;
}
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
// The persona is known (the session was read after the page opened) or changed (NavMenu's switcher): everything on the
// page belongs to it.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
{
waitingForPersona = false;
IsLoading = false;
return;
}
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
return;
waitingForPersona = false;
await Reload();
}
@@ -461,6 +490,14 @@
await InvokeAsync(StateHasChanged);
}
// a composer that opens shows at the top of the feed, where it is, however far down the feed was read
async Task ToggleCompose()
{
isComposing = !isComposing;
if (isComposing && feedModule is not null)
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
}
async Task ShowPending()
{
var shown = pending;
+10 -3
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;
+7 -1
View File
@@ -21,6 +21,9 @@
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
data-remind-alt-text />
<label class="flex flex-col gap-2 max-w-md">
<span class="text-sm">@Localizer["Language"]</span>
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
@@ -130,7 +133,10 @@
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<DataTransfer />
<div class="flex flex-col gap-6">
<PersonaArchivePanel />
<DataTransfer />
</div>
}
else
{
+5
View File
@@ -45,11 +45,14 @@ builder.Services.AddOptions()
.AddScoped<AccountService>()
.AddScoped<PortabilityService>()
.AddScoped<AdminService>()
.AddScoped<ArchiveService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
.AddScoped<DiscoveryService>()
.AddScoped<StreamingService>()
.AddScoped<PublicNoticeService>()
.AddScoped<ConfirmService>()
.AddScoped<OverlayService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
.AddScoped<InstanceService>()
@@ -69,6 +72,8 @@ builder.Services.AddHttpClient("default", client =>
{
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// each request has its own timeout (HttpService.Measure): an upload may take minutes, anything else 100 s
client.Timeout = Timeout.InfiniteTimeSpan;
});
builder.Services.AddSingleton(serviceProvider =>
+450 -21
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,454 @@
<data name="{0} filtered, waiting for you" xml:space="preserve">
<value>{0} filtrate, ti aspettano</value>
</data>
<data name="Menu" xml:space="preserve">
<value>Menu</value>
</data>
<data name="New post" xml:space="preserve">
<value>Nuovo post</value>
</data>
<data name="Open the globe full screen" xml:space="preserve">
<value>Apri il globo a schermo intero</value>
</data>
<data name="Delete this post?" xml:space="preserve">
<value>Eliminare questo post?</value>
</data>
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
<value>Sparisce da qui, e ai server che lo hanno viene chiesto di eliminarlo.</value>
</data>
<data name="Take this post off your wall?" xml:space="preserve">
<value>Togliere questo post dalla tua bacheca?</value>
</data>
<data name="Block {0}?" xml:space="preserve">
<value>Bloccare {0}?</value>
</data>
<data name="Mute {0}?" xml:space="preserve">
<value>Silenziare {0}?</value>
</data>
<data name="Leave {0}?" xml:space="preserve">
<value>Lasciare {0}?</value>
</data>
<data name="Never publish this post?" xml:space="preserve">
<value>Non pubblicare mai questo post?</value>
</data>
<data name="Log out?" xml:space="preserve">
<value>Uscire?</value>
</data>
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Tutte le persone di questo accesso escono da questo dispositivo.</value>
</data>
<data name="Feeds" xml:space="preserve">
<value>Feed</value>
</data>
<data name="Details" xml:space="preserve">
<value>Dettagli</value>
</data>
<data name="Picture" xml:space="preserve">
<value>Immagine</value>
</data>
<data name="Previous picture" xml:space="preserve">
<value>Immagine precedente</value>
</data>
<data name="Next picture" xml:space="preserve">
<value>Immagine successiva</value>
</data>
<data name="Picture {0} of {1}" xml:space="preserve">
<value>Immagine {0} di {1}</value>
</data>
<data name="day/night line" xml:space="preserve">
<value>linea giorno/notte</value>
</data>
<data name="solar noon" xml:space="preserve">
<value>mezzogiorno solare</value>
</data>
<data name="solar midnight" xml:space="preserve">
<value>mezzanotte solare</value>
</data>
<data name="Tropic of Cancer" xml:space="preserve">
<value>Tropico del Cancro</value>
</data>
<data name="Tropic of Capricorn" xml:space="preserve">
<value>Tropico del Capricorno</value>
</data>
<data name="Arctic Circle" xml:space="preserve">
<value>Circolo polare artico</value>
</data>
<data name="Antarctic Circle" xml:space="preserve">
<value>Circolo polare antartico</value>
</data>
<data name="this hour, all year" xml:space="preserve">
<value>a quest'ora, tutto l'anno</value>
</data>
<data name="the Sun never sets" xml:space="preserve">
<value>il Sole non tramonta</value>
</data>
<data name="the Sun never rises" xml:space="preserve">
<value>il Sole non sorge</value>
</data>
<data name="perihelion" xml:space="preserve">
<value>perielio</value>
</data>
<data name="aphelion" xml:space="preserve">
<value>afelio</value>
</data>
<data name="not to scale" xml:space="preserve">
<value>non in scala</value>
</data>
<data name="day map" xml:space="preserve">
<value>mappa del giorno</value>
</data>
<data name="day and night maps" xml:space="preserve">
<value>mappe del giorno e della notte</value>
</data>
<data name="{0}× anisotropic" xml:space="preserve">
<value>anisotropia {0}×</value>
</data>
<data name="Avatar removed." xml:space="preserve">
<value>Avatar rimosso.</value>
</data>
<data name="Header removed." xml:space="preserve">
<value>Intestazione rimossa.</value>
</data>
<data name="Describe them" xml:space="preserve">
<value>Descrivile</value>
</data>
<data name="Media unavailable" xml:space="preserve">
<value>Contenuto non disponibile</value>
</data>
<data name="Processing" xml:space="preserve">
<value>In elaborazione</value>
</data>
<data name="Publish anyway" xml:space="preserve">
<value>Pubblica comunque</value>
</data>
<data name="Ready" xml:space="preserve">
<value>Pronto</value>
</data>
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
<value>Ricordami di descrivere le immagini prima di pubblicare</value>
</data>
<data name="Remove the avatar" xml:space="preserve">
<value>Rimuovi l'avatar</value>
</data>
<data name="Remove the header" xml:space="preserve">
<value>Rimuovi l'intestazione</value>
</data>
<data name="Uploading" xml:space="preserve">
<value>Caricamento</value>
</data>
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
<value>Attendi che i file finiscano di caricarsi, o rimuovi quelli non riusciti.</value>
</data>
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
<value>{0} delle immagini non hanno una descrizione, su cui conta chi non può vederle.</value>
</data>
<data name="&quot;{0}&quot; is not a kind of file this server takes." xml:space="preserve">
<value>"{0}" non è un tipo di file che questo server accetta.</value>
</data>
<data name="A backup is running." xml:space="preserve">
<value>È in corso un backup.</value>
</data>
<data name="A restore is running." xml:space="preserve">
<value>È in corso un ripristino.</value>
</data>
<data name="Back up now" xml:space="preserve">
<value>Fai un backup ora</value>
</data>
<data name="Backups" xml:space="preserve">
<value>Backup</value>
</data>
<data name="Before a deploy" xml:space="preserve">
<value>Prima di un rilascio</value>
</data>
<data name="Before a restore" xml:space="preserve">
<value>Prima di un ripristino</value>
</data>
<data name="Delete this backup?" xml:space="preserve">
<value>Eliminare questo backup?</value>
</data>
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
<value>Eliminati di nuovo: {0} account, {1} persona, {2} gruppi, {3} post. Mantenuti da allora: {4} blocchi, silenziamenti, segnalazioni e altri atti di protezione.</value>
</data>
<data name="Download" xml:space="preserve">
<value>Scarica</value>
</data>
<data name="Download a backup" xml:space="preserve">
<value>Scarica un backup</value>
</data>
<data name="Download {0}" xml:space="preserve">
<value>Scarica {0}</value>
</data>
<data name="Get the link" xml:space="preserve">
<value>Ottieni il link</value>
</data>
<data name="It holds every persona&#x27;s private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
<value>Contiene le chiavi private e i post privati di ogni persona: tienilo dove solo tu puoi leggerlo. La tua password, per avere un link:</value>
</data>
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
<value>I suoi file vengono eliminati; il server non viene toccato.</value>
</data>
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
<value>Creati dopo il backup, ora eliminati mantenendo i loro nomi: {0}.</value>
</data>
<data name="Made by hand" xml:space="preserve">
<value>Fatto a mano</value>
</data>
<data name="Nightly" xml:space="preserve">
<value>Notturno</value>
</data>
<data name="No backup yet." xml:space="preserve">
<value>Ancora nessun backup.</value>
</data>
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
<value>PrivaPub si sta riavviando per ripristinare {0}. Tutti, tu compreso, accederanno di nuovo quando tornerà.</value>
</data>
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
<value>PrivaPub si ferma, ripristina questo backup e riparte, in pochi minuti. Tutti dovranno accedere di nuovo.</value>
</data>
<data name="Restore" xml:space="preserve">
<value>Ripristina</value>
</data>
<data name="Restore the backup of {0}?" xml:space="preserve">
<value>Ripristinare il backup del {0}?</value>
</data>
<data name="Restored {0}" xml:space="preserve">
<value>Ripristinato {0}</value>
</data>
<data name="The backup has started." xml:space="preserve">
<value>Il backup è iniziato.</value>
</data>
<data name="The backup {0} was uploaded." xml:space="preserve">
<value>Il backup {0} è stato caricato.</value>
</data>
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
<value>L'ultimo ripristino, di {0}, è stato abbandonato; non è cambiato nulla.</value>
</data>
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
<value>Il link funziona per dieci minuti, solo per questo backup.</value>
</data>
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
<value>Il ripristino di {0}, chiesto da {1}, è {2}.</value>
</data>
<data name="The upload failed: {0}" xml:space="preserve">
<value>Il caricamento non è riuscito: {0}</value>
</data>
<data name="Type {0} to confirm" xml:space="preserve">
<value>Scrivi {0} per confermare</value>
</data>
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
<value>Carica un backup scaricato da qui (un file .tar):</value>
</data>
<data name="Uploaded" xml:space="preserve">
<value>Caricato</value>
</data>
<data name="What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names." xml:space="preserve">
<value>Quanto fatto da allora va perso: post, modifiche, richieste di seguire, impostazioni. Ciò che protegge no: eliminazioni, blocchi, silenziamenti, server bloccati, segnalazioni, filtri e nomi riservati fatti da allora restano, e i follower restano quelli di adesso. Account, persona e gruppi creati da allora vengono eliminati, mantenendo i loro nomi.</value>
</data>
<data name="asked for by {0}" xml:space="preserve">
<value>chiesto da {0}</value>
</data>
<data name="not read at one instant" xml:space="preserve">
<value>non letto in un unico istante</value>
</data>
<data name="{0} documents in {1} collections" xml:space="preserve">
<value>{0} documenti in {1} collezioni</value>
</data>
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
<value>{0} documenti in {1} collezioni; {2} file multimediali recuperati, {3} mancanti.</value>
</data>
<data name="{0} free on the server&#x27;s disk" xml:space="preserve">
<value>{0} liberi sul disco del server</value>
</data>
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
<value>{0} account sono stati eliminati da allora; un ripristino li elimina di nuovo.</value>
</data>
<data name="{0} media files ({1})" xml:space="preserve">
<value>{0} file multimediali ({1})</value>
</data>
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
<value>{0} file multimediali elencati, conservati dai backup notturni</value>
</data>
<data name="{0} missing" xml:space="preserve">
<value>{0} mancanti</value>
</data>
<data name="{0} of {1}" xml:space="preserve">
<value>{0} di {1}</value>
</data>
<data name="{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}." xml:space="preserve">
<value>{0} post creati da allora risultano eliminati; {1} file sono andati nel cestino; {2} sessioni terminate. Il server com'era prima è il backup {3}.</value>
</data>
<data name="pending" xml:space="preserve">
<value>in attesa</value>
</data>
<data name="running" xml:space="preserve">
<value>in corso</value>
</data>
<data name="unreadable" xml:space="preserve">
<value>illeggibile</value>
</data>
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona&#x27;s profile without being sent to anyone; follows are asked again." xml:space="preserve">
<value>Un archivio fatto qui, o da Mastodon. I suoi post arrivano sul profilo di questa persona senza essere inviati a nessuno; i follow vengono richiesti di nuovo.</value>
</data>
<data name="Blocks" xml:space="preserve">
<value>Blocchi</value>
</data>
<data name="Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas." xml:space="preserve">
<value>Tutto ciò che la persona ha pubblicato, con le sue immagini, chi segue, blocca e silenzia, le sue liste, i segnalibri e i filtri, nell'archivio che fa Mastodon. Niente del tuo account né delle tue altre persona.</value>
</data>
<data name="Follows, asked again" xml:space="preserve">
<value>Follow, richiesti di nuovo</value>
</data>
<data name="From this persona&#x27;s own archive." xml:space="preserve">
<value>Dall'archivio di questa stessa persona.</value>
</data>
<data name="From {0}." xml:space="preserve">
<value>Da {0}.</value>
</data>
<data name="Imported." xml:space="preserve">
<value>Importato.</value>
</data>
<data name="Importing: {0} of {1} read." xml:space="preserve">
<value>Importazione: {0} di {1} letti.</value>
</data>
<data name="Likes, given again" xml:space="preserve">
<value>Mi piace, dati di nuovo</value>
</data>
<data name="Liking again tells each post&#x27;s author; located and scheduled posts come from PrivaPub&#x27;s archives only." xml:space="preserve">
<value>Rimettere i mi piace lo fa sapere all'autore di ogni post; i post localizzati e programmati vengono solo dagli archivi di PrivaPub.</value>
</data>
<data name="Located posts" xml:space="preserve">
<value>Post localizzati</value>
</data>
<data name="Made {0}; kept until {1}." xml:space="preserve">
<value>Fatto {0}; conservato fino al {1}.</value>
</data>
<data name="Make the archive" xml:space="preserve">
<value>Crea l'archivio</value>
</data>
<data name="Mutes" xml:space="preserve">
<value>Silenziamenti</value>
</data>
<data name="Notification policy" xml:space="preserve">
<value>Regole delle notifiche</value>
</data>
<data name="Pinned posts" xml:space="preserve">
<value>Post fissati</value>
</data>
<data name="Posts and their pictures" xml:space="preserve">
<value>Post e le loro immagini</value>
</data>
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
<value>Profilo: nome, descrizione, campi, immagini</value>
</data>
<data name="Restore an archive into this persona" xml:space="preserve">
<value>Ripristina un archivio in questa persona</value>
</data>
<data name="Sending {0} of {1}" xml:space="preserve">
<value>Invio di {0} su {1}</value>
</data>
<data name="Stopped." xml:space="preserve">
<value>Interrotto.</value>
</data>
<data name="The archive could not be made: {0}" xml:space="preserve">
<value>Non è stato possibile creare l'archivio: {0}</value>
</data>
<data name="The import failed: {0}" xml:space="preserve">
<value>L'importazione non è riuscita: {0}</value>
</data>
<data name="This persona&#x27;s archive" xml:space="preserve">
<value>L'archivio di questa persona</value>
</data>
<data name="Waiting to start." xml:space="preserve">
<value>In attesa di iniziare.</value>
</data>
<data name="accounts blocked" xml:space="preserve">
<value>account bloccati</value>
</data>
<data name="accounts muted" xml:space="preserve">
<value>account silenziati</value>
</data>
<data name="already there: {0}" xml:space="preserve">
<value>già presenti: {0}</value>
</data>
<data name="bookmarks imported" xml:space="preserve">
<value>segnalibri importati</value>
</data>
<data name="boosts left out" xml:space="preserve">
<value>condivisioni escluse</value>
</data>
<data name="filters imported" xml:space="preserve">
<value>filtri importati</value>
</data>
<data name="follows asked" xml:space="preserve">
<value>follow richiesti</value>
</data>
<data name="hashtags followed" xml:space="preserve">
<value>hashtag seguiti</value>
</data>
<data name="likes given" xml:space="preserve">
<value>mi piace dati</value>
</data>
<data name="list members added" xml:space="preserve">
<value>membri aggiunti alle liste</value>
</data>
<data name="list members not followed" xml:space="preserve">
<value>membri delle liste non seguiti</value>
</data>
<data name="lists made" xml:space="preserve">
<value>liste create</value>
</data>
<data name="located posts already here" xml:space="preserve">
<value>post localizzati già presenti</value>
</data>
<data name="located posts imported" xml:space="preserve">
<value>post localizzati importati</value>
</data>
<data name="not found: {0}" xml:space="preserve">
<value>non trovati: {0}</value>
</data>
<data name="notification policy set" xml:space="preserve">
<value>regole delle notifiche impostate</value>
</data>
<data name="other items left out" xml:space="preserve">
<value>altri elementi esclusi</value>
</data>
<data name="pictures and files imported" xml:space="preserve">
<value>immagini e file importati</value>
</data>
<data name="pictures not in the archive" xml:space="preserve">
<value>immagini assenti dall'archivio</value>
</data>
<data name="pictures refused" xml:space="preserve">
<value>immagini rifiutate</value>
</data>
<data name="posts already here" xml:space="preserve">
<value>post già presenti</value>
</data>
<data name="posts by others left out" xml:space="preserve">
<value>post di altri esclusi</value>
</data>
<data name="posts deleted here, left deleted" xml:space="preserve">
<value>post eliminati qui, lasciati eliminati</value>
</data>
<data name="posts imported" xml:space="preserve">
<value>post importati</value>
</data>
<data name="posts pinned" xml:space="preserve">
<value>post fissati</value>
</data>
<data name="private posts left out" xml:space="preserve">
<value>post privati esclusi</value>
</data>
<data name="profile updated" xml:space="preserve">
<value>profilo aggiornato</value>
</data>
<data name="scheduled posts imported" xml:space="preserve">
<value>post programmati importati</value>
</data>
<data name="scheduled posts past or refused" xml:space="preserve">
<value>post programmati scaduti o rifiutati</value>
</data>
<data name="servers blocked" xml:space="preserve">
<value>server bloccati</value>
</data>
</root>
+450 -21
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,454 @@
<data name="{0} filtered, waiting for you" xml:space="preserve">
<value>{0} filtered, waiting for you</value>
</data>
<data name="Menu" xml:space="preserve">
<value>Menu</value>
</data>
<data name="New post" xml:space="preserve">
<value>New post</value>
</data>
<data name="Open the globe full screen" xml:space="preserve">
<value>Open the globe full screen</value>
</data>
<data name="Delete this post?" xml:space="preserve">
<value>Delete this post?</value>
</data>
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
<value>It goes from here, and the servers that have it are asked to delete it too.</value>
</data>
<data name="Take this post off your wall?" xml:space="preserve">
<value>Take this post off your wall?</value>
</data>
<data name="Block {0}?" xml:space="preserve">
<value>Block {0}?</value>
</data>
<data name="Mute {0}?" xml:space="preserve">
<value>Mute {0}?</value>
</data>
<data name="Leave {0}?" xml:space="preserve">
<value>Leave {0}?</value>
</data>
<data name="Never publish this post?" xml:space="preserve">
<value>Never publish this post?</value>
</data>
<data name="Log out?" xml:space="preserve">
<value>Log out?</value>
</data>
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Every persona of this login is signed out on this device.</value>
</data>
<data name="Feeds" xml:space="preserve">
<value>Feeds</value>
</data>
<data name="Details" xml:space="preserve">
<value>Details</value>
</data>
<data name="Picture" xml:space="preserve">
<value>Picture</value>
</data>
<data name="Previous picture" xml:space="preserve">
<value>Previous picture</value>
</data>
<data name="Next picture" xml:space="preserve">
<value>Next picture</value>
</data>
<data name="Picture {0} of {1}" xml:space="preserve">
<value>Picture {0} of {1}</value>
</data>
<data name="day/night line" xml:space="preserve">
<value>day/night line</value>
</data>
<data name="solar noon" xml:space="preserve">
<value>solar noon</value>
</data>
<data name="solar midnight" xml:space="preserve">
<value>solar midnight</value>
</data>
<data name="Tropic of Cancer" xml:space="preserve">
<value>Tropic of Cancer</value>
</data>
<data name="Tropic of Capricorn" xml:space="preserve">
<value>Tropic of Capricorn</value>
</data>
<data name="Arctic Circle" xml:space="preserve">
<value>Arctic Circle</value>
</data>
<data name="Antarctic Circle" xml:space="preserve">
<value>Antarctic Circle</value>
</data>
<data name="this hour, all year" xml:space="preserve">
<value>this hour, all year</value>
</data>
<data name="the Sun never sets" xml:space="preserve">
<value>the Sun never sets</value>
</data>
<data name="the Sun never rises" xml:space="preserve">
<value>the Sun never rises</value>
</data>
<data name="perihelion" xml:space="preserve">
<value>perihelion</value>
</data>
<data name="aphelion" xml:space="preserve">
<value>aphelion</value>
</data>
<data name="not to scale" xml:space="preserve">
<value>not to scale</value>
</data>
<data name="day map" xml:space="preserve">
<value>day map</value>
</data>
<data name="day and night maps" xml:space="preserve">
<value>day and night maps</value>
</data>
<data name="{0}× anisotropic" xml:space="preserve">
<value>{0}× anisotropic</value>
</data>
<data name="Avatar removed." xml:space="preserve">
<value>Avatar removed.</value>
</data>
<data name="Header removed." xml:space="preserve">
<value>Header removed.</value>
</data>
<data name="Describe them" xml:space="preserve">
<value>Describe them</value>
</data>
<data name="Media unavailable" xml:space="preserve">
<value>Media unavailable</value>
</data>
<data name="Processing" xml:space="preserve">
<value>Processing</value>
</data>
<data name="Publish anyway" xml:space="preserve">
<value>Publish anyway</value>
</data>
<data name="Ready" xml:space="preserve">
<value>Ready</value>
</data>
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
<value>Remind me to describe pictures before publishing</value>
</data>
<data name="Remove the avatar" xml:space="preserve">
<value>Remove the avatar</value>
</data>
<data name="Remove the header" xml:space="preserve">
<value>Remove the header</value>
</data>
<data name="Uploading" xml:space="preserve">
<value>Uploading</value>
</data>
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
<value>Wait for the files to finish uploading, or remove the ones that failed.</value>
</data>
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
<value>{0} of the pictures have no description, which people who can't see them rely on.</value>
</data>
<data name="&quot;{0}&quot; is not a kind of file this server takes." xml:space="preserve">
<value>"{0}" is not a kind of file this server takes.</value>
</data>
<data name="A backup is running." xml:space="preserve">
<value>A backup is running.</value>
</data>
<data name="A restore is running." xml:space="preserve">
<value>A restore is running.</value>
</data>
<data name="Back up now" xml:space="preserve">
<value>Back up now</value>
</data>
<data name="Backups" xml:space="preserve">
<value>Backups</value>
</data>
<data name="Before a deploy" xml:space="preserve">
<value>Before a deploy</value>
</data>
<data name="Before a restore" xml:space="preserve">
<value>Before a restore</value>
</data>
<data name="Delete this backup?" xml:space="preserve">
<value>Delete this backup?</value>
</data>
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
<value>Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.</value>
</data>
<data name="Download" xml:space="preserve">
<value>Download</value>
</data>
<data name="Download a backup" xml:space="preserve">
<value>Download a backup</value>
</data>
<data name="Download {0}" xml:space="preserve">
<value>Download {0}</value>
</data>
<data name="Get the link" xml:space="preserve">
<value>Get the link</value>
</data>
<data name="It holds every persona&#x27;s private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
<value>It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:</value>
</data>
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
<value>Its files are deleted; the server itself is untouched.</value>
</data>
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
<value>Made after the backup, now deleted with their names kept: {0}.</value>
</data>
<data name="Made by hand" xml:space="preserve">
<value>Made by hand</value>
</data>
<data name="Nightly" xml:space="preserve">
<value>Nightly</value>
</data>
<data name="No backup yet." xml:space="preserve">
<value>No backup yet.</value>
</data>
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
<value>PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.</value>
</data>
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
<value>PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again.</value>
</data>
<data name="Restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="Restore the backup of {0}?" xml:space="preserve">
<value>Restore the backup of {0}?</value>
</data>
<data name="Restored {0}" xml:space="preserve">
<value>Restored {0}</value>
</data>
<data name="The backup has started." xml:space="preserve">
<value>The backup has started.</value>
</data>
<data name="The backup {0} was uploaded." xml:space="preserve">
<value>The backup {0} was uploaded.</value>
</data>
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
<value>The last restore, of {0}, was abandoned; nothing changed.</value>
</data>
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
<value>The link works for ten minutes, for this backup only.</value>
</data>
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
<value>The restore of {0}, asked for by {1}, is {2}.</value>
</data>
<data name="The upload failed: {0}" xml:space="preserve">
<value>The upload failed: {0}</value>
</data>
<data name="Type {0} to confirm" xml:space="preserve">
<value>Type {0} to confirm</value>
</data>
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
<value>Upload a backup downloaded from here (a .tar file):</value>
</data>
<data name="Uploaded" xml:space="preserve">
<value>Uploaded</value>
</data>
<data name="What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names." xml:space="preserve">
<value>What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names.</value>
</data>
<data name="asked for by {0}" xml:space="preserve">
<value>asked for by {0}</value>
</data>
<data name="not read at one instant" xml:space="preserve">
<value>not read at one instant</value>
</data>
<data name="{0} documents in {1} collections" xml:space="preserve">
<value>{0} documents in {1} collections</value>
</data>
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
<value>{0} documents in {1} collections; {2} media files brought back, {3} missing.</value>
</data>
<data name="{0} free on the server&#x27;s disk" xml:space="preserve">
<value>{0} free on the server's disk</value>
</data>
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
<value>{0} logins were deleted since; a restore deletes them again.</value>
</data>
<data name="{0} media files ({1})" xml:space="preserve">
<value>{0} media files ({1})</value>
</data>
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
<value>{0} media files listed, kept by the nightly backups</value>
</data>
<data name="{0} missing" xml:space="preserve">
<value>{0} missing</value>
</data>
<data name="{0} of {1}" xml:space="preserve">
<value>{0} of {1}</value>
</data>
<data name="{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}." xml:space="preserve">
<value>{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.</value>
</data>
<data name="pending" xml:space="preserve">
<value>pending</value>
</data>
<data name="running" xml:space="preserve">
<value>running</value>
</data>
<data name="unreadable" xml:space="preserve">
<value>unreadable</value>
</data>
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona&#x27;s profile without being sent to anyone; follows are asked again." xml:space="preserve">
<value>An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again.</value>
</data>
<data name="Blocks" xml:space="preserve">
<value>Blocks</value>
</data>
<data name="Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas." xml:space="preserve">
<value>Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas.</value>
</data>
<data name="Follows, asked again" xml:space="preserve">
<value>Follows, asked again</value>
</data>
<data name="From this persona&#x27;s own archive." xml:space="preserve">
<value>From this persona's own archive.</value>
</data>
<data name="From {0}." xml:space="preserve">
<value>From {0}.</value>
</data>
<data name="Imported." xml:space="preserve">
<value>Imported.</value>
</data>
<data name="Importing: {0} of {1} read." xml:space="preserve">
<value>Importing: {0} of {1} read.</value>
</data>
<data name="Likes, given again" xml:space="preserve">
<value>Likes, given again</value>
</data>
<data name="Liking again tells each post&#x27;s author; located and scheduled posts come from PrivaPub&#x27;s archives only." xml:space="preserve">
<value>Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only.</value>
</data>
<data name="Located posts" xml:space="preserve">
<value>Located posts</value>
</data>
<data name="Made {0}; kept until {1}." xml:space="preserve">
<value>Made {0}; kept until {1}.</value>
</data>
<data name="Make the archive" xml:space="preserve">
<value>Make the archive</value>
</data>
<data name="Mutes" xml:space="preserve">
<value>Mutes</value>
</data>
<data name="Notification policy" xml:space="preserve">
<value>Notification policy</value>
</data>
<data name="Pinned posts" xml:space="preserve">
<value>Pinned posts</value>
</data>
<data name="Posts and their pictures" xml:space="preserve">
<value>Posts and their pictures</value>
</data>
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
<value>Profile: name, words, fields, pictures</value>
</data>
<data name="Restore an archive into this persona" xml:space="preserve">
<value>Restore an archive into this persona</value>
</data>
<data name="Sending {0} of {1}" xml:space="preserve">
<value>Sending {0} of {1}</value>
</data>
<data name="Stopped." xml:space="preserve">
<value>Stopped.</value>
</data>
<data name="The archive could not be made: {0}" xml:space="preserve">
<value>The archive could not be made: {0}</value>
</data>
<data name="The import failed: {0}" xml:space="preserve">
<value>The import failed: {0}</value>
</data>
<data name="This persona&#x27;s archive" xml:space="preserve">
<value>This persona's archive</value>
</data>
<data name="Waiting to start." xml:space="preserve">
<value>Waiting to start.</value>
</data>
<data name="accounts blocked" xml:space="preserve">
<value>accounts blocked</value>
</data>
<data name="accounts muted" xml:space="preserve">
<value>accounts muted</value>
</data>
<data name="already there: {0}" xml:space="preserve">
<value>already there: {0}</value>
</data>
<data name="bookmarks imported" xml:space="preserve">
<value>bookmarks imported</value>
</data>
<data name="boosts left out" xml:space="preserve">
<value>boosts left out</value>
</data>
<data name="filters imported" xml:space="preserve">
<value>filters imported</value>
</data>
<data name="follows asked" xml:space="preserve">
<value>follows asked</value>
</data>
<data name="hashtags followed" xml:space="preserve">
<value>hashtags followed</value>
</data>
<data name="likes given" xml:space="preserve">
<value>likes given</value>
</data>
<data name="list members added" xml:space="preserve">
<value>list members added</value>
</data>
<data name="list members not followed" xml:space="preserve">
<value>list members not followed</value>
</data>
<data name="lists made" xml:space="preserve">
<value>lists made</value>
</data>
<data name="located posts already here" xml:space="preserve">
<value>located posts already here</value>
</data>
<data name="located posts imported" xml:space="preserve">
<value>located posts imported</value>
</data>
<data name="not found: {0}" xml:space="preserve">
<value>not found: {0}</value>
</data>
<data name="notification policy set" xml:space="preserve">
<value>notification policy set</value>
</data>
<data name="other items left out" xml:space="preserve">
<value>other items left out</value>
</data>
<data name="pictures and files imported" xml:space="preserve">
<value>pictures and files imported</value>
</data>
<data name="pictures not in the archive" xml:space="preserve">
<value>pictures not in the archive</value>
</data>
<data name="pictures refused" xml:space="preserve">
<value>pictures refused</value>
</data>
<data name="posts already here" xml:space="preserve">
<value>posts already here</value>
</data>
<data name="posts by others left out" xml:space="preserve">
<value>posts by others left out</value>
</data>
<data name="posts deleted here, left deleted" xml:space="preserve">
<value>posts deleted here, left deleted</value>
</data>
<data name="posts imported" xml:space="preserve">
<value>posts imported</value>
</data>
<data name="posts pinned" xml:space="preserve">
<value>posts pinned</value>
</data>
<data name="private posts left out" xml:space="preserve">
<value>private posts left out</value>
</data>
<data name="profile updated" xml:space="preserve">
<value>profile updated</value>
</data>
<data name="scheduled posts imported" xml:space="preserve">
<value>scheduled posts imported</value>
</data>
<data name="scheduled posts past or refused" xml:space="preserve">
<value>scheduled posts past or refused</value>
</data>
<data name="servers blocked" xml:space="preserve">
<value>servers blocked</value>
</data>
</root>
+14
View File
@@ -221,6 +221,20 @@ namespace decePubClient.Services
: new WebResult { Data = account };
}
/// <summary>Removes the persona's avatar or header (the default shows again); Data is the account as it is now.</summary>
public async Task<WebResult> RemovePicture(bool avatar, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(avatar ? APIs.Mastodon.DELETE_ProfileAvatar : APIs.Mastodon.DELETE_ProfileHeader,
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
return account?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = account };
}
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
+68
View File
@@ -62,6 +62,74 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The server's backups, what runs and the last restore; null on failure.</summary>
public async Task<ViewBackups> Backups(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminBackups, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewBackups>(cancellationToken);
await LogFailure(response, nameof(Backups), cancellationToken);
return null;
}
/// <summary>Starts a backup; it runs on while the list says so.</summary>
public async Task<WebResult> BackUp(CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackup, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<WebResult> DeleteBackup(string id, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackup(id), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>A download link for a backup, for the password; Data is the ViewBackupTicket.</summary>
public async Task<WebResult> BackupTicket(string id, string password, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupTicket(id), new BackupPasswordForm { Password = password },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupTicket>(cancellationToken) };
}
/// <summary>Asks for a backup to be restored: PrivaPub stops within seconds and restores it as it starts again.</summary>
public async Task<WebResult> RestoreBackup(string id, RestoreBackupForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupRestore(id), form, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>An upload to send a backup's pieces to (transfer.js); Data is the ViewBackupUpload.</summary>
public async Task<WebResult> StartBackupUpload(CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackupUpload, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackupUpload>(cancellationToken) };
}
/// <summary>The whole upload read into a backup; Data is the ViewBackup.</summary>
public async Task<WebResult> FinishBackupUpload(string id, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupUploadFinish(id), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewBackup>(cancellationToken) };
}
public async Task<WebResult> CancelBackupUpload(string id, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackupUpload(id), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
+15
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Components;
using decePubClient.Models;
using Microsoft.JSInterop;
@@ -41,6 +42,20 @@ public class AppStatusService(IJSRuntime jsRuntime)
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
// whether the navigation being decided came from the Back (or Forward) button
public bool TookBack() => JsSyncRuntime.Invoke<bool>("tookBack");
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
// read once: a card asks it for each gifv it shows
bool? reducedMotion;
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
// a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
+84
View File
@@ -0,0 +1,84 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
namespace decePubClient.Services
{
/// <summary>
/// A persona's whole archive on PrivaPub (/clientapi/persona/{id}/archive): made in a job and downloaded through a
/// link, or another archive (its own, or Mastodon's) uploaded in pieces by js/transfer.js and imported into it, the
/// parts chosen, until it is done or asked to stop. As the root. Values null on failure; nothing throws.
/// </summary>
public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<ViewPersonaArchive> State(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken);
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ArchiveService)}.{nameof(State)}", cancellationToken);
return null;
}
/// <summary>Asks for the archive to be made; Data is the state.</summary>
public async Task<WebResult> Export(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
}
/// <summary>A link to download the archive made; Data is the ViewArchiveTicket.</summary>
public async Task<WebResult> Ticket(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveTicket(personaId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveTicket>(cancellationToken) };
}
/// <summary>An upload for an archive's pieces; Data is the ViewArchiveUpload.</summary>
public async Task<WebResult> StartUpload(CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_PersonaArchiveUpload, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveUpload>(cancellationToken) };
}
public async Task<WebResult> CancelUpload(string uploadId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_PersonaArchiveUpload(uploadId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The uploaded archive imported into the persona; Data is the state.</summary>
public async Task<WebResult> Import(string personaId, ImportPersonaArchiveForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImport(personaId), form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
}
public async Task<WebResult> Stop(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImportStop(personaId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
}
}
+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);
}
}
+15 -1
View File
@@ -310,10 +310,24 @@ namespace decePubClient.Services
}
/// <summary>Sends the request and records it in RequestStats.</summary>
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
{
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
var started = Stopwatch.GetTimestamp();
var response = await httpClient.SendAsync(request, cancellationToken);
HttpResponseMessage response;
try
{
response = await httpClient.SendAsync(request, timeout.Token);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
}
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
+59 -5
View File
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
/// <summary>
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
/// </summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
static readonly MediaConfiguration Fallback = new()
{
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
ImageSizeLimit = 16 * 1024 * 1024,
VideoSizeLimit = 99 * 1024 * 1024
};
MediaConfiguration limits;
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
{
if (limits is not null)
return limits;
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
return limits = instance.Configuration.MediaAttachments;
return Fallback;
}
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{
var file = new ByteArrayContent(media.Blob);
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description");
@@ -29,6 +54,35 @@ namespace decePubClient.Services
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
/// when it can't be.</summary>
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
if (response.StatusCode == HttpStatusCode.PartialContent)
return new WebResult();
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
+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);
}
}
}
}
+35 -18
View File
@@ -1,3 +1,4 @@
using collAnon.Client.Services;
using System.Text;
using decePubClient.Extensions;
@@ -35,7 +36,7 @@ namespace decePubClient.Services
/// reports the rows it could not do.
/// </summary>
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
InstanceService instances)
InstanceService instances, CoalescingStringLocalizer localizer)
{
/// <summary>The most rows read from an export or an import file.</summary>
public const int MaxRows = 5000;
@@ -53,23 +54,33 @@ namespace decePubClient.Services
};
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
sealed class Stop
{
var rows = data switch
{
PortableData.Follows => await Follows(personaId, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, cancellationToken)
};
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
public WebResult Failed { get; set; }
}
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
var stop = new Stop();
var rows = data switch
{
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(stop, cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, stop, cancellationToken)
};
if (stop.Failed is { } failed)
return failed;
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
}
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
@@ -80,9 +91,9 @@ namespace decePubClient.Services
];
}
async Task<List<string>> Mutes(CancellationToken cancellationToken)
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
{
var muted = await Pages(accounts.Mutes, cancellationToken);
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
@@ -99,7 +110,7 @@ namespace decePubClient.Services
return rows;
}
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
{
var rows = new List<string>();
var maxId = default(string);
@@ -107,7 +118,10 @@ namespace decePubClient.Services
{
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
if (result.IsInvalid || result.Data is not FeedPage page)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return rows;
}
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
maxId = page.Posts.Count == 0 ? null : page.MaxId;
}
@@ -116,7 +130,7 @@ namespace decePubClient.Services
}
// every page of an account list, up to MaxRows
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
{
var all = new List<Account>();
var maxId = default(string);
@@ -124,7 +138,10 @@ namespace decePubClient.Services
{
var result = await page(maxId, cancellationToken);
if (result.IsInvalid || result.Data is not AccountPage accountPage)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return all;
}
all.AddRange(accountPage.Accounts);
maxId = accountPage.MaxId;
}
+44 -13
View File
@@ -33,27 +33,25 @@ namespace decePubClient.Services
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
/// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
/// it is picked); a description changed since is sent first.</summary>
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
{
if (form.IsLocated || form.GroupId is not null)
return await PublishThroughClientApi(form, personaId, cancellationToken);
var mediaIds = new List<string>();
foreach (var media in form.Media)
{
var uploaded = await mediaService.Upload(media, cancellationToken);
if (uploaded.IsInvalid)
return uploaded;
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
}
if (form.Media.Any(m => m.State != UploadState.Ready))
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
return undescribed;
var mediaIds = form.Media.Select(m => m.Id).ToList();
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
{
Status = form.Content ?? string.Empty,
MediaIds = mediaIds,
InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning,
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
@@ -70,6 +68,20 @@ namespace decePubClient.Services
return new WebResult { Data = FeedPost.Of(status) };
}
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
// described again through the edit's media_attributes)
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
{
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
{
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
if (described.IsInvalid)
return described;
media.SentAltText = media.AltText;
}
return new WebResult();
}
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
{
@@ -104,15 +116,23 @@ namespace decePubClient.Services
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
}
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
/// changed; Data is the edited Status.</summary>
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
{
if (form.Media.Any(m => m.State != UploadState.Ready))
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
return undescribed;
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
{
Status = form.Content ?? string.Empty,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Sensitive = form.HasContentWarning,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
MediaIds = [.. form.Media.Select(m => m.Id)],
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
@@ -228,6 +248,17 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken);
}
/// <summary>Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.</summary>
public async Task<WebResult> DeleteLocated(string postId, string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePost, new DeletePostForm { AvatarId = personaId, PostId = postId },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
+42 -9
View File
@@ -1,14 +1,47 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@implements IDisposable
<div class="relative flex flex-col md:flex-row w-full h-full">
<NavMenu />
@* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's
Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own
navigation closes them all and goes on. *@
<CascadingValue Value="(IDOverlayHost)Overlays" IsFixed="true">
<div class="relative flex flex-col md:flex-row w-full h-full">
<NavMenu />
<div class="block flex-1 relative w-full h-full">
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
@Body
<div class="block flex-1 relative w-full h-full">
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
@Body
</div>
</div>
</div>
</div>
<ToastHost />
<PublicNoticeHost />
<ToastHost />
<PublicNoticeHost />
<ConfirmHost />
</CascadingValue>
@code {
[Inject] OverlayService Overlays { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] AppStatusService Status { get; set; }
IDisposable changing;
protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
async ValueTask OnLocationChanging(LocationChangingContext context)
{
var back = !context.IsNavigationIntercepted && Status.TookBack();
if (!Overlays.HasOpen)
return;
if (back)
{
context.PreventNavigation();
await Overlays.CloseTop(context.CancellationToken);
return;
}
await Overlays.CloseAll(context.CancellationToken);
}
public void Dispose() => changing?.Dispose();
}
+79 -20
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"
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
</a>
</div>
<a class="md:hidden grid place-items-center h-11 w-11" href="settings" aria-label="@Localizer["Settings"]" title="@Localizer["Settings"]">
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full neo-raised neo-2xs"
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
</a>
</nav>
@code {
string menuToggle = "hidden";
[Inject] ConfirmService Confirm { get; set; }
[Inject] OverlayService Overlays { get; set; }
bool isMenuOpen;
IDisposable overlay;
// open, the Back button closes it
void SetMenu(bool open)
{
isMenuOpen = open;
if (open && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(() =>
{
SetMenu(false);
StateHasChanged();
}));
else if (!open && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
bool isPersonaMenuOpen;
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
@@ -144,20 +175,48 @@
protected override void OnInitialized()
{
IsLoading = false;
if (!CascadingState.Status.IsMobileMedia())
menuToggle = default;
Navigation.LocationChanged += OnLocationChanged;
}
void OnLocationChanged(object sender, LocationChangedEventArgs e)
{
if (!isMenuOpen)
return;
SetMenu(false);
InvokeAsync(StateHasChanged);
}
void CloseMenu() => SetMenu(false);
void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
SetMenu(false);
}
public override async ValueTask DisposeAsync()
{
Navigation.LocationChanged -= OnLocationChanged;
overlay?.Dispose();
await base.DisposeAsync();
}
async Task SwitchPersona(string personaId)
{
SetMenu(false);
var result = await CascadingState.SwitchPersona(personaId);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
private void ToggleNavMenu()
private void ToggleNavMenu() => SetMenu(!isMenuOpen);
async Task Logout()
{
menuToggle = menuToggle is { Length: > 0 } ? default : "hidden";
SetMenu(false);
if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true))
return;
Navigation.NavigateTo("logout");
}
protected async Task ResetToOriginalColour()
+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]!;
+138
View File
@@ -0,0 +1,138 @@
using System.Formats.Tar;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
// downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
// pieces; each step checked on /clientapi
public sealed class BackupsTests(Browser browser)
{
const string Root = PastureAdmin.Root;
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
internal static async Task<ILocator> Open(IPage page)
{
await page.GotoAsync($"{Pasture.Site}/administration");
var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
await header.ClickAsync();
var panel = header.Locator("xpath=..").Locator("[data-backups]");
await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
return panel;
}
internal static async Task<string> BackUp(ILocator panel)
{
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).ToHashSet();
await panel.Locator("[data-backup-now]").ClickAsync();
var made = default(string);
Assert.True(await Pasture.Eventually(async () =>
{
var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
return made is not null && list["running"] is null;
}, 300), "the backup was never made");
await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
return made;
}
[Fact]
public async Task A_backup_is_made_downloaded_and_uploaded_again()
{
await using var session = await SignIn.As(browser, Admin);
var page = session.Page;
var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
try
{
await Results.Cell("client.admin.backup", "privapub", Root, async () =>
{
var panel = await Open(page);
var id = await BackUp(panel);
var row = panel.Locator($"[data-backup='{id}']");
await Assertions.Expect(row).ToContainTextAsync("Made by hand");
// the link, for the password
await row.Locator("[data-backup-download]").ClickAsync();
var dialog = page.Locator("[data-backup-download-dialog]");
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
await dialog.Locator("[data-backup-confirm]").ClickAsync();
var link = dialog.Locator("[data-backup-link]");
await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
await download.SaveAsAsync(file);
var names = new List<string>();
await using (var reader = new TarReader(File.OpenRead(file)))
while (await reader.GetNextEntryAsync() is { } entry)
names.Add(entry.Name);
Assert.Contains($"{id}/manifest.json", names);
Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
// deleted (asked first), then uploaded again from the file
await row.Locator("[data-backup-delete]").ClickAsync();
await page.Locator("[data-confirm-yes]").ClickAsync();
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue<string>() == id);
await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
await panel.Locator("[data-backup-upload-start]").ClickAsync();
await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!;
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
await session.AssertGuards();
}, page);
}
finally
{
File.Delete(file);
}
}
}
// A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
// ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
// ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
// again (town.sh renew privapub).
[Trait("Category", "Restore")]
public sealed class RestoreTests(Browser browser)
{
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
[Fact]
public async Task A_backup_is_restored_and_everyone_signs_in_again()
{
await using var session = await SignIn.As(browser, Admin);
var page = session.Page;
await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
{
var panel = await BackupsTests.Open(page);
var id = await BackupsTests.BackUp(panel);
var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue<string>();
await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
var dialog = page.Locator("[data-backup-restore-dialog]");
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
var confirm = dialog.Locator("[data-backup-confirm]");
await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
await Assertions.Expect(confirm).ToBeDisabledAsync();
await dialog.Locator("[data-backup-host]").FillAsync(host);
await confirm.ClickAsync();
await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
// once PrivaPub is back, the session it ended sends the page to sign in again
await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
await Pasture.RenewTown();
}, page);
await using var again = await SignIn.As(browser, Admin);
var panelAgain = await BackupsTests.Open(again.Page);
await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
await again.AssertGuards();
}
}
}
+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);
+184
View File
@@ -0,0 +1,184 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
// again, and an avatar set and removed
public sealed class MediaTests(Browser browser)
{
// a 1×1 PNG
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
static async Task OpenComposer(IPage page)
{
await page.GotoAsync($"{Pasture.Site}/");
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
}
static Task Attach(IPage page, string name) =>
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
{
JsonNode latest = null;
Assert.True(await Pasture.Eventually(async () =>
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
return latest;
}
[Fact]
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
{
var persona = Pasture.Personas().First();
var text = $"a picture {Guid.NewGuid():N}"[..20];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
{
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "dot.png");
// uploaded on its own, before anything is published
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
await Submit(page).ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_picture_without_a_description_is_asked_about_once()
{
var persona = Pasture.Personas().First();
var text = $"undescribed {Guid.NewGuid():N}"[..22];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
{
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "dot.png");
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Submit(page).ClickAsync();
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task A_failed_upload_is_retried_alone()
{
var persona = Pasture.Personas().First();
var text = $"retried {Guid.NewGuid():N}"[..18];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
string made = null;
try
{
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
{
// the second upload is refused once, as a server would refuse it
var uploads = 0;
await page.RouteAsync("**/api/v2/media", async route =>
{
if (Interlocked.Increment(ref uploads) == 2)
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
else
await route.ContinueAsync();
});
await OpenComposer(page);
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await Attach(page, "first.png");
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Attach(page, "second.png");
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
await page.Locator("[data-upload-retry]").ClickAsync();
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
await alt.FillAsync("a dot");
await Submit(page).ClickAsync();
var post = await Published(persona, text);
made = post["id"]!.GetValue<string>();
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
// the first file was sent once: one upload for it, two for the second (refused, then retried)
Assert.Equal(3, uploads);
await session.AssertGuards();
}, page);
}
finally
{
if (made is not null)
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
}
}
[Fact]
public async Task An_avatar_is_set_and_removed_from_settings()
{
var persona = Pasture.Personas().Last();
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/settings");
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
await header.ClickAsync();
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
await editor.WaitForAsync(new() { Timeout = 30000 });
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
await editor.Locator("[data-profile-save]").ClickAsync();
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
Assert.DoesNotContain("missing", set);
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
await session.AssertGuards();
}, page);
}
}
}
+82
View File
@@ -0,0 +1,82 @@
using System.IO.Compression;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
// its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
// again after, so the town stays as seeded
public sealed class PersonaArchiveTests(Browser browser)
{
static async Task<ILocator> Open(IPage page)
{
await page.GotoAsync($"{Pasture.Site}/settings");
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
await header.ClickAsync();
var panel = header.Locator("xpath=..").Locator("[data-archive]");
await panel.WaitForAsync(new() { Timeout = 30000 });
return panel;
}
[Fact]
public async Task An_archive_is_downloaded_and_imported_into_another_persona()
{
var personas = Pasture.Personas();
var (source, target) = (personas[0], personas[1]);
var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
try
{
await using (var session = await SignIn.As(browser, source))
{
var page = session.Page;
await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
{
var panel = await Open(page);
await panel.Locator("[data-archive-make]").ClickAsync();
var get = panel.Locator("[data-archive-get-link]");
await get.WaitForAsync(new() { Timeout = 120000 });
await get.ClickAsync();
var link = panel.Locator("[data-archive-link]");
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
await download.SaveAsAsync(file);
using var archive = ZipFile.OpenRead(file);
Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
await session.AssertGuards();
}, page);
}
await using (var session = await SignIn.As(browser, target))
{
var page = session.Page;
await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
{
var panel = await Open(page);
await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
await panel.Locator("[data-archive-import-start]").ClickAsync();
var report = panel.Locator("[data-archive-report][data-archive-state='done']");
await report.WaitForAsync(new() { Timeout = 300000 });
await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
Assert.NotEmpty(copies);
await session.AssertGuards();
}, page);
}
}
finally
{
File.Delete(file);
// the copies go, as if never imported: the town stays as seeded
await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
+ "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
}
}
}
}
+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,21 +28,44 @@ namespace decePubClient.E2E.Support
_playwright?.Dispose();
}
public async Task<Session> Open(string storageState = null)
// a desktop window of 1440x900, or a phone: touch, a coarse pointer, its size and scale, reduced motion so nothing is
// measured halfway through a transition, and the mobile tests' measuring script (Mobile/fit.js) on every page
public async Task<Session> Open(string storageState = null, Phone phone = null)
{
var context = await Chromium.NewContextAsync(new()
var options = phone is null
? new BrowserNewContextOptions { ViewportSize = new() { Width = 1440, Height = 900 } }
: new BrowserNewContextOptions(_playwright.Devices[phone.Preset]) { ReducedMotion = ReducedMotion.Reduce };
if (phone is { Width: > 0 })
{
IgnoreHTTPSErrors = true,
Locale = "en-GB",
TimezoneId = "Europe/Rome",
ViewportSize = new() { Width = 1440, Height = 900 },
ServiceWorkers = ServiceWorkerPolicy.Block,
StorageStatePath = storageState
});
return new Session(context, await context.NewPageAsync());
options.ViewportSize = new() { Width = phone.Width, Height = phone.Height };
options.ScreenSize = new() { Width = phone.Width, Height = phone.Height };
}
options.IgnoreHTTPSErrors = true;
options.Locale = "en-GB";
options.TimezoneId = "Europe/Rome";
options.ServiceWorkers = ServiceWorkerPolicy.Block;
options.StorageStatePath = storageState;
var context = await Chromium.NewContextAsync(options);
if (phone is not null)
await context.AddInitScriptAsync(Mobile.Fit.Script);
return new Session(context, await context.NewPageAsync(), phone);
}
}
// A phone the tests hold: one of Playwright's device presets (they ship inside its package, nothing is downloaded),
// at its own size or at another one
public sealed record Phone(string Key, string Preset, int Width = 0, int Height = 0)
{
public static readonly Phone Narrow360 = new("narrow360", "Pixel 7", 360, 800);
public static readonly Phone IPhoneSE3 = new("iphone-se3", "iPhone SE (3rd gen)");
public static readonly Phone Pixel7 = new("pixel7", "Pixel 7");
public static readonly Phone Pixel7Landscape = new("pixel7-landscape", "Pixel 7 landscape");
public static readonly Phone[] Portrait = [Narrow360, IPhoneSE3, Pixel7];
public static Phone ByKey(string key) => new[] { Narrow360, IPhoneSE3, Pixel7, Pixel7Landscape }.Single(p => p.Key == key);
}
// A browser tab and what it did: every request's host, every console error, every 5xx and Blazor's error bar. The
// guards hold for every test: the app talks only to its own site and to PrivaPub, and never fails silently.
public sealed class Session : IAsyncDisposable
@@ -54,10 +77,13 @@ namespace decePubClient.E2E.Support
public List<string> ServerErrors { get; } = [];
public List<(string Url, string Authorization)> ApiCalls { get; } = [];
public Session(IBrowserContext context, IPage page)
public Phone Phone { get; }
public Session(IBrowserContext context, IPage page, Phone phone = null)
{
Context = context;
Page = page;
Phone = phone;
page.Request += (_, request) =>
{
var uri = new Uri(request.Url);
@@ -96,6 +122,15 @@ namespace decePubClient.E2E.Support
Assert.All(clientApi, auth => Assert.Equal(3, auth["Bearer ".Length..].Split('.').Length));
}
// a phone session that answers like a desktop would test desktop sizes without saying so: the page must see touch
public async Task AssertPhone()
{
if (Phone is null)
return;
Assert.True(await Page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches && matchMedia('(hover: none)').matches"),
$"{Phone.Key} is not emulated as a touch screen");
}
public async ValueTask DisposeAsync() => await Context.CloseAsync();
}
}
+14
View File
@@ -57,6 +57,20 @@ namespace decePubClient.E2E.Support
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
}
// the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session
public static async Task RenewTown()
{
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" })
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"town.sh renew privapub failed: {error}");
}
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
public static async Task Mongo(string script)
{
+52 -2
View File
@@ -15,8 +15,12 @@ namespace decePubClient.E2E.Support
{
var known = await browser.Open(state);
await known.Page.GotoAsync(Pasture.Site + "/");
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
return known;
if (!await Stale(known.Page))
{
await known.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
return known;
}
await known.DisposeAsync();
}
var session = await browser.Open();
await Through(session.Page, persona);
@@ -25,6 +29,52 @@ namespace decePubClient.E2E.Support
return session;
}
// on a phone: the state the desktop sign-in saved, and the feed as on a first visit (no FeedFilter), so the globe
// banner starts closed as a phone's does
public static async Task<Session> As(Browser browser, Pasture.Persona persona, Phone phone)
{
var state = Path.Combine(StateDir, persona.UserName + ".json");
if (!File.Exists(state))
await (await As(browser, persona)).DisposeAsync();
var session = await browser.Open(state, phone);
await session.Page.GotoAsync(Pasture.Site + "/");
if (await Stale(session.Page))
{
// the saved sign-in has expired (the page went to /login): sign in again on a desktop and start over
await session.DisposeAsync();
await (await As(browser, persona, fresh: true)).DisposeAsync();
session = await browser.Open(state, phone);
}
try
{
await session.Page.GotoAsync(Pasture.Site + "/");
await session.Page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await session.Page.GotoAsync(Pasture.Site + "/");
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
}
catch
{
await Evidence(session.Page, $"signin-{phone.Key}-{persona.UserName}");
throw;
}
await session.AssertPhone();
return session;
}
static async Task<bool> Stale(IPage page)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (DateTime.UtcNow < until)
{
if (new Uri(page.Url).AbsolutePath.StartsWith("/login"))
return true;
if (await page.Locator("article[data-uri], [data-feed-empty]").CountAsync() > 0)
return false;
await page.WaitForTimeoutAsync(250);
}
return false;
}
public static async Task Through(IPage page, Pasture.Persona persona)
{
try
+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>
+24 -11
View File
@@ -61,35 +61,48 @@
return pixels
}
function paint(image) {
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
// paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of
// sensitive media) for good. A changed hash, or an image given another source, is painted again.
function paint(element) {
const hash = element.dataset.blurhash
const isImage = element.tagName === "IMG"
if (!hash || element.dataset.blurhashPainted === hash || (isImage && element.complete && element.naturalWidth))
return
image.dataset.blurhashPainted = "1"
element.dataset.blurhashPainted = hash
try {
const pixels = decode(image.dataset.blurhash)
const pixels = decode(hash)
if (!pixels)
return
const canvas = document.createElement("canvas")
canvas.width = size
canvas.height = size
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
image.style.backgroundImage = `url(${canvas.toDataURL()})`
image.style.backgroundSize = "cover"
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
element.style.backgroundImage = `url(${canvas.toDataURL()})`
element.style.backgroundSize = "cover"
if (isImage)
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
} catch (e) {
window.logFromJs?.(e.message, "blurhash")
}
}
const scan = (root) => root.querySelectorAll("img[data-blurhash]").forEach(paint)
const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint)
new MutationObserver(records => {
for (const record of records)
for (const record of records) {
if (record.type === "attributes") {
// another picture in the same element (the viewer's previous and next): painted anew
if (record.attributeName === "src")
delete record.target.dataset.blurhashPainted
paint(record.target)
continue
}
for (const node of record.addedNodes)
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches("img[data-blurhash]"))
if (node.matches("[data-blurhash]"))
paint(node)
scan(node)
}
}).observe(document.documentElement, { childList: true, subtree: true })
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] })
scan(document)
})()
+71 -14
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) {
+65
View File
@@ -0,0 +1,65 @@
// Files sent to PrivaPub in pieces, read by the browser and never copied into .NET's memory: a backup can be gigabytes.
// Each piece is a slice of the picked file PUT at the offset PrivaPub has received; a piece refused (409) or lost to the
// network carries on from what PrivaPub says it has, so a broken upload resumes. Progress goes to listener.Progress.
const attempts = 6;
function pause(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function received(endpoint, token) {
const response = await fetch(endpoint, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok)
throw new Error(`PrivaPub answered ${response.status}`);
return (await response.json()).received;
}
// the picked file's name and size, or null
export function picked(selector) {
const file = document.querySelector(selector)?.files?.[0];
return file ? { name: file.name, size: file.size } : null;
}
// sends the picked file: { ok, size } or { ok: false, status, error }
export async function upload(selector, endpoint, token, chunkBytes, listener) {
const file = document.querySelector(selector)?.files?.[0];
if (!file)
return { ok: false, status: 0, error: "no file" };
let offset = 0;
let failures = 0;
while (offset < file.size) {
const end = Math.min(offset + chunkBytes, file.size);
let response;
try {
response = await fetch(`${endpoint}?offset=${offset}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream" },
body: file.slice(offset, end)
});
} catch (error) {
if (++failures >= attempts)
return { ok: false, status: 0, error: String(error) };
await pause(1000 * 2 ** failures);
try {
offset = await received(endpoint, token);
} catch {
// still away: try the same piece again
}
continue;
}
if (response.ok || response.status === 409) {
const now = (await response.json()).received;
// refused where it already stood: the piece itself is refused, and sending it again changes nothing
if (!response.ok && now === offset && ++failures >= attempts)
return { ok: false, status: 409, error: "the piece was refused" };
if (now !== offset)
failures = 0;
offset = now;
await listener.invokeMethodAsync("Progress", offset, file.size);
continue;
}
return { ok: false, status: response.status, error: await response.text() };
}
return { ok: true, size: file.size };
}
+58 -1
View File
@@ -123,9 +123,66 @@ window.clearAppBadge = () => {
window.logFromJs(e.message, "clearAppBadge")
}
}
// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its
// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes.
document.addEventListener("click", event => {
try {
if (window.matchMedia("(hover: hover)").matches || !(event.target instanceof Element))
return
const card = event.target.closest("[data-thread]")
if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button], .dropdown, .modal, [data-sheet]"))
return
if (!window.getSelection()?.isCollapsed)
return
card.querySelector("a[data-thread-link]")?.click()
} catch (e) {
console.error(e)
window.logFromJs(e.message, "openThread")
}
})
// The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell
// them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the
// capture phase, so it runs before Blazor's own listener.
let backAt = 0
window.addEventListener("popstate", () => backAt = Date.now(), { capture: true })
window.tookBack = () => {
const back = Date.now() - backAt < 1000
backAt = 0
return back
}
window.canHover = () => {
try {
return window.matchMedia("(hover: hover)").matches
} catch (e) {
console.error(e)
window.logFromJs(e.message, "canHover")
return true
}
}
// the selected tab of a sideways-scrolling strip, brought into its middle
window.revealActive = strip => {
try {
strip?.querySelector("[aria-selected=true]")?.scrollIntoView({ inline: "center", block: "nearest" })
} catch (e) {
console.error(e)
window.logFromJs(e.message, "revealActive")
}
}
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
// the reader asked for less motion: what would move by itself (a gifv) waits for a tap
window.prefersReducedMotion = () => {
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
} catch (e) {
console.error(e)
window.logFromJs(e.message, "prefersReducedMotion")
return false
}
}
window.isMobileMedia = () => {
try {
return ((window.innerWidth > 0) ? window.innerWidth : screen.width) <= 768
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
} catch (e) {
console.error(e)
window.logFromJs(e.message, "isMobileMedia")
+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.