45 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 4c88980374 R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About
Build / Build (push) Successful in 1m5s
Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail,
privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to
Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every
save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the
server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements
show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:32:57 +02:00
thepraandClaude Opus 5.5 b8d7858acb R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s
Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it
follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice.
A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a
filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with
"describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting.
MediaType.Documents goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:02:20 +02:00
thepraandClaude Opus 5.5 b731d8c8c0 R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Build / Build (push) Successful in 1m3s
Completion program R3, with PrivaPub's TOTP routes of the same release.

- Settings, the login: TotpEditor turns two-factor sign-in on (the password, then the QR code, the secret and the
  otpauth:// link, then the app's first code; the ten recovery codes shown once with Copy), shows how many recovery
  codes are left (warned at three), and turns it off with the password and a code, asked first.
- Turning it on, like a password change, ends every other session: AuthService.ContinueSession takes the new JWT and
  exchanges the persona's token again.
- Sign-in: PrivaPub's 428 keeps the name and password and asks for the six digits (DOtp), or a recovery code instead;
  a wrong code says so; Back returns to the password.
- window.copyText (main.js) through AppStatusService.CopyText.
- 29 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:33:46 +02:00
thepraandClaude Opus 5.5 7d4b35f0d7 R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login
Completion program R2, with PrivaPub's routes of the same release.

- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
  sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
  there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
  (DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
  token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
  client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:21:59 +02:00
thepraandClaude Opus 5.5 fd2f2e7c65 R1 cleanup: collAnon and dead code gone, no IndexedDB, honest PWA files
Build / Build (push) Successful in 51s
Starts the completion program (SocialPub docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- The localizer's namespace is decePubClient.Services; collAnon is gone from the repo.
- Dead code: PagesBase, VConstants, SettingsOptions, ViewLanguage, unused APIs routes, InstanceService.Self,
  RequestStats.LastOf/AverageMs, PostAnon/DeleteTotallyAnon, unused AppStatusService methods and their main.js helpers
  (the app badge stays for Web Push), ExtensionMethods/SUtility leftovers (Markdig and BlazorZXingJs packages go),
  unused PublicCacheData/PageSettings/AppConfiguration fields, commented-out code.
- The client log in IndexedDB, which nothing ever read, goes with the whole IndexedDB layer (DnetIndexedDb, IStorage):
  logs go to the browser console; main.js deletes the old "data" database once.
- The solution names the real ClientModels project. Unused vendor assets (fontawesome, solid, open-iconic, rxjs) go.
- robots.txt disallows everything; manifest.json and index.html say decePub; the service worker's cache name follows
  each publish's assets version, so old caches are purged.
- MessageType is Visibility (member names kept: they are resx keys); ExpandMessage.razor is Thread.razor (same route).
- i18n: dead keys removed, Italian "camera" is "punto di vista", DInputBase/DCalendar validation messages come from
  ErrorMessages, DataTransfer's spinner has its label.
- CLAUDE.md follows: client storage, JS interop, PWA, the D fields' messages.

Verified: dotnet build decePubClient.csproj and decePubClient.sln -c Release (0 errors, no new warnings); a Pasture
publish served by the pasture's decepub peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00
thepraandClaude Opus 5.5 e4414f1ac9 No test project: tests/e2e goes
Build / Build (push) Successful in 42s
Owner decision 2026-10-17, with SocialPub's PrivaPub.Tests. The e2e project leaves the solution, the csproj's
exclusions, Tailwind's sources and .gitignore; comments no longer point at it, and CLAUDE.md's e2e section becomes
Verifying: a publish with the Pasture environment, which SocialPub's tools/pasture/peers/decepub.sh serves as
https://decepub.test. appsettings.Pasture.json stays for that, and the deploy still fails if it ever ships. The cards'
data-* hooks stay in the markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:28:34 +02:00
thepraandClaude Opus 5.5 5796d62406 Setup and invitations: the first login made from the client, everyone else by a group's invitation
Build / Build (push) Successful in 52s
Deploy / decepub.thepra.dev (push) Successful in 3m3s
PrivaPub's sign-up is closed. While it has no administrator, Login offers /setup, where the first login is made with its
first persona and becomes the administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16: first come,
first served, and setup then closes for good). Everyone else joins at /join?code=: a group's invitation makes the login,
and its first persona joins the group (/clientapi/user/invitation/signup). Login links there, and a group's page shows
the link next to its code. A login without a persona is sent from Login to Settings instead of a dead end. Login, setup
and join share AuthService's StartSession, and all three go without credentials.

e2e (Login/SignUpTests, against the pasture): setup offered on Login, made, the feed reached with Administration, then a
signed-out reader is offered sign-in and invitations only; an invitation link makes a login whose persona joins the
circle. The phone sweep covers /setup and /join: login, setup and join fit every portrait phone, and AccountTests signs
in through the changed Login. The town's signed-in tests were not rerun: today's pasture came up without Mastodon, and
its town seed left stale sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:48 +02:00
thepraandClaude Opus 5.5 4da6a398d7 A ticked checkbox is filled and raised, and a file input is a thumb-sized neo field
Build / Build (push) Successful in 57s
Deploy / decepub.thepra.dev (push) Successful in 1m54s
daisyUI fills a ticked checkbox only for a colour variant, so without one it drew a tick on nothing: a checkbox without a
colour now takes the primary colour, filled and raised like a button once ticked (an inset well while not), and a
radio's dot is the primary colour. A file input's own button (Choose File, Browse…) is raised like the app's buttons
instead of daisyUI's flat neutral block, and on a touch screen the file input is 44px tall like the other fields; it had
stayed at its small 32px. Mobile/ControlsTests opens Settings' Data import/export on the narrowest phone, which the
sweep (sections closed) never reached, and photographs it in both themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 19:52:26 +02:00
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
192 changed files with 12879 additions and 10137 deletions

No files matched your search

-3
View File
@@ -368,6 +368,3 @@ FodyWeavers.xsd
.tools/
wwwroot/css/app.css
# the e2e tests: the published app they serve and the browser state they keep
tests/e2e/.publish/
tests/e2e/.state/
+310 -70
View File
@@ -14,8 +14,6 @@ columns: navigation, the feed with a tab per kind of feed, and a 3D globe that f
in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970`
(`wwwroot/appsettings.Development.json`, never published).
`Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API.
## Sibling repo dependency
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
@@ -45,34 +43,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
## Testing: end to end against the pasture
## Verifying
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
```sh
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
../SocialPub/tools/pasture/town.sh seed village
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
```
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
time and published only then; the deploy fails if it ever ships.
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
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`.
- **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`.
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
ever ships.
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
127.0.0.1:6443"`), and must accept the pasture's CA.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -96,7 +81,7 @@ without network access. NuGet restore still uses nuget.org.
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
The deploy workflow checks for `css/app.css.gz`.
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/`, `wwwroot/vendor/`, `Docs/`), and only
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/` and `wwwroot/vendor/`), and only
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
every variant as a literal, e.g. in a switch arm.
@@ -116,7 +101,14 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
- picking a hue leaves grey.
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
The other sections of Settings belong to the login or the persona (see **Settings** under PrivaPub wiring).
- **The theme is the login's** while `PageSettings.SyncWithLogin` is on (the default; Settings → General turns it off
for this device). `CascadingState` takes the login's theme (`ViewUserSettings`; never its language, which would reload
the app) at sign-in from the `JwtUser` (`SignedIn`, which Login, Setup and Join call instead of `LoadSession`) and at
start-up from `/clientapi/user/settings`, once `savedAt` says a client saved one; while none did, it gives the login
this device's. `UpdatePageSettings` saves them to `/clientapi/user/update/settings` 1.5 s after the last change, one
save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them
back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown.
- `Styles/neo.css` holds the neumorphic layer:
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
old `neomorph is-nxsmall` classes);
@@ -125,9 +117,63 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
shadow with `shadow-none!`.
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
file input's own button (Choose File, Browse…) is raised like the app's buttons.
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
every collapse disappears.
## Phones
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
- **Width and pointer are separate.**
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
daisyUI's own size variables:
- buttons, tabs, inputs, selects and file inputs are 44px tall;
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
the padding box);
- checkboxes and radios are 24px, toggles and range thumbs 28;
- field text is 16px, so iOS doesn't zoom into a focused field.
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
- **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, removing a follower, revoking a quote, leaving a
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the
server's thumbnail.
- **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, a downvote, 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
@@ -174,7 +220,7 @@ supplies:
- `AfterRenderAsyncJobs`, run once after the next render;
- `WhenOnline` / `StartOnlinePolling`.
`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component.
Never redeclare the base's members in a component.
**UI feedback.**
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
@@ -183,6 +229,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
@@ -193,9 +240,11 @@ supplies:
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
PrivaPub redirects it).
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
not carry a JWT: PrivaPub redirects them).
- `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
@@ -209,30 +258,77 @@ supplies:
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
forgets the session.
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub
answers with (`ContinueSession`), drop the stored persona tokens and exchange one for the current persona at once
(the others when next used), so the device stays signed in.
- **Two-factor sign-in** (TOTP, `/clientapi/user/totp/*`): with it on, a right password is answered with a 428
(`AuthService.NeedsOneTimeCode`). Login keeps the name and the password in the form, out of sight, and sends it again
with `LoginForm.OneTimeCode`: the authenticator app's six digits (`DOtp`, `data-login-code`) or, after "Use a recovery
code instead", a recovery code like `abcde-fghjk` (a text field, `data-login-recovery`). A wrong code is a 400, like a
wrong password, with PrivaPub's message. It is turned on and off in Settings (`TotpEditor`, see **Settings**).
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
- **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
closes for good. Login offers it while it waits.
- **Invitations** (`Pages/Join.razor` at `/join?code=`): a group's invitation makes a login whose first persona joins
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
- Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
persona is sent from Login to Settings to make one.
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
All of it goes with no credentials.
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
`/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
threads, mute, block, poll votes, downvotes, who may quote the persona's posts and revoking a quote of one, 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,
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
`/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, the persona's followers and following and removing a follower, `/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
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and
(`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
`InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
`InstanceAdminController`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
`ProvenanceService`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
- **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.
post's downvotes next to its favourites with the persona's own as a toggle (`data-downvote`, in More on a phone as
`data-downvote-phone`; `StatusAction.Downvote`/`Undownvote`, PrivaPub's `/statuses/:id/downvote|undownvote`, answered
with the post, its `privapub.votes.downvoted` set and its favourite dropped). PrivaPub sends votes on every post, so
the card offers the toggle where they mean something (`Content.CanDownvote`: a threadiverse server's post, a Page, or
one with downvotes; never the persona's own). It also shows blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
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`.
- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from
Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long
description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules
(`data-about-rules`, `data-about-rule`), privacy policy (`data-about-privacy`), terms of service (`data-about-terms`,
none while PrivaPub answers 404) and the domain blocks the administrator published (`data-about-blocks`,
`data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized
HTML in `post-content doc-content` (`Styles/feed.css` spaces their headings). Login's heading names the server
(`data-login-title`, `/api/v2/instance` read without credentials and kept for the session).
- **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
@@ -240,8 +336,16 @@ supplies:
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile.
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
lists them to the persona alone (`hide_collections`), so another profile keeps plain counts. A follower is removed
there (`remove_from_followers`, `data-follower-remove`, asked first). The follow requests waiting for the persona show
above its posts (`Components/FollowRequestsPanel.razor`, `data-follow-request`, accepted or refused; hidden while none
waits), and the Notifications tab opens the same panel in a sheet while some wait (`data-follow-requests-open`).
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
@@ -257,39 +361,147 @@ supplies:
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur).
its media (blur). Another's post goes into a filter from its card's menu ("Filter this post", `data-filter-post`,
`Components/FilterPostDialog.razor`: `POST /api/v2/filters/:id/statuses`, a filter that holds it already says so), and
the card reads the post again so the filter applies at once. On /filters the posts a filter holds are read by id when
it opens (`TimelineService.Read`, `/api/v1/statuses?id[]=`) and show their author and words, linking their thread
(`data-filter-status`), or say "Post no longer available" (`data-filter-status-missing`).
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
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;
- a file taken out before posting is deleted from PrivaPub (`MediaService.Discard`, `DELETE /api/v1/media/:id`,
`data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
- **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
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
- **Threads** (`Pages/Thread.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
and adds what was found, in the thread's order.
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
- **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,
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
card's menu ("Who may quote it", `data-quote-policy` holding the current one; `Components/QuotePolicyDialog.razor`,
`data-quote-policy-choice`: anyone, its followers or nobody, `PUT /api/v1/statuses/:id/interaction_policy` with
`quote_approval_policy`; the policy is read back from `quote_approval.automatic`, `StatusService.QuotePolicyOf`). A
post by someone else quoting one of them has "Revoke quote" in its menu (`data-revoke-quote`, asked first; `POST
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
everywhere", see **One sign-in**); 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 (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.
- **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.
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/`): each section is a `DCollapse` marked
`data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation
log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`),
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators:
`/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until
then they have no section.
- **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule
left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it
reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
`AboutService` read the instance again.
- **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description
(`data-instance-short-description`), Markdown description (`data-instance-description`), contact address
(`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`;
picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
`data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy
policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the
weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the
configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The
thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`,
asked first) is saved as soon as it is picked, streamed from the file as multipart `file`.
- **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its
`data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`,
`data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
(`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id.
- **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`,
`data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions;
`data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`,
`-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`);
`data-announcement-delete` asks first.
- **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and
`/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars
part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a
block, since blocking a server again changes its block. `data-server-block-save`.
- **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source`
configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from
`Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
- **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows,
inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the
configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`,
`data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
- **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time
(`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who
(`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note
(`data-log-note`). `data-log-reload`.
- **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their
texts, a removed rule's too.
- **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels'
`Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
ledger's events and the server's own days. Counted per server, never per account.
- A server's name opens `HostDetailDialog` (`data-statistics-host-open`, `/hosts/{host}?days=`): what it says of itself
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
ledger's `channel:…:…:outcome` keys like the servers table; `live` for a day not rolled up yet), its last events, and
"Describe again" (`data-host-describe`, `POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
- Events (`data-statistics-events`, `EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
time, and "Load more" asks for those `before` the last one's time.
- The server's days (`data-statistics-server`, `/server?days=`): its client API's requests by status class, the
documents it served, and its ledger written, dropped and failed.
- **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
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait.
links there while requests wait. A notification is dismissed from its line (`data-notification-dismiss`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
one alone), and the page takes that item out (`Content.OnDismissed`).
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
@@ -310,6 +522,16 @@ supplies:
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
- **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones
shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`,
`-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to
(`data-announcement-reaction="{emoji}"` toggles the persona's own, `data-announcement-react` offers more,
`data-announcement-react-emoji`; `PUT`/`DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They
come live on the user stream (`announcement`, `announcement.reaction`, `announcement.delete`):
`StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it
alone on a polled feed), and raises what it tells as `Announced`; the user stream's posts and notifications are
the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the
feed's cards.
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
@@ -327,8 +549,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,24 +581,20 @@ 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
`ErrorsResource`.
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`, and by the
D fields' parse errors (`DInputBase`, `DCalendar`) through the static `ErrorMessages.InvalidValue` / `InvalidDate`. A new
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
**Client storage** comes in two layers:
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
server cache `ServerInfos`, keyed by `nameof(Type)`.
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
the mock feed's `Message` store out).
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`
and the server cache `ServerInfos`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
still holds it.
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
@@ -366,10 +604,12 @@ it: the root's name, its policies as claims, and the current persona. Pages that
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
synchronous `IJSInProcessRuntime`.
- Each helper reports its errors back through `window.logFromJs`.
- The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `<script>` tags in `wwwroot/index.html`; the feed's
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
`wwwroot/index.html`; the feed's
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
published worker names its cache after the assets manifest's version, so each publish drops the previous cache.
## Development rules
+44 -2
View File
@@ -27,13 +27,28 @@ namespace decePubClient;
[JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ClientLogs))]
[JsonSerializable(typeof(ClientLogException))]
[JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(SetupForm))]
[JsonSerializable(typeof(InvitationLoginForm))]
[JsonSerializable(typeof(ViewServerSetup))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(PasswordRecoveryForm))]
[JsonSerializable(typeof(NewPasswordForm))]
[JsonSerializable(typeof(ViewAccount))]
[JsonSerializable(typeof(EmailChangeForm))]
[JsonSerializable(typeof(EmailCodeForm))]
[JsonSerializable(typeof(PasswordForm))]
[JsonSerializable(typeof(TotpCodeForm))]
[JsonSerializable(typeof(TotpDisableForm))]
[JsonSerializable(typeof(ViewTotpSetup))]
[JsonSerializable(typeof(ViewTotpEnabled))]
[JsonSerializable(typeof(ViewUserSettings))]
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
@@ -49,6 +64,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 +73,32 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewHostDetail))]
[JsonSerializable(typeof(ViewHostInstance))]
[JsonSerializable(typeof(ViewHostDay))]
[JsonSerializable(typeof(ViewInteractionEvent))]
[JsonSerializable(typeof(List<ViewInteractionEvent>))]
[JsonSerializable(typeof(ViewServerDay))]
[JsonSerializable(typeof(List<ViewServerDay>))]
[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))]
[JsonSerializable(typeof(ViewServerSettings))]
[JsonSerializable(typeof(ServerSettingsForm))]
[JsonSerializable(typeof(ViewAnnouncement))]
[JsonSerializable(typeof(List<ViewAnnouncement>))]
[JsonSerializable(typeof(AnnouncementForm))]
[JsonSerializable(typeof(ViewRelay))]
[JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
+136
View File
@@ -0,0 +1,136 @@
@inherits LocalizableComponentBase
@* The persona's own followers or the accounts it follows (/api/v1/accounts/:id/followers, /following), 40 a page by the
Link header, each opening its profile. PrivaPub lists them to the persona alone (hide_collections), so only the
persona's own profile opens this. A follower can be removed (remove_from_followers), asked first. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-account-list="@(Followers ? "followers" : "following")">
<Heading><span class="capitalize">@(Followers ? Localizer["followers"] : Localizer["following"])</span></Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (accounts.Count == 0)
{
<p class="text-sm opacity-70 mt-3" data-account-list-empty>@Localizer["Nobody yet."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var account in accounts)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-account="@account.Acct">
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
@if (Followers)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => RemoveFollower(account)"
data-follower-remove>
<i class="ion-md-person text-error" aria-hidden="true"></i><span>@Localizer["Remove from followers"]</span>
</DButton>
}
</div>
}
</div>
@if (maxId is not null)
{
<div class="flex justify-center mt-3">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-account-list-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
</div>
}
</ChildContent>
</DModal>
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>The persona's own account id.</summary>
[Parameter, EditorRequired] public string AccountId { get; set; }
/// <summary>Its followers, rather than the accounts it follows.</summary>
[Parameter] public bool Followers { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>A follower was removed.</summary>
[Parameter] public EventCallback<Account> OnRemoved { get; set; }
List<Account> accounts = [];
HashSet<string> busy = [];
string maxId;
bool isLoadingMore;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
accounts = [];
maxId = null;
await LoadPage();
}
async Task LoadPage()
{
var result = Followers ? await AccountService.Followers(AccountId, maxId) : await AccountService.Following(AccountId, maxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
var known = accounts.Select(a => a.Id).ToHashSet();
accounts.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
maxId = page.MaxId;
}
async Task LoadMore()
{
isLoadingMore = true;
await LoadPage();
isLoadingMore = false;
}
async Task RemoveFollower(Account account)
{
var name = account.Name ?? account.Acct;
if (!await Confirm.Ask(Localizer["Remove {0} from your followers?", name], Localizer["They no longer see your followers-only posts, and may ask to follow you again."],
Localizer["Remove from followers"], true))
return;
busy.Add(account.Id);
var result = await AccountService.RemoveFromFollowers(account.Id);
busy.Remove(account.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
accounts.Remove(account);
Toast.AddResponseSuccess(Localizer["{0} removed from your followers.", name]);
await OnRemoved.InvokeAsync(account);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+245
View File
@@ -0,0 +1,245 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and
whether they are all day, kept as drafts until published; shown to every persona above its feed while published and
not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions
with it, and every persona's stream hears it go. *@
<div class="flex flex-col gap-3 w-full" data-announcements>
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-announcement-new>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Write an announcement"]</span>
</DButton>
</div>
@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 (announcements.Count == 0)
{
<p class="text-sm opacity-70" data-announcements-empty>@Localizer["No announcement yet."]</p>
}
@foreach (var announcement in announcements)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-announcement="@announcement.Id" data-announcement-state="@StateOf(announcement)">
<div class="flex flex-wrap items-center gap-2">
@switch (StateOf(announcement))
{
case "shown":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Shown now"]</DBadge>
break;
case "ended":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Ended"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Draft"]</DBadge>
break;
}
@if (announcement.AllDay)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["All day"]</DBadge>
}
<span class="text-xs opacity-70" title="@announcement.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)]
</span>
<span class="flex-1"></span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Edit(announcement)" data-announcement-edit>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(announcement.Id)" OnClick="() => Delete(announcement)" data-announcement-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</div>
@if (announcement.StartsAt is not null || announcement.EndsAt is not null)
{
<p class="text-xs opacity-70">@When(announcement)</p>
}
<div class="post-content doc-content text-sm break-words">@((MarkupString)announcement.Html)</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="opacity-70" data-announcement-reads="@announcement.Reads">@Localizer["Dismissed by {0}", announcement.Reads]</span>
@foreach (var reaction in announcement.Reactions)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-announcement-reaction="@reaction.Name">
@reaction.Name <span class="tabular-nums">@reaction.Count</span>
</DBadge>
}
</div>
</article>
}
</div>
@if (editing is not null)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
data-announcement-dialog>
<Heading>@(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"])</Heading>
<ChildContent>
<EditForm Model="editing" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["What it says (Markdown)"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="editing.Text" Immediate="true" rows="6" autocomplete="off" maxlength="5000"
data-announcement-text />
</label>
<div class="flex flex-wrap gap-3 pointer-coarse:gap-2">
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["From"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="startsAt" autocomplete="off" data-announcement-starts />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Until"]</span>
<DInput TValue="string" Type="datetime-local" class="w-auto" @bind-Value="endsAt" autocomplete="off" data-announcement-ends />
</label>
</div>
<p class="text-xs opacity-70">@Localizer["Both are optional: once its end is past, it is no longer shown."]</p>
<DToggle LabelText="@Localizer["All day"]" @bind-Value="editing.AllDay" data-announcement-all-day />
<DToggle LabelText="@Localizer["Published: shown to every persona"]" @bind-Value="editing.IsPublished" data-announcement-published />
<ErrorViewer AdditionalError="@error" />
<div class="flex justify-end gap-2">
<DButton OnClick="() => editing = null" data-announcement-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-announcement-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
}
@code {
const string LocalInput = "yyyy-MM-ddTHH:mm";
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ViewAnnouncement> announcements = [];
// the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own)
AnnouncementForm editing;
string startsAt;
string endsAt;
string error;
bool isSaving;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => announcements = await InstanceAdminService.Announcements();
static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft";
string When(ViewAnnouncement announcement)
{
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
return (starts, ends) switch
{
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
({ } from, null) => Localizer["From {0}", from],
_ => Localizer["until {0}", ends]
};
}
void Closed(bool open)
{
if (!open)
editing = null;
}
void New()
{
error = null;
editing = new AnnouncementForm();
startsAt = null;
endsAt = null;
}
void Edit(ViewAnnouncement announcement)
{
error = null;
editing = new AnnouncementForm
{
Id = announcement.Id,
Text = announcement.Text,
StartsAt = announcement.StartsAt,
EndsAt = announcement.EndsAt,
AllDay = announcement.AllDay,
IsPublished = announcement.IsPublished
};
startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture);
}
// a datetime-local value, the reader's own time, in UTC; null when empty or not a time
static DateTime? Utc(string local) =>
DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null;
async Task Save()
{
error = null;
editing.StartsAt = Utc(startsAt);
editing.EndsAt = Utc(endsAt);
if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from)
{
error = Localizer["An announcement can't end before it starts."];
return;
}
isSaving = true;
var result = await InstanceAdminService.SaveAnnouncement(editing);
isSaving = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
var saved = (ViewAnnouncement)result.Data;
var at = announcements.FindIndex(a => a.Id == saved.Id);
if (at < 0)
announcements.Insert(0, saved);
else
announcements[at] = saved;
editing = null;
Toast.AddResponseSuccess(Localizer["The announcement is saved."]);
}
async Task Delete(ViewAnnouncement announcement)
{
if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true))
return;
busy.Add(announcement.Id);
var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id);
busy.Remove(announcement.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcements.Remove(announcement);
Toast.AddResponseSuccess(Localizer["The announcement is deleted."]);
}
}
+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)
{
}
}
}
+58
View File
@@ -0,0 +1,58 @@
@inherits LocalizableComponentBase
@* The federation ledger's events, newest first (ViewInteractionEvent): what was received, delivered or fetched and how
it ended, never who. Channels and outcomes show as the ledger keys them, like the statistics' counters. In the
statistics' Events section and in a server's detail. *@
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-events-table data-scroll-x>
<thead>
<tr>
<th>@Localizer["When"]</th>
@if (ShowHost)
{
<th>@Localizer["server"]</th>
}
<th>@Localizer["Channel"]</th>
<th>@Localizer["Activity"]</th>
<th>@Localizer["Outcome"]</th>
<th>@Localizer["Reason"]</th>
<th class="text-right">HTTP</th>
<th class="text-right">ms</th>
</tr>
</thead>
<tbody>
@foreach (var happened in Events)
{
<tr data-statistics-event="@happened.Channel" data-outcome="@happened.Outcome">
<td class="whitespace-nowrap" title="@NerdRow.Time(happened.At)">@happened.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</td>
@if (ShowHost)
{
<td class="max-w-40 truncate" title="@happened.Host">@happened.Host</td>
}
<td>@happened.Channel</td>
<td class="max-w-48 truncate" title="@happened.Object">@happened.Activity</td>
<td>
<DBadge Size="DSize.Xs" Color="@OutcomeColor(happened.Outcome)">@happened.Outcome</DBadge>
</td>
<td class="max-w-48 truncate" title="@happened.Reason">@happened.Reason</td>
<td class="text-right tabular-nums">@happened.Status</td>
<td class="text-right tabular-nums">@happened.LatencyMs</td>
</tr>
}
</tbody>
</DTable>
@code {
[Parameter, EditorRequired] public List<ViewInteractionEvent> Events { get; set; } = [];
/// <summary>A column for the server, where the events are of many.</summary>
[Parameter] public bool ShowHost { get; set; } = true;
protected override void OnInitialized() => IsLoading = false;
static DColor OutcomeColor(string outcome) => outcome switch
{
"ok" or "accepted" => DColor.Success,
"queued" or "deferred" or "retry" => DColor.Warning,
"failed" or "dead" or "refused" or "rejected" or "dropped" => DColor.Error,
_ => DColor.None
};
}
+155
View File
@@ -0,0 +1,155 @@
@inherits LocalizableComponentBase
@* One server of the federation statistics (/clientapi/admin/statistics/hosts/{host}), opened from its row: what it says
of itself and what PrivaPub knows of its deliveries (as nerd rows, by their field names), its days with the main
counters (newest first; a live day is today's, or one not rolled up yet), and its latest events. "Describe again"
asks PrivaPub to read its NodeInfo and instance description anew. Counted per server, never per account. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" BoxClass="md:w-11/12 md:max-w-4xl"
data-host-detail="@HostName">
<Heading><span class="break-all">@HostName</span></Heading>
<ChildContent>
<div class="flex flex-col gap-4 mt-3">
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isDescribing)" OnClick="Describe" data-host-describe>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Describe again"]</span>
</DButton>
</div>
@if (IsLoading)
{
<p class="flex items-center gap-2"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (detail is null)
{
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
}
else
{
@if (detail.Instance is { } instance)
{
<NerdRows Rows="InstanceRows(instance)" />
}
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-72 rounded-xl neo-inset neo-2xs" data-host-days data-scroll-x>
<thead>
<tr>
<th>@Localizer["Day"]</th>
<th class="text-right">@Localizer["Received"]</th>
<th class="text-right">@Localizer["In"]</th>
<th class="text-right">@Localizer["Out"]</th>
<th class="text-right">@Localizer["Failures"]</th>
<th class="text-right">@Localizer["Accounts"]</th>
</tr>
</thead>
<tbody>
@foreach (var day in detail.Days.OrderByDescending(d => d.Day))
{
<tr data-host-day="@NerdRow.Time(day.Day)">
<td class="whitespace-nowrap">
@day.Day.ToString(SUtility.dateTimeFormat)
@if (day.Live)
{
<DBadge Size="DSize.Xs" Color="DColor.Info">@Localizer["live"]</DBadge>
}
</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "recv")</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "in")</td>
<td class="text-right tabular-nums">@Channel(day.Counters, "out")</td>
<td class="text-right tabular-nums">@Failures(day.Counters)</td>
<td class="text-right tabular-nums">@day.Accounts</td>
</tr>
}
</tbody>
</DTable>
@if (detail.Events.Count > 0)
{
<b class="text-sm">@Localizer["Last events"]</b>
<EventsTable Events="detail.Events" ShowHost="false" />
}
<p class="text-xs opacity-70">@detail.Attribution</p>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Parameter, EditorRequired] public string HostName { get; set; }
/// <summary>How many days back its days go.</summary>
[Parameter] public int Days { get; set; } = 30;
[Parameter] public EventCallback OnClosed { get; set; }
ViewHostDetail detail;
bool isDescribing;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => detail = await AdminService.Host(HostName, Days);
async Task Describe()
{
isDescribing = true;
var result = await AdminService.Describe(HostName);
isDescribing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["PrivaPub will read what {0} says of itself again.", HostName]);
}
/// <summary>A channel's counters of a day (recv: deliveries to PrivaPub's inboxes; in: activities taken in; out: sent).</summary>
static long Channel(Dictionary<string, long> counters, string channel) =>
counters.Where(c => c.Key.StartsWith(channel + ":", StringComparison.Ordinal)).Sum(c => c.Value);
// the deliveries out that failed, are retried or were given up, as the servers table counts them
static long Failures(Dictionary<string, long> counters) =>
counters.Where(c => c.Key.Split(':') is { Length: >= 4 } parts && parts[0] == "out" && parts[3] is "retry" or "dead" or "failed").Sum(c => c.Value);
static IEnumerable<NerdRow> InstanceRows(ViewHostInstance instance)
{
yield return NerdRow.Of("software", $"{instance.Software} {instance.SoftwareVersion}".Trim());
yield return NerdRow.Of("nodeName", instance.NodeName);
yield return NerdRow.Of("title", instance.Title);
yield return NerdRow.Of("nodeDescription", instance.NodeDescription);
yield return NerdRow.Of("openRegistrations", instance.OpenRegistrations);
yield return NerdRow.Of("registrationMode", instance.RegistrationMode);
yield return NerdRow.Of("usersTotal", instance.UsersTotal);
yield return NerdRow.Of("usersActiveMonth", instance.UsersActiveMonth);
yield return NerdRow.Of("localPosts", instance.LocalPosts);
yield return NerdRow.Of("languages", instance.Languages.Count > 0 ? string.Join(", ", instance.Languages) : null);
yield return NerdRow.Of("seen", instance.Seen);
yield return NerdRow.Of("firstSeenAt", instance.FirstSeenAt);
yield return NerdRow.Of("lastSeenAt", instance.LastSeenAt);
yield return NerdRow.Of("describedAt", instance.DescribedAt);
yield return NerdRow.Of("descriptionError", instance.DescriptionError);
yield return NerdRow.Of("lastSuccessAt", instance.LastSuccessAt);
yield return NerdRow.Of("lastFailureAt", instance.LastFailureAt);
yield return NerdRow.Of("lastError", instance.LastError);
yield return NerdRow.Of("consecutiveFailures", instance.ConsecutiveFailures > 0 ? instance.ConsecutiveFailures : null);
yield return NerdRow.Of("unavailableUntil", instance.UnavailableUntil);
yield return NerdRow.Of("place", string.Join(", ", new[] { instance.City, instance.Country }.Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } place ? place : null);
yield return NerdRow.Of("asn", instance.Asn is { } asn ? $"AS{asn} {instance.AsnOrg}".Trim() : null);
yield return NerdRow.Of("cdn", instance.Cdn);
yield return NerdRow.Of("instanceApi", instance.InstanceApi);
yield return NerdRow.Of("sourceUrl", instance.SourceUrl);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+399
View File
@@ -0,0 +1,399 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown
description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the
local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its
weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts
from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays.
Registrations are the configuration's and only shown. *@
<div class="flex flex-col gap-4 w-full" data-instance>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">@Localizer["Thumbnail"]</span>
<img src="@settings.ThumbnailUrl" alt="@Localizer["Thumbnail"]" class="w-full max-w-sm aspect-[40/21] object-cover rounded-lg neo-inset neo-2xs"
data-instance-thumbnail-image="@(settings.ThumbnailIsDefault ? "default" : "uploaded")" />
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="PickThumbnail" disabled="@(IsDefaultDisabled || isUploading)"
aria-label="@Localizer["Thumbnail"]" data-instance-thumbnail />
@if (isUploading)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
@if (!settings.ThumbnailIsDefault)
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isUploading" OnClick="RemoveThumbnail" data-instance-thumbnail-remove>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Back to the default picture"]</span>
</DButton>
}
</div>
<p class="text-xs opacity-70">@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]</p>
<ErrorViewer AdditionalErrors="thumbnailErrors" />
</div>
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Server name"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.Title" Immediate="true" autocomplete="off" maxlength="64" data-instance-title />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Short description"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="form.ShortDescription" Immediate="true" rows="2" autocomplete="off" maxlength="500"
data-instance-short-description />
<span class="text-xs opacity-70">@Localizer["Plain text, as the instance API, NodeInfo and the server's own actor give it."]</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Description (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.Description" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-description />
@if (settings.DescriptionUpdatedAt is { } described)
{
<span class="text-xs opacity-70" title="@described.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", described.GetPassedTime(Localizer)]</span>
}
</label>
<fieldset class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<legend class="text-sm font-semibold px-1">@Localizer["Contact"]</legend>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Contact address"]</span>
<DInput TValue="string" Type="email" class="w-full" @bind-Value="form.ContactEmail" Immediate="true" autocomplete="off" maxlength="254"
data-instance-email>
<Prefix><i class="ion-md-mail" aria-hidden="true"></i></Prefix>
</DInput>
</label>
<span class="text-sm">@Localizer["Contact persona"]</span>
@if (form.ContactAccountId is { Length: > 0 })
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-raised neo-2xs" data-instance-contact="@form.ContactAccountId">
<i class="ion-md-person text-lg" aria-hidden="true"></i>
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate">@(contactName ?? form.ContactAccountId)</b>
@if (contactHandle is { Length: > 0 })
{
<span class="text-xs opacity-70 truncate">@contactHandle</span>
}
</span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="RemoveContact" data-instance-contact-remove>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["No contact persona"]</span>
</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["None: the server names nobody."]</p>
}
@* its form attribute names no form, so Enter in the search never saves the settings *@
<DInput TValue="string" class="w-full" Value="contactQuery" ValueChanged="SearchContact" ValueExpression="() => contactQuery" Immediate="true"
autocomplete="off" form="instance-contact-search" placeholder="@Localizer["Search this server's personas"]"
aria-label="@Localizer["Search this server's personas"]" data-instance-contact-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<div class="flex flex-wrap gap-2" data-instance-contact-options>
@if (contactQuery is { Length: > 0 })
{
@foreach (var account in contactMatches)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving" OnClick="() => PickContact(account.Id, account.Name, account.Acct)"
data-instance-contact-option="@account.Id">
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</DButton>
}
@if (contactMatches.Count == 0 && !isSearching)
{
<span class="text-xs opacity-70">@Localizer["No persona of this server matches."]</span>
}
}
else
{
@foreach (var persona in CascadingState.Personas)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="IsDefaultDisabled || isSaving"
OnClick="() => PickContact(persona.Id, persona.Name ?? persona.UserName, persona.Handle ?? persona.UserName)" data-instance-contact-option="@persona.Id">
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
</DButton>
}
}
</div>
<p class="text-xs opacity-70">@Localizer["The contact persona is shown to everyone as the one who runs this server."]</p>
</fieldset>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Privacy policy (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.PrivacyPolicy" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" data-instance-privacy />
@if (settings.PrivacyPolicyUpdatedAt is { } policyAt)
{
<span class="text-xs opacity-70" title="@policyAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Changed {0} ago", policyAt.GetPassedTime(Localizer)]</span>
}
</label>
<div class="flex flex-col gap-1">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Terms of service (Markdown)"]</span>
<DTextarea TValue="string" class="w-full font-mono text-sm" @bind-Value="form.TermsOfService" Immediate="true" rows="8" autocomplete="off"
maxlength="@ServerSettingsForm.MaxText" placeholder="@Localizer["None"]" data-instance-terms />
</label>
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm">@Localizer["In effect from"]</span>
<DInput TValue="string" Type="date" class="w-auto" @bind-Value="termsDate" autocomplete="off" data-instance-terms-date />
</label>
<span class="text-xs opacity-70">@Localizer["Empty, the server has no terms. Without a date, new terms take effect today."]</span>
</div>
<div class="flex flex-col gap-2">
<DToggle LabelText="@Localizer["Publish the weekly activity"]" @bind-Value="form.PublishActivity" data-instance-publish-activity />
<p class="text-xs opacity-70">@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]</p>
</div>
<p class="text-sm" data-instance-registrations="@settings.RegistrationMode">
<b>@Localizer["Sign-ups"]</b>
@(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"])
<span class="text-xs opacity-70">@Localizer["(set in the server's configuration, Registrations:Mode)"]</span>
</p>
<ErrorViewer />
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (settings.UpdatedAt is { } savedAt)
{
<span class="me-auto text-xs opacity-70" title="@savedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)" data-instance-saved>
@Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy]
</span>
}
<DButton Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-instance-reset>@Localizer["Discard changes"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-instance-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
ViewServerSettings settings;
ServerSettingsForm form = new();
// the terms' date as the date input holds it (yyyy-MM-dd), empty for none
string termsDate;
// the contact persona as shown: what the settings name, or the one just picked
string contactName;
string contactHandle;
string contactQuery;
List<Account> contactMatches = [];
int searching;
bool isSearching;
bool isSaving;
bool isUploading;
List<string> pictureTypes = [];
List<string> thumbnailErrors = [];
string PictureTypes => string.Join(',', pictureTypes);
// what PrivaPub takes for a picture now, within its hard cap
long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0
? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes)
: ServerMediaLimitsForm.HardMaxImageBytes;
string SavedBy => settings?.UpdatedBy switch
{
null => Localizer["someone"],
"cli" => Localizer["the command line"],
var name => name
};
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))];
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset()
{
if (settings is null)
return;
form = InstanceAdminService.FormOf(settings);
termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName;
contactHandle = settings.ContactAccount?.Handle;
contactQuery = null;
contactMatches = [];
}
async Task SearchContact(string query)
{
contactQuery = query;
var lookup = ++searching;
if (string.IsNullOrWhiteSpace(query))
{
contactMatches = [];
isSearching = false;
return;
}
isSearching = true;
await Task.Delay(300);
if (lookup != searching)
return;
var found = await AccountService.Suggest(query.Trim(), false);
if (lookup != searching)
return;
// the server's own personas only: a local account's acct has no host
contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))];
isSearching = false;
}
void PickContact(string accountId, string name, string handle)
{
form.ContactAccountId = accountId;
contactName = name;
contactHandle = handle;
contactQuery = null;
contactMatches = [];
}
void RemoveContact()
{
form.ContactAccountId = null;
contactName = null;
contactHandle = null;
}
async Task PickThumbnail(InputFileChangeEventArgs eventArgs)
{
thumbnailErrors = [];
var file = eventArgs.File;
if (!pictureTypes.Contains(file.ContentType))
{
thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > MaximumThumbnail)
{
thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
MaximumThumbnail.GetFileSize(Localizer)]);
return;
}
isUploading = true;
var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail);
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// the form keeps what is being written; only the picture and the dates come from the answer
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail saved."]);
}
async Task RemoveThumbnail()
{
if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."],
Localizer["Remove"], true))
return;
isUploading = true;
var result = await InstanceAdminService.RemoveThumbnail();
isUploading = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Toast.AddResponseSuccess(Localizer["Thumbnail removed."]);
}
// the value written here when it differs from the one shown, else the one PrivaPub has now
static string Changed(string written, string shown, string latest) => written == shown ? latest : written;
async Task Save()
{
var effective = default(DateTime?);
if (termsDate is { Length: > 0 })
{
if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var on))
{
Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]);
return;
}
effective = on;
}
// only what was changed here: what another administrator saved meanwhile to the rest stays
var shown = InstanceAdminService.FormOf(settings);
var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest =>
{
latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title);
latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription);
latest.Description = Changed(form.Description, shown.Description, latest.Description);
latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail);
latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId);
latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy);
latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService);
latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt;
latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity;
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The server's settings are saved."]);
}
}
+214
View File
@@ -0,0 +1,214 @@
@inherits LocalizableComponentBase
@* The media limits the administrator sets over the configuration's (Media:*, /clientapi/admin/settings): what a login's
media may take in all, how big a picture and a video may be, and how long a sound or a video may last. Left empty, a
limit is the configuration's; beside each, the configured value and the one in force now, and PrivaPub's hard caps
are the maxima (40 MB, 99 MB, four hours). Saving starts from the settings PrivaPub has now
(InstanceAdminService.SaveSettings), so what Instance or Rules saved stays. *@
<div class="flex flex-col gap-3 w-full" data-media-limits>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload."]</p>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="quota">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Media a login may keep in all"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="quotaMb" Immediate="true" autocomplete="off" min="0" step="1"
placeholder="@Localizer["as configured"]" data-media-quota>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", Quota(settings.Media.ConfiguredQuotaBytesPerRoot)] ·
<span data-media-effective>@Localizer["In force: {0}", Quota(settings.Media.EffectiveQuotaBytesPerRoot)]</span> ·
@Localizer["0: no quota"]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="image">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest picture"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="imageMb" Immediate="true" autocomplete="off" min="1"
max="@(settings.Media.HardMaxImageBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-image>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxImageBytes.GetFileSize(Localizer)] ·
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxImageBytes.GetFileSize(Localizer)]</span> ·
@Localizer["At most {0}", settings.Media.HardMaxImageBytes.GetFileSize(Localizer)]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="video">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Largest video or sound"]</span>
<DInput TValue="long?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="videoMb" Immediate="true" autocomplete="off" min="1"
max="@(settings.Media.HardMaxVideoBytes / Megabyte)" step="1" placeholder="@Localizer["as configured"]" data-media-video>
<Suffix><span class="text-xs opacity-70">@Localizer["MB"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", settings.Media.ConfiguredMaxVideoBytes.GetFileSize(Localizer)] ·
<span data-media-effective>@Localizer["In force: {0}", settings.Media.EffectiveMaxVideoBytes.GetFileSize(Localizer)]</span> ·
@Localizer["At most {0}", settings.Media.HardMaxVideoBytes.GetFileSize(Localizer)]
</p>
</div>
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-media-limit="seconds">
<label class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold flex-1 min-w-40">@Localizer["Longest video or sound"]</span>
<DInput TValue="int?" Type="number" Size="DSize.Sm" class="w-36" @bind-Value="seconds" Immediate="true" autocomplete="off" min="1"
max="@settings.Media.HardMaxSeconds" step="1" placeholder="@Localizer["as configured"]" data-media-seconds>
<Suffix><span class="text-xs opacity-70">@Localizer["seconds"]</span></Suffix>
</DInput>
</label>
<p class="text-xs opacity-70">
@Localizer["Configured: {0}", Length(settings.Media.ConfiguredMaxSeconds)] ·
<span data-media-effective>@Localizer["In force: {0}", Length(settings.Media.EffectiveMaxSeconds)]</span> ·
@Localizer["At most {0}", Length(settings.Media.HardMaxSeconds)]
</p>
</div>
<ErrorViewer AdditionalErrors="errors" />
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="UseConfigured" data-media-use-configured>
@Localizer["Use the configured values"]
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-media-reset>@Localizer["Discard changes"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" OnClick="Save" data-media-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save the limits"]</span>
</DButton>
</div>
}
</div>
@code {
const long Megabyte = 1024 * 1024;
[Inject] InstanceAdminService InstanceAdminService { get; set; }
ViewServerSettings settings;
// the overrides as being written, in megabytes and seconds; null: the configured value
long? quotaMb;
long? imageMb;
long? videoMb;
int? seconds;
List<string> errors = [];
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset()
{
errors = [];
if (settings is null)
return;
quotaMb = Megabytes(settings.Media.QuotaBytesPerRoot);
imageMb = Megabytes(settings.Media.MaxImageBytes);
videoMb = Megabytes(settings.Media.MaxVideoBytes);
seconds = settings.Media.MaxSeconds;
}
void UseConfigured()
{
errors = [];
quotaMb = null;
imageMb = null;
videoMb = null;
seconds = null;
}
string Quota(long bytes) => bytes <= 0 ? Localizer["no quota"] : bytes.GetFileSize(Localizer);
static string Length(int value) => NerdRow.Duration(TimeSpan.FromSeconds(value));
static long? Megabytes(long? bytes) => bytes is { } value ? (long)Math.Round(value / (double)Megabyte) : null;
// what was set stays to the byte while its megabytes are left as they were shown
static long? Bytes(long? megabytes, long? before) =>
megabytes is not { } value ? null : before is { } kept && Megabytes(kept) == value ? kept : value * Megabyte;
async Task Save()
{
errors = [];
var media = settings.Media;
if (quotaMb is < 0)
errors.Add(Localizer["The quota can't be below 0."]);
if (imageMb is { } image && (image < 1 || image * Megabyte > media.HardMaxImageBytes))
errors.Add(Localizer["A picture's limit goes from 1 MB to {0}.", media.HardMaxImageBytes.GetFileSize(Localizer)]);
if (videoMb is { } video && (video < 1 || video * Megabyte > media.HardMaxVideoBytes))
errors.Add(Localizer["A video's limit goes from 1 MB to {0}.", media.HardMaxVideoBytes.GetFileSize(Localizer)]);
if (seconds is { } length && (length < 1 || length > media.HardMaxSeconds))
errors.Add(Localizer["A length goes from 1 second to {0}.", Length(media.HardMaxSeconds)]);
if (errors.Count > 0)
return;
// only the limits changed here: what another administrator saved meanwhile to the others stays
var quotaChanged = quotaMb != Megabytes(media.QuotaBytesPerRoot);
var imageChanged = imageMb != Megabytes(media.MaxImageBytes);
var videoChanged = videoMb != Megabytes(media.MaxVideoBytes);
var secondsChanged = seconds != media.MaxSeconds;
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest => latest.Media = new ServerMediaLimitsForm
{
QuotaBytesPerRoot = quotaChanged ? Bytes(quotaMb, media.QuotaBytesPerRoot) : latest.Media.QuotaBytesPerRoot,
MaxImageBytes = imageChanged ? Bytes(imageMb, media.MaxImageBytes) : latest.Media.MaxImageBytes,
MaxVideoBytes = videoChanged ? Bytes(videoMb, media.MaxVideoBytes) : latest.Media.MaxVideoBytes,
MaxSeconds = secondsChanged ? seconds : latest.Media.MaxSeconds
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The media limits are saved."]);
}
}
+161
View File
@@ -0,0 +1,161 @@
@inherits LocalizableComponentBase
@* The staff's log (/clientapi/moderator/log), newest first, 50 at a time: who acted (to administrators every login's
name; to a moderator only its own; "the command line" for PrivaPub's CLI; nobody when an act on a persona of the
acting login would tie the two), what was done, to what, and its note. Never an IP address. A moderator who isn't an
administrator reads what isn't about a login. *@
<div class="flex flex-col gap-2 w-full" data-log>
<div class="flex flex-wrap items-center gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="Reload" data-log-reload>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
</DButton>
</div>
@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 (failed)
{
<p class="text-sm text-error">@Localizer["The log could not be read."]</p>
}
else if (entries.Count == 0)
{
<p class="text-sm opacity-70" data-log-empty>@Localizer["Nothing was done yet."]</p>
}
<ol class="flex flex-col gap-2">
@foreach (var entry in entries)
{
<li class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-log-entry="@entry.Id" data-log-action="@entry.Action">
<div class="flex flex-wrap items-center gap-2">
<b class="text-sm">@ActionName(entry.Action)</b>
@if (entry.TargetLabel is { Length: > 0 } label)
{
<span class="text-sm break-all min-w-0" title="@($"{entry.TargetKind} {entry.TargetId}")" data-log-target>@label</span>
}
<span class="flex-1"></span>
<span class="text-xs opacity-70" title="@entry.At.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@entry.At.GetPassedTime(Localizer)</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span data-log-by="@(entry.ByYou ? "you" : entry.By ?? "none")">@By(entry)</span>
<code class="opacity-60">@entry.Action</code>
@if (entry.ReportId is { Length: > 0 } reportId)
{
<span class="opacity-70">@Localizer["Report: {0}", reportId]</span>
}
</div>
@if (entry.Note is { Length: > 0 } note)
{
<p class="text-xs opacity-80 break-words" data-log-note>@note</p>
}
</li>
}
</ol>
@if (next is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="IsDefaultDisabled || isLoadingMore" OnClick="More" data-log-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
@code {
const int PageSize = 50;
[Inject] AdminService AdminService { get; set; }
List<ViewAdminAction> entries = [];
// the next page's `before`; null on the last
string next;
bool failed;
bool isLoadingMore;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
var page = await AdminService.Log(null, PageSize);
failed = page is null;
entries = page?.Items ?? [];
next = page?.Next;
}
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
async Task More()
{
isLoadingMore = true;
var page = await AdminService.Log(next, PageSize);
isLoadingMore = false;
if (page is null)
{
Toast.AddResponseError(Localizer["The log could not be read."]);
return;
}
var known = entries.Select(e => e.Id).ToHashSet();
entries.AddRange(page.Items.Where(e => known.Add(e.Id)));
next = page.Next;
}
string By(ViewAdminAction entry) => entry switch
{
{ ByYou: true } => Localizer["by you"],
{ By: "cli" } => Localizer["by the command line"],
{ By: { Length: > 0 } name } => Localizer["by {0}", name],
_ => Localizer["by someone not named"]
};
string ActionName(string action) => action switch
{
"domain_block.insert" => Localizer["Server blocked"],
"domain_block.delete" => Localizer["Server unblocked"],
"backup.create" => Localizer["Backup made"],
"backup.delete" => Localizer["Backup deleted"],
"backup.restore" => Localizer["Backup restored"],
"settings.update" => Localizer["Settings saved"],
"settings.thumbnail" => Localizer["Thumbnail changed"],
"rule.insert" => Localizer["Rule added"],
"rule.update" => Localizer["Rule changed"],
"rule.delete" => Localizer["Rule removed"],
"announcement.insert" => Localizer["Announcement written"],
"announcement.update" => Localizer["Announcement changed"],
"announcement.delete" => Localizer["Announcement deleted"],
"relay.insert" => Localizer["Relay added"],
"relay.delete" => Localizer["Relay removed"],
"report.resolve" => Localizer["Report resolved"],
"root.promote" => Localizer["Login made administrator"],
"root.demote" => Localizer["Login no longer administrator"],
"root.create" => Localizer["Login made"],
"root.delete" => Localizer["Login deleted"],
"root.reset_totp" => Localizer["Two-factor sign-in reset"],
"persona.move" => Localizer["Persona moved to another login"],
_ => action
};
}
+191
View File
@@ -0,0 +1,191 @@
@inherits LocalizableComponentBase
@* The relays the server's actor subscribes to (/clientapi/admin/relays): the configuration's (Federation:Relays, removed
only there) and the ones added here, each with its subscription's state. PrivaPub answers an addition or a removal at
once (202) and subscribes or unsubscribes in the background, since reaching a relay takes a fetch, so the panel reads
the list again a few seconds later. *@
<div class="flex flex-col gap-3 w-full" data-relays>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DInput TValue="string" class="flex-1 min-w-48" Type="url" @bind-Value="address" Immediate="true" autocomplete="off" maxlength="2048"
placeholder="@Localizer["https://relay.example/actor"]" aria-label="@Localizer["The relay's address"]" data-relay-address>
<Prefix><i class="ion-md-radio" aria-hidden="true"></i></Prefix>
</DInput>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(address)" OnClick="Add" data-relay-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add the relay"]</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here."]</p>
@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 (relays.Count == 0)
{
<p class="text-sm opacity-70" data-relays-empty>@Localizer["The server subscribes to no relay."]</p>
}
@foreach (var relay in relays)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-relay="@relay.Address" data-relay-state="@relay.State"
data-relay-source="@relay.Source">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="flex-1 min-w-0 break-all text-sm">@relay.Address</b>
@switch (relay.State)
{
case "accepted":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success">@Localizer["Subscribed"]</DBadge>
break;
case "pending":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Waiting for the relay"]</DBadge>
break;
case "rejected":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Refused by the relay"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Not asked yet"]</DBadge>
break;
}
@if (relay.Source == "configuration")
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline" title="@Localizer["Named by the configuration (Federation:Relays): removed only there."]">
@Localizer["Configured"]
</DBadge>
}
else
{
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(relay)" data-relay-remove>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
</DButton>
}
</div>
@if (relay.ActorURI is { Length: > 0 } actor && actor != relay.Address)
{
<p class="text-xs opacity-70 break-all">@Localizer["Its actor: {0}", actor]</p>
}
<p class="text-xs opacity-70">
@if (relay.RequestedAt is { } requested)
{
<span title="@requested.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Asked {0} ago", requested.GetPassedTime(Localizer)]</span>
}
@if (relay.AnsweredAt is { } answered)
{
<span title="@answered.ToLocalTime().ToString(SUtility.dateTimeFormatExt)"> · @Localizer["answered {0} ago", answered.GetPassedTime(Localizer)]</span>
}
</p>
@if (relay.Source == "configuration")
{
<p class="text-xs opacity-70">@Localizer["Named by the configuration (Federation:Relays): removed only there."]</p>
}
</div>
}
</div>
@code {
// when the list is read again after a change: subscribing to a relay is a fetch, then its answer
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
[Inject] InstanceAdminService InstanceAdminService { get; set; }
List<ViewRelay> relays = [];
string address;
bool isSaving;
CancellationTokenSource rereading;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => relays = await InstanceAdminService.Relays();
async Task Add()
{
isSaving = true;
var result = await InstanceAdminService.AddRelay(address);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relays = (List<ViewRelay>)result.Data;
address = null;
Toast.AddResponseSuccess(Localizer["The relay is added: the server subscribes to it now."]);
await ReadAgainSoon();
}
async Task Remove(ViewRelay relay)
{
isSaving = true;
var result = await InstanceAdminService.RemoveRelay(relay.Address);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relays = (List<ViewRelay>)result.Data;
Toast.AddResponseSuccess(Localizer["The relay is removed: the server unsubscribes from it now."]);
await ReadAgainSoon();
}
// the background subscription answers later: the list is read again, the last change's timer replacing the one before
async Task ReadAgainSoon()
{
if (rereading is not null)
{
await rereading.CancelAsync();
rereading.Dispose();
}
rereading = new();
_ = ReadAgain(rereading.Token);
}
async Task ReadAgain(CancellationToken cancellationToken)
{
foreach (var pause in Rereads)
{
try
{
await Task.Delay(pause, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var read = await InstanceAdminService.Relays(cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
// an empty answer to a list that had relays is a failed read: a change here showed its own list at once
if (read.Count == 0 && relays.Count > 0)
continue;
relays = read;
await InvokeAsync(StateHasChanged);
}
}
public override async ValueTask DisposeAsync()
{
if (rereading is not null)
{
await rereading.CancelAsync();
rereading.Dispose();
}
await base.DisposeAsync();
}
}
+27 -1
View File
@@ -1,7 +1,8 @@
@inherits LocalizableComponentBase
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
server, about an account and some of its posts; resolved here. *@
server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub
keeps them, a removed rule's too); resolved here. *@
<div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@@ -57,6 +58,27 @@
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
@if (report.RuleIds.Count > 0)
{
<div class="flex flex-col gap-1 text-sm" data-report-rules>
<b>@Localizer["Rules it names"]</b>
<ol class="list-decimal ps-5">
@foreach (var (ruleId, text) in RulesOf(report))
{
<li class="break-words" data-report-rule-id="@ruleId">
@if (text is { Length: > 0 })
{
@text
}
else
{
<span class="opacity-70">@Localizer["A rule no longer kept"]</span>
}
</li>
}
</ol>
</div>
}
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
@@ -113,6 +135,10 @@
IsLoading = false;
}
// each rule the report names with its text, in the report's order (an empty text: the server no longer has it)
static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) =>
report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null));
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
+177
View File
@@ -0,0 +1,177 @@
@inherits LocalizableComponentBase
@* The server's rules (/clientapi/admin/settings): written, explained, put in order and removed here, then saved together.
A rule keeps its id while it is listed, so the reports that name it keep their meaning; one removed is kept, greyed
below, as old reports name it, and can come back. Saving starts from the settings PrivaPub has now
(InstanceAdminService.SaveSettings), so what Instance or Media limits saved stays. *@
<div class="flex flex-col gap-3 w-full" data-rules>
@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 (settings is null)
{
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-error">@Localizer["The server's settings could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<p class="text-xs opacity-70">@Localizer["Everyone reads them on the About page, and a report of a violation names the ones broken."]</p>
@if (rows.Count == 0)
{
<p class="text-sm opacity-70" data-rules-empty>@Localizer["The server has no rules."]</p>
}
<ol class="flex flex-col gap-2">
@foreach (var (row, position) in Numbered)
{
<li @key="row" class="flex flex-col gap-2 p-2 rounded-lg neo-raised neo-2xs" data-rule="@position" data-rule-id="@(row.Id ?? "new")">
<div class="flex items-start gap-2 pointer-coarse:gap-2">
<span class="mt-2 w-6 flex-none text-right text-sm font-semibold tabular-nums">@(position + 1).</span>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<DTextarea TValue="string" class="w-full" @bind-Value="row.Text" Immediate="true" rows="2" autocomplete="off" maxlength="500"
placeholder="@Localizer["The rule"]" aria-label="@Localizer["The rule"]" data-rule-text />
<DInput TValue="string" class="w-full" Size="DSize.Sm" @bind-Value="row.Hint" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["What it means, if it needs saying (optional)"]" aria-label="@Localizer["Explanation"]" data-rule-hint />
</div>
<div class="flex flex-col gap-1 pointer-coarse:gap-2 flex-none">
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == 0" OnClick="() => Move(position, -1)"
aria-label="@Localizer["Move up"]" title="@Localizer["Move up"]" data-rule-up>
<i class="ion-md-arrow-up" aria-hidden="true"></i>
</DButton>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || position == rows.Count - 1" OnClick="() => Move(position, 1)"
aria-label="@Localizer["Move down"]" title="@Localizer["Move down"]" data-rule-down>
<i class="ion-md-arrow-down" aria-hidden="true"></i>
</DButton>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving" OnClick="() => Remove(row)"
aria-label="@Localizer["Remove the rule"]" title="@Localizer["Remove the rule"]" data-rule-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i>
</DButton>
</div>
</div>
</li>
}
</ol>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="Add" data-rule-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add a rule"]</span>
</DButton>
<span class="flex-1"></span>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || isSaving" OnClick="Reset" data-rule-reset>@Localizer["Discard changes"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving || rows.Any(r => string.IsNullOrWhiteSpace(r.Text))" OnClick="Save"
data-rule-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save the rules"]</span>
</DButton>
</div>
@if (rows.Count >= ServerSettingsForm.MaxRules)
{
<p class="text-xs opacity-70">@Localizer["A server has {0} rules at most.", ServerSettingsForm.MaxRules]</p>
}
@if (Removed.Count > 0)
{
<div class="flex flex-col gap-2 mt-2" data-rules-deleted>
<b class="text-sm">@Localizer["Removed rules, kept as old reports name them"]</b>
@foreach (var rule in Removed)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-lg neo-inset neo-2xs opacity-60" data-rule-deleted="@rule.Id">
<span class="flex flex-col flex-1 min-w-0">
<s class="text-sm break-words">@rule.Text</s>
@if (rule.DeletedAt is { } deletedAt)
{
<span class="text-xs" title="@deletedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@Localizer["Removed {0} ago", deletedAt.GetPassedTime(Localizer)]</span>
}
</span>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled || isSaving || rows.Count >= ServerSettingsForm.MaxRules" OnClick="() => Restore(rule)"
data-rule-restore>
<i class="ion-md-undo" aria-hidden="true"></i><span>@Localizer["Bring it back"]</span>
</DButton>
</div>
}
</div>
}
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
ViewServerSettings settings;
// the rules as being written, in their order: a listed rule keeps its id, a new one has none
List<ServerRuleForm> rows = [];
bool isSaving;
IEnumerable<(ServerRuleForm Row, int Position)> Numbered => rows.Select((row, position) => (row, position));
// the rules the server keeps deleted, unless they are listed again above
List<ViewServerRule> Removed => [.. (settings?.Rules ?? []).Where(r => r.IsDeleted && rows.All(row => row.Id != r.Id))];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
settings = await InstanceAdminService.Settings();
Reset();
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void Reset() => rows = settings is null ? [] : InstanceAdminService.FormOf(settings).Rules;
void Add() => rows.Add(new ServerRuleForm());
// gone from the list, it is removed when the rules are saved (kept, marked, for the reports naming it)
void Remove(ServerRuleForm row) => rows.Remove(row);
void Move(int position, int by)
{
var row = rows[position];
rows.RemoveAt(position);
rows.Insert(position + by, row);
}
// listed again with its id, it is the same rule: the reports naming it name it still
void Restore(ViewServerRule rule) => rows.Add(new ServerRuleForm { Id = rule.Id, Text = rule.Text, Hint = rule.Hint });
async Task Save()
{
var rules = rows.Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text?.Trim(), Hint = string.IsNullOrWhiteSpace(r.Hint) ? null : r.Hint.Trim() }).ToList();
isSaving = true;
var result = await InstanceAdminService.SaveSettings(latest => latest.Rules = rules);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
settings = (ViewServerSettings)result.Data;
Reset();
Toast.AddResponseSuccess(Localizer["The rules are saved."]);
}
}
+39 -2
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins.
A block is published (/api/v1/instance/domain_blocks, the About page) only when asked, since that tells the blocked
server and everyone else; published, its name can be partly starred. Blocking a server again changes its block, so
Edit fills the form with it. *@
<div class="flex flex-col gap-3 w-full" data-server-blocks>
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<DataAnnotationsValidator />
@@ -13,13 +16,19 @@
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div>
<div class="flex flex-wrap gap-2 items-center">
<DToggle LabelText="@Localizer["Publish it"]" Value="form.Published" ValueChanged="Publish" ValueExpression="() => form.Published"
data-server-block-published />
<DToggle LabelText="@Localizer["Hide part of its name"]" @bind-Value="form.Obfuscate" disabled="@(!form.Published)" data-server-block-obfuscate />
</div>
<p class="text-xs opacity-70">@Localizer["A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it."]</p>
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for everyone to read"]" />
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for the admins only"]" />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save>
@Localizer["Block server"]
</DButton>
</EditForm>
@@ -48,7 +57,16 @@
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
}
@if (block.Published)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Info" data-server-block-public>
@(block.Obfuscate ? Localizer["Published, partly hidden"] : Localizer["Published"])
</DBadge>
}
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Edit(block)" data-server-block-edit>
@Localizer["Edit"]
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
@Localizer["Unblock"]
</DButton>
@@ -89,6 +107,25 @@
async Task LoadData() => blocks = await AdminService.ServerBlocks();
// a block that isn't published can't be partly hidden either
void Publish(bool published)
{
form.Published = published;
if (!published)
form.Obfuscate = false;
}
void Edit(ViewDomainBlock block) => form = new()
{
Domain = block.Domain,
Suspend = block.Severity == "Suspend",
RejectMedia = block.RejectMedia,
Published = block.Published,
Obfuscate = block.Obfuscate,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment
};
async Task Block()
{
isSaving = true;
+165 -5
View File
@@ -1,7 +1,10 @@
@inherits LocalizableComponentBase
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
(by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@
(by traffic, failures, latency or name, 50 a page; a server's name opens its detail, HostDetailDialog), how far the
crawler got, the ledger's events (by server, channel and outcome, 100 at a time, older ones from the last one's time)
and the server's own days (its client API, the documents it served, its ledger). Counted per server, never per
account. *@
<div class="flex flex-col gap-4 w-full" data-statistics>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
@@ -37,7 +40,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>
@@ -87,7 +90,10 @@
@foreach (var host in hosts.Hosts)
{
<tr data-statistics-host="@host.Host">
<td class="max-w-48 truncate">@host.Host</td>
<td class="max-w-48">
<button type="button" class="link link-hover block max-w-full truncate text-left pointer-coarse:py-3.5" title="@host.Host"
@onclick="() => openHost = host.Host" data-statistics-host-open>@host.Host</button>
</td>
<td>@host.Software @host.Version</td>
<td class="text-right tabular-nums">@host.In</td>
<td class="text-right tabular-nums">@host.Out</td>
@@ -116,6 +122,11 @@
<p class="text-xs opacity-70">@hosts.Attribution</p>
}
@if (openHost is { } detailHost)
{
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
}
@if (crawler is not null)
{
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
@@ -141,6 +152,99 @@
}
</div>
}
<div class="flex flex-col gap-2" data-statistics-events>
<b class="text-sm">@Localizer["Events"]</b>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["server"]</span>
<DInput TValue="string" Size="DSize.Sm" @bind-Value="eventsHost" @bind-Value:after="ReloadEvents" autocomplete="off" maxlength="253"
placeholder="@Localizer["A server, e.g. example.social"]" data-statistics-events-host />
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Channel"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="eventsChannel" ValueChanged="ChangeChannel" ValueExpression="() => eventsChannel"
data-statistics-events-channel>
<option value="">@Localizer["All"]</option>
@foreach (var channel in EventChannels)
{
<option value="@channel">@channel</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Outcome"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="eventsOutcome" ValueChanged="ChangeOutcome" ValueExpression="() => eventsOutcome"
data-statistics-events-outcome>
<option value="">@Localizer["All"]</option>
@foreach (var outcome in EventOutcomes)
{
<option value="@outcome">@outcome</option>
}
</DSelect>
</label>
</div>
@if (events.Count == 0)
{
<p class="text-xs opacity-70" data-statistics-events-empty>@Localizer["Empty"]</p>
}
else
{
<EventsTable Events="events" />
}
@if (hasMoreEvents)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="MoreEvents" data-statistics-events-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-2" data-statistics-server>
<b class="text-sm">@Localizer["Server days"]</b>
@if (serverDays.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["Empty"]</p>
}
else
{
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-scroll-x>
<thead>
<tr>
<th>@Localizer["Day"]</th>
<th class="text-right">@Localizer["Client API"]</th>
<th class="text-right">4xx</th>
<th class="text-right">5xx</th>
<th class="text-right">@Localizer["Served"]</th>
<th class="text-right" title="@Localizer["written / dropped / failed"]">@Localizer["Ledger"]</th>
<th class="text-right">@Localizer["Accounts"]</th>
<th class="text-right"><span class="capitalize">@Localizer["servers"]</span></th>
</tr>
</thead>
<tbody>
@foreach (var day in Enumerable.Reverse(serverDays))
{
<tr data-statistics-server-day="@NerdRow.Time(day.Day)">
<td class="whitespace-nowrap">@day.Day.ToString(SUtility.dateTimeFormat)</td>
<td class="text-right tabular-nums">@ClientRequests(day, null)</td>
<td class="text-right tabular-nums">@ClientRequests(day, ":4xx")</td>
<td class="text-right tabular-nums">@ClientRequests(day, ":5xx")</td>
<td class="text-right tabular-nums">@day.Served.Values.Sum()</td>
<td class="text-right tabular-nums whitespace-nowrap" title="@Localizer["written / dropped / failed"]">
@day.LedgerWritten / @day.LedgerDropped / @day.LedgerFailed
</td>
<td class="text-right tabular-nums">@day.Accounts</td>
<td class="text-right tabular-nums">@day.Hosts</td>
</tr>
}
</tbody>
</DTable>
}
</div>
}
</div>
@@ -148,11 +252,24 @@
[Inject] AdminService AdminService { get; set; }
const int PageSize = 50;
const int EventsPageSize = 100;
// the ledger's channels and outcomes, as it keys them
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
static readonly int[] Periods = [1, 7, 30, 90];
ViewStatisticsOverview overview;
ViewHostsPage hosts;
ViewCrawler crawler;
List<ViewInteractionEvent> events = [];
List<ViewServerDay> serverDays = [];
// the server whose detail is open
string openHost;
string eventsHost;
string eventsChannel;
string eventsOutcome;
bool hasMoreEvents;
bool isLoadingMore;
int days = 30;
string sort = "traffic";
int page = 1;
@@ -179,10 +296,53 @@
}
async Task LoadData()
{
await LoadDays();
crawler = await AdminService.Crawler();
await ReloadEvents();
}
// what the period chosen shows
async Task LoadDays()
{
overview = await AdminService.Overview(days);
hosts = await AdminService.Hosts(days, sort, page);
crawler = await AdminService.Crawler();
serverDays = await AdminService.ServerDays(days);
}
// the client API's requests of a day, those of one status class (":4xx") or all
static long ClientRequests(ViewServerDay day, string statusClass) =>
day.Client.Where(c => c.Key.EndsWith("xx", StringComparison.Ordinal) && (statusClass is null || c.Key.EndsWith(statusClass, StringComparison.Ordinal)))
.Sum(c => c.Value);
async Task ReloadEvents()
{
events = [];
hasMoreEvents = false;
await LoadEvents();
}
async Task LoadEvents()
{
isLoadingMore = true;
var page = await AdminService.Events(eventsHost?.Trim(), eventsChannel, eventsOutcome, null, events.LastOrDefault()?.At, EventsPageSize);
isLoadingMore = false;
events.AddRange(page);
hasMoreEvents = page.Count == EventsPageSize;
}
async Task MoreEvents() => await LoadEvents();
async Task ChangeChannel(string value)
{
eventsChannel = value;
await ReloadEvents();
}
async Task ChangeOutcome(string value)
{
eventsOutcome = value;
await ReloadEvents();
}
static string Ms(int? value) => value?.ToString() ?? "—";
@@ -191,7 +351,7 @@
{
days = value;
page = 1;
await LoadData();
await LoadDays();
}
async Task ChangeSort(string value)
+207
View File
@@ -0,0 +1,207 @@
@inherits LocalizableComponentBase
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
@if (Current is { } current)
{
<section class="flex-none flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" aria-label="@Localizer["Announcements"]"
data-announcement-banner data-announcement="@current.Id">
<div class="flex items-center gap-2 pointer-coarse:gap-2">
<i class="ion-md-megaphone text-lg text-primary" aria-hidden="true"></i>
<b class="text-sm">@Localizer["Announcement"]</b>
<span class="text-xs opacity-70" title="@current.PublishedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@current.PublishedAt.GetPassedTime(Localizer)
</span>
@if (ShownCount > 1)
{
<span class="inline-flex items-center gap-1 pointer-coarse:gap-2">
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index == 0" OnClick="() => index--" aria-label="@Localizer["Previous announcement"]"
title="@Localizer["Previous announcement"]" data-announcement-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
<span class="text-xs tabular-nums" data-announcement-position>@Localizer["{0} of {1}", index + 1, ShownCount]</span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index >= ShownCount - 1" OnClick="() => index++" aria-label="@Localizer["Next announcement"]"
title="@Localizer["Next announcement"]" data-announcement-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
</span>
}
<span class="flex-1"></span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Circle" Disabled="IsDefaultDisabled || isActing" OnClick="Dismiss"
aria-label="@Localizer["Dismiss"]" title="@Localizer["Dismiss"]" data-announcement-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
</div>
@if (current.StartsAt is not null || current.EndsAt is not null)
{
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
}
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
@foreach (var reaction in current.Reactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
}
else
{
<span>@reaction.Name</span>
}
<span class="tabular-nums">@reaction.Count</span>
</DButton>
}
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
}
</ChildContent>
</DDropdown>
</div>
</section>
}
@code {
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
[Inject] AnnouncementService AnnouncementService { get; set; }
[Inject] StreamingService Streaming { get; set; }
List<Announcement> announcements = [];
int index;
bool isActing;
bool isReactionsOpen;
string loadedPersonaId;
// what the persona has not dismissed, oldest first as the server lists them
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
int ShownCount => announcements.Count(a => a.Read != true);
// the one in view, or none
Announcement Current => Shown is [_, ..] shown ? shown[Math.Clamp(index, 0, shown.Count - 1)] : null;
protected override void OnInitialized() => Streaming.Announced += OnAnnounced;
// another persona reads its own: its dismissals and reactions
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
announcements = await AnnouncementService.List();
index = 0;
await Streaming.HearAnnouncements(true);
}
string When(Announcement announcement)
{
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
return (starts, ends) switch
{
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
({ } from, null) => Localizer["From {0}", from],
_ => Localizer["until {0}", ends]
};
}
async Task Dismiss()
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.Dismiss(announcement.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcement.Read = true;
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
}
// the counts are read again afterwards: the stream may have told of this very reaction already
async Task React(string emoji, bool add)
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.React(announcement.Id, emoji, add);
if (result.IsInvalid)
{
isActing = false;
Toast.AddResponseError(result);
return;
}
var position = index;
announcements = await AnnouncementService.List();
index = Math.Clamp(position, 0, Math.Max(0, ShownCount - 1));
isActing = false;
}
Task OnAnnounced(AnnouncementMessage message) => InvokeAsync(() =>
{
switch (message.Event)
{
case "announcement" when message.Announcement is { } changed:
var at = announcements.FindIndex(a => a.Id == changed.Id);
if (at < 0)
announcements.Add(changed);
else
announcements[at] = changed;
break;
case "announcement.delete" when message.DeletedId is { } id:
announcements.RemoveAll(a => a.Id == id);
break;
case "announcement.reaction" when message.Reaction is { } reaction && announcements.Find(a => a.Id == reaction.AnnouncementId) is { } reacted:
var known = reacted.Reactions.Find(r => r.Name == reaction.Name);
reacted.Reactions = reaction.Count <= 0
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
: known is null
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
break;
default:
return;
}
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
StateHasChanged();
});
public override async ValueTask DisposeAsync()
{
Streaming.Announced -= OnAnnounced;
await Streaming.HearAnnouncements(false);
await base.DisposeAsync();
}
}
+249 -62
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,62 @@
@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"]"
data-upload-remove>
<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 +167,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.Visibility.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
<i class="@form.Visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
@foreach (var visibility in Enum.GetValues<Visibility>())
{
<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.Visibility == visibility" OnClick="() => SetVisibility(visibility)">
<i class="@visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[visibility.ToString()]</span>
</DButton>
}
</ChildContent>
@@ -162,12 +200,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)
{
@@ -178,7 +216,7 @@
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<ChildContent>
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
@foreach (var mediaType in Enum.GetValues<MediaType>())
{
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
}
@@ -233,6 +271,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 +332,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,57 +353,106 @@
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;
// a new post starts with the visibility the persona chose in Settings
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
Visibility DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.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.Visibility = 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)
return;
form.InReplyToId = InReplyTo.Id;
form.MessageType = InReplyTo.Visibility switch
form.Visibility = InReplyTo.Visibility switch
{
"direct" => MessageType.Direct,
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"direct" => Visibility.Direct,
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.Public
};
// a reply is written in its thread's language, as a rule
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
@@ -374,9 +463,9 @@
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
}
void SetMessageType(MessageType messageType)
void SetVisibility(Visibility visibility)
{
form.MessageType = messageType;
form.Visibility = visibility;
form.IsScopeOptionsOpen = false;
}
@@ -417,40 +506,126 @@
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)
// 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);
else
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
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);
// a file taken out before posting goes from PrivaPub too (DELETE /api/v1/media/:id), unless a post holds it already: an
// edit's own media stay its post's. A failure is only logged; the upload stays unattached on PrivaPub.
async Task Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
if (media.Posted || media.Id is not { } mediaId)
return;
await MediaService.Discard(mediaId);
}
// 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 +639,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;
@@ -507,14 +694,14 @@
if (result.Data is ScheduledStatus scheduled)
{
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
await OnPublished.InvokeAsync(null);
return;
}
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
var published = result.Data as FeedPost;
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
await OnPublished.InvokeAsync(published);
}
+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;
}
+312 -71
View File
@@ -5,10 +5,13 @@
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
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
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
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.
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; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. 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)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -17,11 +20,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 +34,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)
{
@@ -49,13 +52,20 @@
}
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
@if (!Compact)
{
<DButton Size="DSize.Xs" class="ml-auto" Disabled="@(IsDefaultDisabled || isActing)" OnClick="DismissNotification" title="@Localizer["Dismiss"]"
aria-label="@Localizer["Dismiss"]" data-notification-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
}
</p>
}
else if (Post.BoostedBy is { } booster)
{
<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 +89,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 +109,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 +118,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 +130,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 +141,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 +200,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 +221,20 @@
@if (Post.Located)
{
<p class="inline-flex gap-2 items-center text-xs opacity-70">
<div class="flex flex-wrap items-center gap-2 text-xs">
<p class="inline-flex gap-2 items-center opacity-70">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p>
@* its author takes it back here: it has no other action *@
@if (IsOwn && !Compact)
{
<DButton Size="DSize.Xs" class="ml-auto" Disabled="IsDefaultDisabled || isActing" OnClick="Delete" data-located-delete>
<i aria-hidden="true" class="ion-md-trash text-error"></i>
<span>@Localizer["Delete"]</span>
</DButton>
}
</div>
}
@if (status.Privapub?.Approval is "pending" or "rejected")
@@ -220,59 +248,78 @@
@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)
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
favourite (on a phone, in More) *@
@if (CanDownvote(status) && status.Privapub.Votes is { } votes)
{
<span class="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
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="votes.Downvoted == true" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Downvote(status)" title="@(votes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])"
aria-pressed="@(votes.Downvoted == true ? "true" : "false")" data-downvote="@(votes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-lg @(votes.Downvoted == true ? "text-warning" : null)"></i>
@if (votes.Down > 0)
{
<span class="tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>@votes.Down</span>
}
</DButton>
}
else if (status.Privapub?.Votes is { Down: > 0 } ownVotes)
{
<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>@ownVotes.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 +328,52 @@
}
</ChildContent>
</DDropdown>
<span class="hidden md:contents">
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</span>
</div>
<div class="flex gap-2">
<div class="contents md:flex md:gap-2">
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
<ChildContent>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm justify-start whitespace-nowrap md:hidden" href="@($"expand/{Uri.EscapeDataString(status.Id)}")"
@onclick="OpenConversation" data-thread-link>
<i aria-hidden="true" class="ion-md-expand text-base"></i>
<span>@Localizer["Thread"]</span>
</NavLink>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" data-bookmark-phone>
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
<span>@Localizer["Bookmark"]</span>
</DButton>
@if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="phoneVotes.Downvoted == true"
Disabled="IsDefaultDisabled || isActing" OnClick="() => Downvote(status)" data-downvote-phone="@(phoneVotes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-base @(phoneVotes.Downvoted == true ? "text-warning" : null)"></i>
<span>@(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])</span>
@if (phoneVotes.Down > 0)
{
<span class="opacity-70 tabular-nums">@phoneVotes.Down</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">
@@ -332,6 +411,17 @@
}
@if (!IsOwn)
{
@if (QuotesOwnPost(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="RevokeQuote" data-revoke-quote>
<i aria-hidden="true" class="ion-md-quote text-base text-error"></i>
<span>@Localizer["Revoke quote"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isFilteringPost = true)" data-filter-post>
<i aria-hidden="true" class="ion-md-funnel text-base"></i>
<span>@Localizer["Filter this post"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
@@ -354,6 +444,11 @@
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="@(() => isChoosingQuotePolicy = true)" data-quote-policy="@StatusService.QuotePolicyValue(StatusService.QuotePolicyOf(status))">
<i aria-hidden="true" class="ion-md-quote text-base"></i>
<span>@Localizer["Who may quote it"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i>
@@ -368,7 +463,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 +471,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)
{
@@ -396,9 +512,17 @@
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isFilteringPost)
{
<FilterPostDialog StatusId="@status.Id" OnClosed="@(() => isFilteringPost = false)" OnFiltered="Filtered" />
}
@if (isChoosingQuotePolicy)
{
<QuotePolicyDialog Status="status" OnClosed="@(() => isChoosingQuotePolicy = false)" OnChanged="QuotePolicyChanged" />
}
@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,12 +573,15 @@
@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; }
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
/// <summary>Its notification was dismissed: the page takes the item out.</summary>
[Parameter] public EventCallback<FeedPost> OnDismissed { get; set; }
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
[Parameter] public EventCallback<string> OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
@@ -468,6 +595,8 @@
[Parameter] public FilterContext? FilterIn { get; set; }
bool isReplying;
bool isDetailsOpen;
MediaAttachment viewingPicture;
bool isQuoting;
bool isEditing;
bool isActing;
@@ -479,6 +608,8 @@
bool isReadingServer;
bool isHistoryOpen;
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = [];
@@ -504,8 +635,8 @@
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
// (tests/e2e/Perf/FeedPerfTests).
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
// through.
object renderedWith;
int handlingEvents;
@@ -552,12 +683,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 +701,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 +723,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 +771,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 +831,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 +840,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;
@@ -734,6 +887,74 @@
Post.Status = updated.Reblog ?? updated;
}
// a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card
// offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it
// has downvotes already. Not on the persona's own post.
bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes
&& (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software));
static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb";
// a vote is public, like a reaction: the persona is told so once, before its first
async Task Downvote(Status status)
{
var on = status.Privapub?.Votes?.Downvoted != true;
if (on && !await Notices.Confirm(CascadingState.PersonaId))
return;
await Act(on ? StatusAction.Downvote : StatusAction.Undownvote);
}
// the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss)
async Task DismissNotification()
{
var notification = Post.Notification;
isActing = true;
var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}");
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await OnDismissed.InvokeAsync(Post);
}
// in a filter now: the post is read again, so the filter applies here at once
async Task Filtered()
{
isFilteringPost = false;
if (await StatusService.Get(Status.Id) is { } filtered)
Post.Status = filtered.Reblog ?? filtered;
}
void QuotePolicyChanged(Status updated)
{
isChoosingQuotePolicy = false;
Post.Status = updated.Reblog ?? updated;
}
// someone else's post quoting the persona's own: the persona may take its permission back
bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId;
async Task RevokeQuote()
{
if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true))
return;
isActing = true;
var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (Status)result.Data;
Post.Status = updated.Reblog ?? updated;
Toast.AddResponseSuccess(Localizer["Quote revoked."]);
}
[Inject] AccountService AccountService { get; set; }
[Inject] PublicNoticeService Notices { get; set; }
@@ -885,8 +1106,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 +1129,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 +1146,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 +1201,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)
@@ -1019,10 +1260,10 @@
string VisibilityText(string visibility) => visibility switch
{
"public" => Localizer[MessageType.Public.ToString()],
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
"private" => Localizer[MessageType.FollowersOnly.ToString()],
"direct" => Localizer[MessageType.Direct.ToString()],
"public" => Localizer[Visibility.Public.ToString()],
"unlisted" => Localizer[Visibility.Unlisted.ToString()],
"private" => Localizer[Visibility.FollowersOnly.ToString()],
"direct" => Localizer[Visibility.Direct.ToString()],
_ => Localizer["Located"]
};
+4 -1
View File
@@ -3,7 +3,8 @@
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
@if (Href is null)
{
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
@onmousedown:preventDefault="KeepFocus">
@ChildContent
</button>
}
@@ -27,6 +28,8 @@ else
[Parameter] public string Type { get; set; } = "button";
[Parameter] public string Href { get; set; }
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
[Parameter] public bool KeepFocus { get; set; }
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
+69 -12
View File
@@ -1,19 +1,24 @@
@inherits DComponentBase
@implements IDisposable
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
too. With Hover the CSS hover rule opens it instead. *@
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
@onclick="Toggle">
@Trigger
</div>
@if (IsOpen)
{
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
aria-hidden="true" @onclick="Close"></div>
}
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
@ChildContent
</div>
</div>
@@ -31,6 +36,21 @@
[Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; }
[Parameter] public string ContentClass { get; set; }
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
[Parameter] public bool SheetOnNarrow { get; set; }
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
[CascadingParameter] IDOverlayHost Overlays { get; set; }
ElementReference trigger;
ElementReference content;
IDisposable overlay;
bool focusContent;
bool focusTrigger;
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
@@ -50,17 +70,54 @@
_ => null
};
Task Toggle() => SetOpen(!IsOpen);
protected override void OnParametersSet() => TrackOverlay();
Task Close() => SetOpen(false);
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
async Task SetOpen(bool isOpen)
void TrackOverlay()
{
if (IsOpen && overlay is null)
overlay = Overlays?.Open(() => InvokeAsync(async () =>
{
await SetOpen(false, true);
StateHasChanged();
}));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public void Dispose() => overlay?.Dispose();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusContent)
{
focusContent = false;
await content.FocusAsync();
}
if (focusTrigger)
{
focusTrigger = false;
await trigger.FocusAsync();
}
}
Task Toggle() => SetOpen(!IsOpen, IsOpen);
Task Close() => SetOpen(false, true);
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
async Task SetOpen(bool isOpen, bool giveFocusBack)
{
focusContent = isOpen && !IsOpen;
focusTrigger = !isOpen && IsOpen && giveFocusBack;
IsOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+47 -3
View File
@@ -1,14 +1,32 @@
@inherits DComponentBase
@implements IDisposable
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
@if (Heading is not null || ShowClose)
{
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
@if (Heading is not null)
{
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
}
else
{
<span class="flex-1"></span>
}
@if (ShowClose)
{
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
@onclick="Close" data-modal-close>
<span aria-hidden="true">✕</span>
</button>
}
</div>
}
@ChildContent
@if (Actions is not null)
@@ -35,11 +53,17 @@
[Parameter] public DModalPlacement Placement { get; set; }
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
[Parameter] public string CloseText { get; set; } = "close";
/// <summary>A visible close button beside the heading.</summary>
[Parameter] public bool ShowClose { get; set; } = true;
[Parameter] public string BoxClass { get; set; }
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
[CascadingParameter] IDOverlayHost Overlays { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
IDisposable overlay;
ElementReference box;
bool wasOpen;
bool focusPending;
@@ -52,6 +76,7 @@
DModalPlacement.Bottom => "modal-bottom",
DModalPlacement.Start => "modal-start",
DModalPlacement.End => "modal-end",
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
_ => null
};
@@ -60,8 +85,26 @@
if (IsOpen && !wasOpen)
focusPending = true;
wasOpen = IsOpen;
TrackOverlay();
}
void TrackOverlay()
{
if (IsOpen && overlay is null)
overlay = Overlays?.Open(() => InvokeAsync(async () =>
{
await SetOpen(false);
StateHasChanged();
}));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public void Dispose() => overlay?.Dispose();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
@@ -78,6 +121,7 @@
{
IsOpen = isOpen;
wasOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+3 -1
View File
@@ -7,5 +7,7 @@ public enum DModalPlacement
Top,
Bottom,
Start,
End
End,
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
ResponsiveBottom
}
+3 -1
View File
@@ -1,5 +1,7 @@
using System.Globalization;
using decePubClient.Resources;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
@@ -39,7 +41,7 @@ public abstract class DInputBase<TValue> : InputBase<TValue>
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not valid.";
validationErrorMessage = string.Format(ErrorMessages.InvalidValue, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
}
+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>
}
+1 -1
View File
@@ -32,7 +32,7 @@
result = date;
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not a valid date.";
validationErrorMessage = string.Format(ErrorMessages.InvalidDate, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
+4 -1
View File
@@ -3,10 +3,13 @@
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
value is the content; rows, placeholder and the like pass through. *@
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
@code {
/// <summary>The textarea itself, for focusing it.</summary>
public ElementReference Element { get; private set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; }
+11
View File
@@ -0,0 +1,11 @@
namespace decePubClient.Components.Daisy;
/// <summary>
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
/// </summary>
public interface IDOverlayHost
{
IDisposable Open(Func<Task> close);
}
+2
View File
@@ -9,6 +9,8 @@
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string Class { get; set; }
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
[Parameter] public string AccessibleName { get; set; }
protected override void OnInitialized()
{
+1
View File
@@ -12,6 +12,7 @@
var index = i;
var tab = tabs[i];
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
@onclick="() => Select(index)">
@tab.Header
+27 -6
View File
@@ -6,14 +6,17 @@
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="flex-1 min-w-0 overflow-x-auto">
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
tabs wait; from md only the selected tab shows its name until xl *@
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
aria-label="@Localizer["Feeds"]">
@foreach (var type in Types)
{
<DTab Class="gap-1 whitespace-nowrap">
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
<Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
{
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
@@ -30,10 +33,15 @@
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
</NerdTip>
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<i class="ion-md-planet text-base"></i>
</DButton>
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
@* a phone held sideways has no room for the banner: the globe opens full screen *@
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
data-globe-full>
<i class="ion-md-planet text-base"></i>
</DButton>
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
<i class="ion-md-create text-base"></i>
<span class="hidden sm:inline">@Localizer["Post"]</span>
</DButton>
@@ -107,6 +115,8 @@
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { get; set; }
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
[Parameter] public EventCallback OnGlobeFull { get; set; }
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
@@ -120,6 +130,17 @@
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
ElementReference strip;
int revealed = -1;
protected override void OnAfterRender(bool firstRender)
{
if (ActiveIndex == revealed)
return;
revealed = ActiveIndex;
CascadingState.Status.RevealActive(strip);
}
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
[Inject] DiscoveryService DiscoveryService { get; set; }
+121 -115
View File
@@ -3,55 +3,58 @@
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
<div class="relative w-full h-full">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
@if (stats?.Sun is { } sun)
was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
<dl id="@($"{uid}-sun")" class="sr-only">
@if (sun is not null)
{
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
<span class="flex items-center gap-1 font-semibold">
<i class="@SunIcon(sun)"></i>
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt>@row.Label</dt>
<dd>@row.Value</dd>
}
}
</dl>
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
@* the moment the Sun is shown at: now, or the focused post's *@
@if (sun is not null)
{
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
data-globe-moment>
<i class="@SunIcon(sun)" aria-hidden="true"></i>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</span>
@if (sun.Phase is { } phase)
{
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
</p>
}
else
else if (error is { Length: > 0 })
{
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
}
</div>
</NerdTip>
</div>
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
}
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
@if (Expanded)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
}
else
{
<span>@Localizer["loading"]…</span>
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
</button>
}
</span>
</NerdTip>
</div>
</div>
@code {
@@ -61,23 +64,85 @@
/// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { get; set; } = [];
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
[Parameter] public bool Expanded { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
[Inject] OverlayService Overlays { get; set; }
ElementReference closeButton;
ElementReference expandButton;
IDisposable overlay;
bool focusClose;
bool focusExpand;
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
static readonly string[] InstrumentKeys =
[
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
];
async Task Open()
{
focusClose = true;
await ExpandedChanged.InvokeAsync(true);
}
async Task Close()
{
focusExpand = true;
await ExpandedChanged.InvokeAsync(false);
}
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
ElementReference element;
IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new();
int? sentFingerprint;
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
bool postsHandedOver = true;
GlobeStats stats;
SunStats sun;
string error;
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
protected override void OnInitialized() => IsLoading = false;
protected override void OnParametersSet() => postsHandedOver = true;
protected override void OnParametersSet()
{
postsHandedOver = true;
// full screen, the Back button closes it
if (Expanded && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(Close));
else if (!Expanded && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusClose && Expanded)
{
focusClose = false;
await closeButton.FocusAsync();
}
if (focusExpand && !Expanded)
{
focusExpand = false;
await expandButton.FocusAsync(preventScroll: true);
}
await base.OnAfterRenderAsync(firstRender);
try
{
@@ -85,10 +150,11 @@
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
statsTimer = new(TimeSpan.FromSeconds(1));
_ = ReadStats(cancellation.Token);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
{
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
});
}
if (postsHandedOver)
{
@@ -148,7 +214,8 @@
: null;
}
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
/// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
[JSInvokable]
public async Task SunChanged()
{
@@ -157,7 +224,7 @@
try
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
StateHasChanged();
}
catch (OperationCanceledException)
@@ -168,42 +235,12 @@
}
}
async Task ReadStats(CancellationToken cancellationToken)
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
[JSInvokable]
public void GlobeFailed(string message)
{
try
{
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable<NerdRow> StatsRows()
{
if (stats is null)
yield break;
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
error = message;
StateHasChanged();
}
IEnumerable<NerdRow> SunRows(SunStats sun)
@@ -272,9 +309,9 @@
public override async ValueTask DisposeAsync()
{
overlay?.Dispose();
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.Dispose();
reference?.Dispose();
try
{
@@ -295,37 +332,6 @@
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
sealed class GlobeStats
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Altitude { get; set; }
public int Visible { get; set; }
public int VisibleLocated { get; set; }
public double? CentroidLatitude { get; set; }
public double? CentroidLongitude { get; set; }
public double SpreadKm { get; set; }
public int Points { get; set; }
public int Posts { get; set; }
public int Servers { get; set; }
public int CdnGroups { get; set; }
public int Fps { get; set; }
public string Renderer { get; set; }
public string Texture { get; set; }
public int Anisotropy { get; set; }
public bool AutoRotate { get; set; }
public bool Paused { get; set; }
public bool Idle { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public int Countries { get; set; }
public int CountryNames { get; set; }
public int Borders { get; set; }
public SunStats Sun { get; set; }
public string Error { get; set; }
}
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
sealed class SunStats
{
+88
View File
@@ -0,0 +1,88 @@
@inherits LocalizableComponentBase
@* "Filter this post", from a card's menu: the persona's filters (/api/v2/filters), one of which takes the post (POST
/api/v2/filters/:id/statuses); a filter that holds it already says so. Filters are made and changed on /filters. The
card reads the post again afterwards, so the filter applies to it at once. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-filter-post-dialog>
<Heading>@Localizer["Filter this post"]</Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (filters.Count == 0)
{
<p class="text-sm opacity-70 mt-3">@Localizer["This persona has no filter yet."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var filter in filters)
{
<DButton Size="DSize.Sm" class="justify-start" Disabled="@(IsDefaultDisabled || isSaving || Holds(filter))" OnClick="() => Add(filter)"
data-filter-choice="@filter.Id">
<i class="ion-md-funnel" aria-hidden="true"></i>
<span class="flex-1 min-w-0 truncate text-left">@filter.Title</span>
<span class="text-xs opacity-70">@Description(filter)</span>
</DButton>
}
<a class="btn btn-sm justify-start" href="filters" data-filter-post-manage>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Make a filter"]</span>
</a>
</div>
</ChildContent>
</DModal>
@code {
[Inject] FilterService FilterService { get; set; }
[Parameter, EditorRequired] public string StatusId { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>The post went into a filter.</summary>
[Parameter] public EventCallback OnFiltered { get; set; }
List<Filter> filters = [];
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => filters = await FilterService.All();
bool Holds(Filter filter) => filter.Statuses.Any(s => s.StatusId == StatusId);
string Description(Filter filter) => Holds(filter) ? Localizer["Already in it"]
: !filter.IsActive ? Localizer["Expired"]
: filter.FilterAction switch
{
FilterAction.Hide => Localizer["Hide"],
FilterAction.Blur => Localizer["Blur"],
_ => Localizer["Warn"]
};
async Task Add(Filter filter)
{
isSaving = true;
var result = await FilterService.AddStatus(filter.Id, StatusId);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Added to {0}.", filter.Title]);
await OnFiltered.InvokeAsync();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+147
View File
@@ -0,0 +1,147 @@
@inherits LocalizableComponentBase
@* The accounts waiting for the persona to accept their follow (/api/v1/follow_requests), newest first, each opening its
profile, accepted (authorize) or refused (reject) here; "Load more" follows the Link header's next page when PrivaPub
sends one. On the persona's own profile (hidden while nobody waits) and in a sheet from the Notifications tab. *@
@if (!HideWhenEmpty || IsLoading || requests.Count > 0)
{
<section class="flex flex-none flex-col gap-2 w-full" data-follow-requests>
@if (ShowHeading)
{
<h2 class="font-semibold text-sm inline-flex items-center gap-2"><i class="ion-md-person-add" aria-hidden="true"></i>@Localizer["Follow requests"]</h2>
}
@if (IsLoading)
{
<p class="flex items-center gap-2 text-sm"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (requests.Count == 0)
{
<p class="text-sm opacity-70" data-follow-requests-empty>@Localizer["No follow request is waiting."]</p>
}
@foreach (var account in requests)
{
<div class="flex flex-wrap items-center gap-2 p-2 rounded-xl neo-raised neo-2xs min-w-0" data-follow-request="@account.Acct">
<a class="flex flex-1 items-center gap-3 min-w-0 pointer-coarse:min-h-11" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
@if (answers.TryGetValue(account.Id, out var accepted))
{
<span class="text-xs opacity-70" data-follow-request-answer="@(accepted ? "accepted" : "refused")">
@(accepted ? Localizer["Accepted."] : Localizer["Refused."])
</span>
}
else
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, true)"
data-follow-request-accept>
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Answer(account, false)"
data-follow-request-reject>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
}
</div>
}
@if (maxId is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore" data-follow-requests-more>
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</section>
}
@code {
[Inject] AccountService AccountService { get; set; }
/// <summary>Renders nothing once read with nobody waiting (the persona's own profile).</summary>
[Parameter] public bool HideWhenEmpty { get; set; }
[Parameter] public bool ShowHeading { get; set; }
/// <summary>A request was answered: true when accepted.</summary>
[Parameter] public EventCallback<bool> OnAnswered { get; set; }
List<Account> requests = [];
// what was answered here, by account id: accepted (true) or refused
Dictionary<string, bool> answers = new();
HashSet<string> busy = [];
string maxId;
string loadedPersonaId;
bool isLoadingMore;
// The persona changed (NavMenu's switcher): its requests are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
requests = [];
answers = new();
maxId = null;
await LoadPage();
IsLoading = false;
}
async Task LoadPage()
{
var result = await AccountService.FollowRequests(maxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
var known = requests.Select(a => a.Id).ToHashSet();
requests.AddRange(page.Accounts.Where(a => known.Add(a.Id)));
maxId = page.MaxId;
}
async Task LoadMore()
{
isLoadingMore = true;
await LoadPage();
isLoadingMore = false;
}
async Task Answer(Account account, bool accept)
{
busy.Add(account.Id);
var result = await AccountService.Answer(account.Id, accept);
busy.Remove(account.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
answers[account.Id] = accept;
await OnAnswered.InvokeAsync(accept);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
have done so, newest first, each opening its profile. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
<ChildContent>
@if (IsLoading)
+75
View File
@@ -0,0 +1,75 @@
@inherits LocalizableComponentBase
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
keys), and the original in a new tab for whoever wants the file. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
<Heading>
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
</Heading>
<ChildContent>
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
<figure class="flex flex-col gap-2 min-h-0">
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
data-blurhash="@Current.Blurhash" />
@if (Current.Description is { Length: > 0 } description)
{
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
}
</figure>
<div class="flex items-center justify-between gap-2">
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
title="@Localizer["Previous picture"]" data-picture-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
}
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-open" aria-hidden="true"></i>
<span>@Localizer["Open original"]</span>
</a>
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
title="@Localizer["Next picture"]" data-picture-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
}
</div>
</div>
</ChildContent>
</DModal>
@code {
/// <summary>The post's pictures, in its order.</summary>
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
/// <summary>The one opened.</summary>
[Parameter] public int Start { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
int index;
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
protected override void OnInitialized()
{
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
IsLoading = false;
}
void Previous() => index = Math.Max(0, index - 1);
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "ArrowLeft")
Previous();
else if (e.Key == "ArrowRight")
Next();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
}
+30
View File
@@ -0,0 +1,30 @@
@inherits LocalizableComponentBase
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>
@code {
[Parameter] public string Heading { get; set; }
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
[Parameter] public bool Pending { get; set; }
protected override void OnInitialized() => IsLoading = false;
}
+38 -21
View File
@@ -1,10 +1,32 @@
@inherits LocalizableComponentBase
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
@if (TapOpens)
{
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
@ChildContent
</DTooltip>
</DTooltip>
}
else
{
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@ChildContent
</DTooltip>
}
@if (isSheetOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
<Heading>@Heading</Heading>
<ChildContent>
<NerdRows Rows="Rows" Pending="Pending" />
</ChildContent>
</DModal>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
@@ -16,8 +38,19 @@
[Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; }
[Parameter] public string Class { get; set; }
/// <summary>A tap opens the rows in a sheet where the screen has no hover.</summary>
[Parameter] public bool TapOpens { get; set; } = true;
bool opened;
bool isSheetOpen;
async Task Tap()
{
if (CascadingState.Status.CanHover())
return;
isSheetOpen = true;
await Open();
}
protected override void OnInitialized() => IsLoading = false;
@@ -29,21 +62,5 @@
await OnOpen.InvokeAsync();
}
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>;
RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
}
+59
View File
@@ -0,0 +1,59 @@
@inherits LocalizableComponentBase
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
@if (IsOpen)
{
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
data-sheet>
<div class="md:hidden flex items-center gap-3 mb-3">
<b class="flex-1 min-w-0 truncate">@Heading</b>
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
@onclick="Close" data-sheet-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
</div>
@ChildContent
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>The sheet's heading on a phone.</summary>
[Parameter] public string Heading { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Class { get; set; }
[Inject] OverlayService Overlays { get; set; }
IDisposable overlay;
protected override void OnInitialized() => IsLoading = false;
// open, the Back button closes it
protected override void OnParametersSet()
{
if (IsOpen && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(Close));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public override async ValueTask DisposeAsync()
{
overlay?.Dispose();
await base.DisposeAsync();
}
Task Close() => OnClose.InvokeAsync();
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
}
+3 -2
View File
@@ -3,7 +3,8 @@
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
(PublicNoticeService), in MainLayout. *@
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
data-public-notice>
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
<ChildContent>
<p class="text-sm">
@@ -14,7 +15,7 @@
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)">@Localizer["Continue"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
</Actions>
</DModal>
+77
View File
@@ -0,0 +1,77 @@
@inherits LocalizableComponentBase
@* Who may quote one of the persona's own public or unlisted posts (PUT /api/v1/statuses/:id/interaction_policy,
quote_approval_policy): anyone, its followers or nobody, from the card's menu. The post states its policy in
quote_approval.automatic; PrivaPub tells the post's readers of the change. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-quote-policy-dialog>
<Heading>@Localizer["Who may quote it"]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 mt-3">
@foreach (var policy in Enum.GetValues<QuotePolicy>())
{
<DButton Size="DSize.Sm" class="justify-start" Active="policy == current" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => Choose(policy)"
aria-pressed="@(policy == current ? "true" : "false")" data-quote-policy-choice="@StatusService.QuotePolicyValue(policy)">
<i class="@PolicyIcon(policy)" aria-hidden="true"></i>
<span class="flex-1 text-left">@PolicyName(policy)</span>
@if (policy == current)
{
<i class="ion-md-checkmark text-primary" aria-hidden="true"></i>
}
</DButton>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] StatusService StatusService { get; set; }
[Parameter, EditorRequired] public Status Status { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
/// <summary>The post as PrivaPub now describes it, its new policy in it.</summary>
[Parameter] public EventCallback<Status> OnChanged { get; set; }
QuotePolicy current = QuotePolicy.Public;
bool isSaving;
protected override void OnInitialized()
{
current = StatusService.QuotePolicyOf(Status);
IsLoading = false;
}
string PolicyName(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => Localizer["Your followers"],
QuotePolicy.Nobody => Localizer["Nobody"],
_ => Localizer["Anyone"]
};
static string PolicyIcon(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => "ion-md-people",
QuotePolicy.Nobody => "ion-md-close-circle",
_ => "ion-md-globe"
};
async Task Choose(QuotePolicy policy)
{
if (policy == current)
return;
isSaving = true;
var result = await StatusService.SetQuotePolicy(Status.Id, policy);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (Status)result.Data;
current = StatusService.QuotePolicyOf(updated);
Toast.AddResponseSuccess(Localizer["Saved."]);
await OnChanged.InvokeAsync(updated);
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+64 -5
View File
@@ -1,8 +1,10 @@
@inherits LocalizableComponentBase
@* 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>
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A
violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the
server has rules. *@
<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">
@@ -11,8 +13,31 @@
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
@if (choices.Count > 0)
{
<option value="@ReportCategory.Violation">@Localizer[nameof(ReportCategory.Violation)]</option>
}
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect>
@if (form.Category is ReportCategory.Violation)
{
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2" data-report-rules>
<legend class="text-sm mb-1">@Localizer["Which of the server's rules does it break?"]</legend>
@foreach (var choice in choices)
{
<label class="flex items-start gap-2 cursor-pointer pointer-coarse:py-2">
<DCheckbox @bind-Value="choice.Picked" class="mt-0.5 flex-none" data-report-rule="@choice.Rule.Id" />
<span class="flex flex-col min-w-0">
<span class="text-sm break-words">@choice.Rule.Text</span>
@if (choice.Rule.Hint is { Length: > 0 } hint)
{
<span class="text-xs opacity-70 break-words">@hint</span>
}
</span>
</label>
}
</fieldset>
}
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
@if (Host is { } host)
@@ -20,13 +45,17 @@
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
}
<div class="flex justify-end gap-2">
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] AboutService AboutService { get; set; }
[Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary>
@@ -36,17 +65,41 @@
ReportForm form = new();
bool isSending;
string error;
// the server's rules, each ticked or not, for a violation
List<RuleChoice> choices = [];
// the account's own server, for a remote account
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
protected override void OnInitialized() => IsLoading = false;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => choices = [.. (await AboutService.Rules()).Select(rule => new RuleChoice { Rule = rule })];
async Task Send()
{
isSending = true;
error = null;
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward);
var ruleIds = form.Category is ReportCategory.Violation ? choices.Where(c => c.Picked).Select(c => c.Rule.Id).ToList() : [];
if (form.Category is ReportCategory.Violation && ruleIds.Count == 0)
{
error = Localizer["Tick the rules it breaks."];
return;
}
isSending = true;
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, ruleIds,
Host is not null && form.Forward);
isSending = false;
if (result.IsInvalid)
{
@@ -59,4 +112,10 @@
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
sealed class RuleChoice
{
public InstanceRule Rule { get; init; }
public bool Picked { get; set; }
}
}
+242 -3
View File
@@ -1,11 +1,112 @@
@inherits LocalizableComponentBase
@* The login itself (the root, /clientapi/user): its password changed, or the whole of it deleted, every persona with
it. Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
device's goes on with a new JWT); its two-factor sign-in (TotpEditor); every session of it ended at once; or the whole
of it deleted, every persona with it.
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
<div class="flex flex-col gap-4 w-full" data-account>
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
<b class="text-sm">@Localizer["Email address"]</b>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (account is not null)
{
@if (account.Email is { Length: > 0 } email)
{
<p class="flex flex-wrap items-center gap-2 text-sm">
<span class="break-all" data-account-email-address>@email</span>
@if (account.IsEmailValidated)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-account-email-verified>@Localizer["verified"]</DBadge>
}
else
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-email-unverified>@Localizer["not verified"]</DBadge>
}
</p>
@if (!account.IsEmailValidated)
{
<p class="text-xs opacity-70">@Localizer["Only a verified address can recover a forgotten password."]</p>
}
}
else
{
<p class="text-sm" data-account-email-none>@Localizer["No email address yet: without one, a forgotten password can't be recovered."]</p>
}
@if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail)
{
<EditForm Model="code" OnValidSubmit="VerifyEmail" class="flex flex-col gap-2" data-account-email-code>
<DataAnnotationsValidator />
@if (codeSentence is not null)
{
<p class="text-sm opacity-70">@codeSentence</p>
}
<p class="text-sm break-all">@Localizer["Enter the code sent to {0}.", pending]</p>
<DOtp @bind-Value="code.Code" Immediate="true" aria-label="@Localizer["Code"]" data-account-email-code-digits />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-verify>
<i class="ion-md-checkmark"></i><span>@Localizer["Verify"]</span>
</DButton>
</EditForm>
}
@if (isChangingEmail)
{
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
<DataAnnotationsValidator />
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none"
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-send>
<i class="ion-md-mail"></i><span>@Localizer["Send the code"]</span>
</DButton>
</div>
</EditForm>
}
else if (isRemovingEmail)
{
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-remove-confirm>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
@if (account.Email is { Length: > 0 })
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailRemoval" data-account-email-remove-open>
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
</DButton>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailChange" data-account-email-change-open>
<i class="ion-md-create"></i><span>@(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"])</span>
</DButton>
</div>
}
}
</div>
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["Change the password"]</b>
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-current />
<div class="flex flex-wrap gap-2">
@@ -20,6 +121,16 @@
</DButton>
</EditForm>
<TotpEditor />
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
<b class="text-sm">@Localizer["Sign out everywhere"]</b>
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
<DButton Size="DSize.Sm" Color="DColor.Warning" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="LogoutEverywhere" data-account-everywhere-confirm>
<i class="ion-md-log-out"></i><span>@Localizer["Sign out everywhere"]</span>
</DButton>
</div>
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
<DataAnnotationsValidator />
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
@@ -36,17 +147,125 @@
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
ViewAccount account;
EmailChangeForm change = new();
EmailCodeForm code = new();
PasswordForm emailRemoval = new();
UserPasswordForm password = new();
RemoveSelfForm removal = new();
/// <summary>PrivaPub's answer to the change asked here, the same whether or not the address is free.</summary>
string codeSentence;
bool isChangingEmail;
bool isRemovingEmail;
bool understood;
bool isSaving;
protected override void OnInitialized() => IsLoading = false;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => account = await AuthService.Account();
void OpenEmailChange()
{
change = new();
isRemovingEmail = false;
isChangingEmail = true;
}
void OpenEmailRemoval()
{
emailRemoval = new();
isChangingEmail = false;
isRemovingEmail = true;
}
void CloseEmailForms()
{
isChangingEmail = false;
isRemovingEmail = false;
}
async Task ChangeEmail()
{
isSaving = true;
change.Email = change.Email.Trim();
var result = await AuthService.ChangeEmail(change);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
codeSentence = result.Data is string { Length: > 0 } said
? said
: Localizer["If that address can be used, a code is on its way to it. It works for one hour."];
// as PrivaPub keeps it while it waits for its code
account.PendingEmail = change.Email.ToLowerInvariant();
change = new();
code = new();
isChangingEmail = false;
}
async Task VerifyEmail()
{
isSaving = true;
var result = await AuthService.VerifyEmail(code.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
// five wrong codes, or an hour, and the address no longer waits
account = await AuthService.Account() ?? account;
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
code = new();
codeSentence = default;
Toast.AddResponseSuccess(Localizer["Email address verified."]);
}
async Task RemoveEmail()
{
if (!await Confirm.Ask(Localizer["Remove the email address?"], Localizer["Without one, a forgotten password can't be recovered."],
Localizer["Remove the address"], true))
return;
isSaving = true;
var result = await AuthService.RemoveEmail(emailRemoval.Password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
emailRemoval = new();
codeSentence = default;
isRemovingEmail = false;
Toast.AddResponseSuccess(Localizer["Email address removed."]);
}
async Task ChangePassword()
{
isSaving = true;
// the session goes on with the JWT PrivaPub answers with, and the persona with a new token
var result = await AuthService.ChangePassword(password);
isSaving = false;
if (result.IsInvalid)
@@ -59,6 +278,26 @@
Toast.AddResponseSuccess(Localizer["Password changed."]);
}
async Task LogoutEverywhere()
{
if (!await Confirm.Ask(Localizer["Sign out everywhere?"],
Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."],
Localizer["Sign out everywhere"], true))
return;
isSaving = true;
var result = await AuthService.LogoutEverywhere();
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
async Task Delete()
{
isSaving = true;
+9 -3
View File
@@ -14,7 +14,7 @@
data-export-kind="@data">
@if (exporting == data)
{
<DLoading Size="DSize.Xs" />
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
else
{
@@ -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)
{
}
}
}
+68 -21
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">
<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="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
<label class="flex flex-col gap-1 text-sm">
@if (HasOwn(account.Avatar) && form.Avatar is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
</label>
@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">
@@ -68,7 +84,7 @@ else
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Visibility"]</span>
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
<DSelect TValue="Visibility" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
@foreach (var type in Visibilities)
{
<option value="@type">@Localizer[type.ToString()]</option>
@@ -110,18 +126,24 @@ 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];
static readonly Visibility[] Visibilities = [Visibility.Public, Visibility.Unlisted, Visibility.FollowersOnly];
Account account;
ProfileForm form = new();
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;
}
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)}";
// 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
{
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;
+306
View File
@@ -0,0 +1,306 @@
@inherits LocalizableComponentBase
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
<p class="flex flex-wrap items-center gap-2">
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
@if (account is { TwoFactorEnabled: true })
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
}
else if (account is not null)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
}
</p>
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (recoveryCodes.Count > 0)
{
<div class="flex flex-col gap-3" data-totp-codes>
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
</DAlert>
<p class="text-sm">
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
</p>
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
@foreach (var recoveryCode in recoveryCodes)
{
<li>@recoveryCode</li>
}
</ol>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
</DButton>
</div>
</div>
}
else if (account is { TwoFactorEnabled: true })
{
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
</DAlert>
}
@if (isDisabling)
{
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-disable-password />
@if (disableWithRecoveryCode)
{
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
}
else
{
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
</DButton>
}
}
else if (account is not null)
{
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
@if (setup is not null)
{
<div class="flex flex-col gap-3" data-totp-scan>
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Key"]</span>
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
</div>
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
</DButton>
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
<DataAnnotationsValidator />
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
<ErrorViewer />
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
</DButton>
</div>
</EditForm>
</div>
}
else if (isAskingPassword)
{
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
<DataAnnotationsValidator />
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-totp-setup-password />
<ErrorViewer />
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
</DButton>
</div>
</EditForm>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
data-totp-setup-open>
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
</DButton>
}
}
</div>
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
/// <summary>From this many recovery codes left down, the block warns.</summary>
const int FewRecoveryCodes = 3;
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
ViewAccount account;
PasswordForm setupPassword = new();
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
ViewTotpSetup setup;
TotpCodeForm enable = new();
TotpDisableForm disable = new();
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
List<string> recoveryCodes = [];
bool isAskingPassword;
bool isDisabling;
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool disableWithRecoveryCode;
bool isSaving;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => account = await AuthService.Account();
void OpenSetup()
{
setupPassword = new();
isAskingPassword = true;
}
void OpenDisable()
{
disable = new();
disableWithRecoveryCode = false;
isDisabling = true;
}
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
void CloseForms()
{
isAskingPassword = false;
isDisabling = false;
setup = default;
setupPassword = new();
enable = new();
disable = new();
}
void ToggleDisableRecoveryCode()
{
disableWithRecoveryCode = !disableWithRecoveryCode;
disable.Code = default;
}
async Task Setup()
{
isSaving = true;
var result = await AuthService.TotpSetup(setupPassword.Password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
// a 409: two-factor sign-in was turned on meanwhile, from another device
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
}
return;
}
setup = result.Data as ViewTotpSetup;
setupPassword = new();
enable = new();
isAskingPassword = false;
}
async Task Enable()
{
isSaving = true;
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
var result = await AuthService.TotpEnable(enable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
enable = new();
// a 409: the setup expired (an hour), so it begins again with the password
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
{
CloseForms();
account = await AuthService.Account() ?? account;
isAskingPassword = account is { TwoFactorEnabled: false };
}
return;
}
recoveryCodes = result.Data as List<string> ?? [];
setup = default;
enable = new();
account.TwoFactorEnabled = true;
account.RecoveryCodesLeft = recoveryCodes.Count;
}
async Task CopyCodes()
{
// a refusal is reported by main.js; the codes can still be selected and copied by hand
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
return;
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
}
void CodesSaved() => recoveryCodes = [];
async Task Disable()
{
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
return;
isSaving = true;
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
disable.Code = default;
return;
}
account = result.Data as ViewAccount ?? await AuthService.Account();
CloseForms();
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
}
}
+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">
-704
View File
@@ -1,704 +0,0 @@
{
"_type": "export",
"__export_format": 4,
"__export_date": "2022-02-14T20:02:43.810Z",
"__export_source": "insomnia.desktop.app:v2021.7.2",
"resources": [
{
"_id": "req_db7df93225be442e89a167ae594c5551",
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
"modified": 1644868863001,
"created": 1643816994345,
"url": "https://localhost:5000/api/v1/message/add",
"name": "Add message",
"description": "rootMessageId => un susseguirsi di risposte ad un messaggio si portera' dietro sempre l'id del messaggio di partenza\n\ntitle => titolo opzionale\n\ncontent => testo del messaggio, con supporto di markdown\n\nResponse JSON:\n\n>\t\t{\n>\t\t\t\"user\": {\n\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n\t\t\t\t\"displayName\": \"loosy\",\n\t\t\t\t\"pictureUrl\": \"https://...\",\n\t\t\t\t\"backgroundUrl\": \"https://...\",\n\t\t\t\t\"profileUrl\": \"https://...\"\n\t\t\t},\n\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n\t\t\t\"rootMessageId\": null,\n\t\t\t\"messageId\": \"guid/uuid/whatever\",\n\t\t\t\"title\": \"...\",\n\t\t\t\"content\": \"...\",\n\t\t\t\"isFavourite\": false,\n\t\t\t\"isBoosted\": false,\n\t\t\t\"media\": [{\n\t\t\t\t\t\"fileName\": \"ciao.png\",\n\t\t\t\t\t\"altText\": \"descrizione immagine\",\n\t\t\t\t\t\"contentType\": \"image/png\",\n\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n\t\t\t}],\n\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t}\n",
"method": "POST",
"body": {
"mimeType": "application/json",
"text": " {\n\t\"contentType\": \"1||2||3\",//PlainText,HTML,Markdown\n\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public,\n\t\"mediaType\": \"1||2||3||4\",//Images,Video,Audio,Documents\n\t\"rootMessageId\": null,\n\t\"title\": \"...\",\n\t\"content\": \"...\",\n\t\"media\": [{\n\t\t\"fileName\": \"ciao.png\",\n \"altText\": \"descrizione opzionale immagine\",\n\t\t\"contentType\": \"image/png\",//mime\n\t\t\"blob\": [23,33,11,...]//byteArray || base64?\n\t}],\n\t\"createdAt\": \"UTC or ticks\"\n}\n\n"
},
"parameters": [],
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"id": "pair_b95faf0515f9453595110e7895330c5f"
}
],
"authentication": {},
"metaSortKey": -1643816957190,
"isPrivate": false,
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "fld_2fca219979e448c997a6788d2b539ee7",
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
"modified": 1643829443147,
"created": 1643816769446,
"name": "APIs",
"description": "",
"environment": {},
"environmentPropertyOrder": {},
"metaSortKey": -1643816769446,
"_type": "request_group"
},
{
"_id": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
"parentId": null,
"modified": 1608987862911,
"created": 1608987862911,
"name": "Insomnia",
"description": "",
"scope": "collection",
"_type": "workspace"
},
{
"_id": "req_b33056a8e8a64c838662a60a4909537c",
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
"modified": 1643829255295,
"created": 1643817054991,
"url": "https://localhost:5000/api/v1/message/messageId/delete",
"name": "Remove message",
"description": "[queryParam] messageId => id del messaggio da eliminare\n\nResponse 200\n",
"method": "DELETE",
"body": {},
"parameters": [],
"headers": [],
"authentication": {
"type": "oauth2",
"grantType": "authorization_code"
},
"metaSortKey": -1643816957177.5,
"isPrivate": false,
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "req_95c442071ee247f1a97d823c6e503db2",
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
"modified": 1644868763642,
"created": 1643816956965,
"url": "https://localhost:5000/api/v1/messages",
"name": "Get messages list",
"description": "Response JSON:\n>\t\t[\n>\t\t\t{\n>\t\t\t\t\"user\": {\n>\t\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n>\t\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n>\t\t\t\t\t\"displayName\": \"loosy\",\n>\t\t\t\t\t\"pictureUrl\": \"https://...\",\n>\t\t\t\t\t\"backgroundUrl\": \"https://...\",\n>\t\t\t\t\t\"profileUrl\": \"https://...\"\n>\t\t\t\t},\n>\t\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n>\t\t\t\t\"rootMessageId\": null,\n>\t\t\t\t\"messageId\": \"guid/uuid/whatever\",\n>\t\t\t\t\"title\": \"...\",\n>\t\t\t\t\"content\": \"...\",\n>\t\t\t\t\"isFavourite\": false,\n>\t\t\t\t\"isBoosted\": false,\n>\t\t\t\t\"media\": [{\n>\t\t\t\t\t\"fileName\": \"ciao.png\",\n>\t\t\t\t\t\"altText\": \"descrizione immagine\",\n>\t\t\t\t\t\"contentType\": \"image/png\",\n>\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n>\t\t\t\t}],\n>\t\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t\t}, {...}\n>\t\t]",
"method": "GET",
"body": {},
"parameters": [
{
"id": "pair_6e5222c6a78341bbb24f9b18d236df6c",
"name": "since",
"value": "UTC/ticks/messageId?",
"description": ""
}
],
"headers": [],
"authentication": {},
"metaSortKey": -1643816956965,
"isPrivate": false,
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
"modified": 1642377560788,
"created": 1608987862952,
"name": "Base Environment",
"data": {},
"dataPropertyOrder": {},
"color": null,
"isPrivate": false,
"metaSortKey": 1608987862952,
"_type": "environment"
},
{
"_id": "jar_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
"modified": 1642378408249,
"created": 1608987862953,
"name": "Default Jar",
"cookies": [
{
"key": "oc_sessionPassphrase",
"value": "nMmK2Vb0FhKNaSQiPteel3EhA5swjs9%2FYJwU79BzPrq1cgNmCdL2w88ktlfpy8BXU1Yuj7MKSmCLNYMB0NIDFZ%2B7AnbhqpW0am5cFolB4eRzT2uSUSg16nKnAPVXAkGa",
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=Lax" ],
"hostOnly": true,
"creation": "2021-05-04T10:48:03.279Z",
"lastAccessed": "2021-05-04T10:48:03.279Z",
"id": "06476282363234076"
},
{
"key": "occ7de4yt74j",
"value": "dsleqfmojuisnmgcq63p0nrk77",
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=Lax" ],
"hostOnly": true,
"creation": "2021-05-04T10:48:03.279Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "9557040429295196"
},
{
"key": "__Host-nc_sameSiteCookielax",
"value": "true",
"expires": "2100-12-31T23:59:59.000Z",
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=lax" ],
"hostOnly": true,
"creation": "2021-05-04T10:48:03.280Z",
"lastAccessed": "2021-05-04T10:48:03.280Z",
"id": "4410522677170672"
},
{
"key": "__Host-nc_sameSiteCookiestrict",
"value": "true",
"expires": "2100-12-31T23:59:59.000Z",
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=strict" ],
"hostOnly": true,
"creation": "2021-05-04T10:48:03.280Z",
"lastAccessed": "2021-05-04T10:48:03.280Z",
"id": "7173600438498109"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2021-05-04T11:49:33.000Z",
"maxAge": 3600,
"domain": "nuvola.xyz",
"path": "/apps/dashboard",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:49:20.818Z",
"lastAccessed": "2021-05-04T10:49:33.582Z",
"id": "3533626334860853"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/apps/dashboard",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:49:20.818Z",
"lastAccessed": "2021-05-04T10:49:33.582Z",
"id": "06043516223469947"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/apps/dashboard",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:49:20.819Z",
"lastAccessed": "2021-05-04T10:49:33.582Z",
"id": "1905708786875855"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/apps/dashboard",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:49:20.819Z",
"lastAccessed": "2021-05-04T10:49:33.582Z",
"id": "4672408440036"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2021-05-04T10:49:20.819Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "7693972008479257"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2021-05-04T10:49:20.819Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "9731704342625882"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2021-05-04T10:49:20.819Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "3557084085645068"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2021-05-04T11:51:28.000Z",
"maxAge": 3600,
"domain": "nuvola.xyz",
"path": "/remote.php/dav/files",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:51:28.651Z",
"lastAccessed": "2021-05-04T10:51:28.651Z",
"id": "8645799753636227"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/remote.php/dav/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:51:44.619Z",
"lastAccessed": "2021-05-04T10:51:44.619Z",
"id": "9029869294914612"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/remote.php/dav/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:51:44.619Z",
"lastAccessed": "2021-05-04T10:51:44.619Z",
"id": "6422488233461934"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/remote.php/dav/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T10:51:44.619Z",
"lastAccessed": "2021-05-04T10:51:44.619Z",
"id": "7557066093242031"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2021-05-04T12:01:26.000Z",
"maxAge": 3600,
"domain": "nuvola.xyz",
"path": "/index.php/login",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T11:01:26.192Z",
"lastAccessed": "2021-05-04T11:01:26.192Z",
"id": "7093910949861533"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/index.php/login",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T11:01:42.223Z",
"lastAccessed": "2021-05-04T11:01:42.223Z",
"id": "677709203943275"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/index.php/login",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T11:01:42.224Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "9477816718956802"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "nuvola.xyz",
"path": "/index.php/login",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2021-05-04T11:01:42.224Z",
"lastAccessed": "2021-05-04T11:01:42.224Z",
"id": "41804757195232756"
},
{
"key": "oc_sessionPassphrase",
"value": "pUIH26mXSdmGTfubB4dUzw8F8MQsUHdwnHCeo7ElPWIGDxXte2XC1M0EMyN5eriarJcMmkfh4kSGFI9%2BRcs%2BHt2gdDygddkz5I7Tl8KtjkwC%2BawIYuL8z3lobWTL%2BCy%2B",
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=Lax" ],
"hostOnly": true,
"creation": "2022-01-17T00:03:10.727Z",
"lastAccessed": "2022-01-17T00:03:10.727Z",
"id": "06313521114695941"
},
{
"key": "__Host-nc_sameSiteCookielax",
"value": "true",
"expires": "2100-12-31T23:59:59.000Z",
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=lax" ],
"hostOnly": true,
"creation": "2022-01-17T00:03:10.728Z",
"lastAccessed": "2022-01-17T00:03:10.728Z",
"id": "860532957782806"
},
{
"key": "__Host-nc_sameSiteCookiestrict",
"value": "true",
"expires": "2100-12-31T23:59:59.000Z",
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=strict" ],
"hostOnly": true,
"creation": "2022-01-17T00:03:10.728Z",
"lastAccessed": "2022-01-17T00:03:10.728Z",
"id": "31117326197847706"
},
{
"key": "ocl7d3yrw74j",
"value": "uecghev7rleu4uptmruhptuvto",
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"extensions": [ "SameSite=Lax" ],
"hostOnly": true,
"creation": "2022-01-17T00:03:10.728Z",
"lastAccessed": "2022-01-17T00:13:28.249Z",
"id": "5917940161261273"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2022-01-17T01:03:09.000Z",
"maxAge": 3600,
"domain": "cloud.zigzag.academy",
"path": "/index.php/login",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:03:10.728Z",
"lastAccessed": "2022-01-17T00:03:10.728Z",
"id": "009292942805962934"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/index.php/login/v2",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:04:18.252Z",
"lastAccessed": "2022-01-17T00:04:18.252Z",
"id": "5470762369580981"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/index.php/login/v2",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:04:18.252Z",
"lastAccessed": "2022-01-17T00:04:18.252Z",
"id": "4385020724400406"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/index.php/login/v2",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:04:18.253Z",
"lastAccessed": "2022-01-17T00:04:18.253Z",
"id": "05297254853120181"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2022-01-17T00:04:18.253Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "46009278671166176"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2022-01-17T00:04:18.253Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "05812364381235913"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"creation": "2022-01-17T00:04:18.253Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "3772361407666647"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2022-01-17T01:05:17.000Z",
"maxAge": 3600,
"domain": "cloud.zigzag.academy",
"path": "/apps/files",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:05:18.388Z",
"lastAccessed": "2022-01-17T00:05:18.388Z",
"id": "08277815359810625"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/apps/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:05:18.388Z",
"lastAccessed": "2022-01-17T00:05:18.388Z",
"id": "7962572269421415"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/apps/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:05:18.388Z",
"lastAccessed": "2022-01-17T00:05:18.388Z",
"id": "8604909194077435"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/apps/files",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:05:18.388Z",
"lastAccessed": "2022-01-17T00:05:18.388Z",
"id": "6043027104701622"
},
{
"key": "cookie_test",
"value": "test",
"expires": "2022-01-17T01:11:28.000Z",
"maxAge": 3600,
"domain": "cloud.zigzag.academy",
"path": "/ocs/v1.php/cloud",
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:11:28.872Z",
"lastAccessed": "2022-01-17T00:11:28.872Z",
"id": "014769824799037545"
},
{
"key": "nc_username",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/ocs/v1.php/cloud",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:12:52.404Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "7141536696124962"
},
{
"key": "nc_token",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/ocs/v1.php/cloud",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:12:52.404Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "08455474112707173"
},
{
"key": "nc_session_id",
"value": "deleted",
"expires": "1970-01-01T00:00:01.000Z",
"maxAge": 0,
"domain": "cloud.zigzag.academy",
"path": "/ocs/v1.php/cloud",
"secure": true,
"httpOnly": true,
"hostOnly": true,
"pathIsDefault": true,
"creation": "2022-01-17T00:12:52.404Z",
"lastAccessed": "2022-01-17T00:12:52.404Z",
"id": "141765916939931"
}
],
"_type": "cookie_jar"
},
{
"_id": "spc_f832b28de5204b4a88374d08ba93a8e2",
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
"modified": 1608987862912,
"created": 1608987862912,
"fileName": "Insomnia",
"contents": "",
"contentType": "yaml",
"_type": "api_spec"
},
{
"_id": "env_77dfa781871a430f8e5014b07c9f9061",
"parentId": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
"modified": 1642377578797,
"created": 1642377566379,
"name": "Nextcloud API",
"data": {},
"dataPropertyOrder": {},
"color": null,
"isPrivate": false,
"metaSortKey": 1642377566379,
"_type": "environment"
}
]
}
-68
View File
@@ -1,22 +1,15 @@
using Blazored.LocalStorage;
using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Services;
using Markdig;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PrivaPub.ClientModels;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
@@ -28,24 +21,6 @@ namespace decePubClient.Extensions
{
public static partial class ExtensionMethods
{
#region Navigation Manager
public static NameValueCollection QueryString(this NavigationManager navigationManager) =>
HttpUtility.ParseQueryString(new Uri(navigationManager.Uri).Query);
public static string QueryString(this NavigationManager navigationManager, string key) =>
navigationManager.QueryString()[key];
public static T QueryString<T>(this NavigationManager navigationManager, string key)
{
var value = navigationManager.QueryString()[key];
if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
return default;
return (T)Convert.ChangeType(value, typeof(T));
}
#endregion Navigation Manager
#region Setups
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
@@ -82,30 +57,6 @@ namespace decePubClient.Extensions
#endregion Setups
public static string GetLogLevelClass(this string logType) => logType switch
{
"Debug" => "text-base-content/60",
"Information" => "text-info",
"Warning" => "text-warning",
"Error" => "text-error",
"Fatal" => "text-error font-bold",
_ => string.Empty
};
#region Formats
public static MarkdownPipeline Pipeline =>
new MarkdownPipelineBuilder().UseGenericAttributes().Build();
public static string ToJson(this object value) =>
JsonSerializer.Serialize(value);
public static string ToBase64PngImage(this string base64Image) =>
$"data:image/png;base64,{base64Image}";
#endregion Formats
#region Models
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
@@ -240,25 +191,6 @@ namespace decePubClient.Extensions
}
}
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
{
var timeframe = DateTime.Now - creationDate.ToLocalTime();
switch ((int)timeframe.TotalHours)
{
case >= 24:
return localizer["{0} day/s", (int)timeframe.TotalDays];
case >= 1 and < 24:
return localizer["{0} hour/s", (int)timeframe.TotalHours];
case 0:
return localizer["{0} minute/s", (int)timeframe.TotalMinutes];
default:
return string.Empty;
}
}
public static AuthorizationPolicy IsAdminPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsAdmin, "true")
+16 -36
View File
@@ -1,5 +1,4 @@
using collAnon.Client.Services;
using System.Collections.Specialized;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
@@ -12,12 +11,6 @@ using decePubClient.Models.Types;
using decePubClient.Resources;
using decePubClient.Services;
using DnetIndexedDb;
using DnetIndexedDb.Fluent;
using DnetIndexedDb.Models;
using Markdig;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Localization;
@@ -26,26 +19,6 @@ namespace decePubClient.Extensions;
public static class GenericExtensions
{
public static IServiceCollection AddIndexedDb(this IServiceCollection services)
{
return services.AddIndexedDbDatabase<IndexedDb>(options =>
{
var model = new IndexedDbDatabaseModel()
.WithName("data")
.WithVersion(3)
.WithModelId(1);
// IStorage.AddLog numbers entries itself (max key + 1), so the key is Id rather than generated. Version 2
// added this store; version 3 left the mock feed's Message store out of the model.
model.AddStore(nameof(ClientLogs))
.WithKey(nameof(ClientLogs.Id))
.AddUniqueIndex(nameof(ClientLogs.Id))
.AddIndex(nameof(ClientLogs.TimeStamp));
options.UseDatabase(model);
});
}
public static string GetPassedTime(this DateTime dateTime, CoalescingStringLocalizer localizer)
{
var timeframe = DateTime.Now - dateTime.ToLocalTime();
@@ -57,23 +30,32 @@ 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],
_ => localizer["{0}KB", size / 1_024]
};
public static string GetMessageTypeIcon(this MessageType messageType)
public static string GetVisibilityIcon(this Visibility visibility)
{
switch (messageType)
switch (visibility)
{
case MessageType.Public:
case Visibility.Public:
return "ion-md-globe";
case MessageType.Unlisted:
case Visibility.Unlisted:
return "ion-md-unlock";
case MessageType.FollowersOnly:
case Visibility.FollowersOnly:
return "ion-md-lock";
case MessageType.Direct:
case Visibility.Direct:
return "ion-md-paper-plane";
default:
return default;
@@ -104,8 +86,6 @@ public static class GenericExtensions
return "ion-md-images";
case MediaType.Video:
return "ion-md-videocam";
case MediaType.Documents:
return "ion-md-document";
case MediaType.Audio:
return "ion-md-volume-high";
default:
-54
View File
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Mail;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
@@ -108,58 +107,5 @@ namespace decePubClient.Helpers
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
conditionIsTrue ? ifTrue : ifFalse;
public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
{
// convert datetime to a TimeSpan
var now = datetime.TimeOfDay;
// see if start comes before end
if (start < end)
return start <= now && now <= end;
// start is after end, so do the inverse comparison
return !(end < now && now < start);
}
public static string GetFileIcon(string fileName)
{
switch (Path.GetExtension(fileName))
{
case ".odp":
case ".pptx":
return "file-powerpoint";
case ".ods":
case ".xlsx":
return "file-excel";
case ".odt":
case ".docx":
return "file-word";
case ".pdf":
return "file-pdf";
case ".jpg":
case ".jpeg":
case ".png":
return "file-image";
default:
return "file";
}
}
public static bool IsValidEmail(string email)
{
try
{
var addr = new MailAddress(email);
return addr.Address == email;
}
catch
{
return false;
}
}
}
}
+109 -2
View File
@@ -3,7 +3,8 @@
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
compare PersonaId to reload what belongs to the persona. *@
compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's:
taken at sign-in and at start-up, saved there a moment after it changes here. *@
<CascadingValue IsFixed=false Value=this>
@ChildContent
</CascadingValue>
@@ -11,6 +12,8 @@
@code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
/// <summary>How long a change of the page settings waits before it is saved as the login's: a slider sends many.</summary>
static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500);
[Parameter] public RenderFragment ChildContent { get; set; }
[Inject] ILocalStorageService Storage { get; set; }
@@ -27,6 +30,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>
@@ -40,6 +45,11 @@
PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer;
readonly CancellationTokenSource disposeCancellation = new();
/// <summary>What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already
/// match it, the login's own just taken among them, aren't saved again.</summary>
(string, short, short, bool, bool, bool, bool)? loginSettings;
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
CancellationTokenSource pendingSettingsSave;
protected override async Task OnInitializedAsync()
{
@@ -57,6 +67,8 @@
_ = PollOnline(cancellationToken);
await LoadSession(cancellationToken);
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken);
}
@@ -75,13 +87,98 @@
StateHasChanged();
}
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary>
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the
/// login's a moment later (SaveSettingsSoon).</summary>
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
{
change(PublicCacheData.PageSettings);
SaveSettingsSoon();
return UpdatePublicCache(PublicCacheData, cancellationToken);
}
/// <summary>
/// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the
/// login's settings), then keeps the theme with the login's (KeepThemeWithLogin).
/// </summary>
public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
loginSettings = null;
await LoadSession(cancellationToken);
await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken);
}
/// <summary>
/// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the
/// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing.
/// </summary>
async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken)
{
if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin)
return;
if (settings.SavedAt is null)
{
SaveSettingsSoon();
return;
}
PublicCacheData.PageSettings.TakeLoginTheme(settings);
// as the login keeps them now, so taking them isn't followed by saving them back
loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings());
await UpdatePublicCache(PublicCacheData, cancellationToken);
}
/// <summary>
/// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with
/// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure
/// is logged (AuthService.SaveSettings), never shown.
/// </summary>
void SaveSettingsSoon()
{
CancelSettingsSave();
var settings = PublicCacheData.PageSettings;
if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings)
return;
pendingSettingsSave = new();
_ = SaveSettingsLater(pendingSettingsSave.Token);
}
async Task SaveSettingsLater(CancellationToken cancellationToken)
{
try
{
await Task.Delay(SettingsSaveDelay, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
if (!IsSignedIn)
return;
var settings = PublicCacheData.PageSettings.ToLoginSettings();
var result = await AuthService.SaveSettings(settings, cancellationToken);
if (result.IsInvalid)
return;
loginSettings = LoginFingerprint(settings);
}
void CancelSettingsSave()
{
var pending = pendingSettingsSave;
pendingSettingsSave = null;
pending?.Cancel();
pending?.Dispose();
}
/// <summary>What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved.</summary>
static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) =>
(settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming,
settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray);
/// <summary>
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
/// acts as the last one used (the first one otherwise).
@@ -93,6 +190,7 @@
if (!IsSignedIn)
{
ClearSession();
IsSessionLoaded = true;
StateHasChanged();
return;
}
@@ -105,11 +203,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>
@@ -153,6 +256,7 @@
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default)
{
CancelSettingsSave();
await AuthService.Logout(cancellationToken);
ClearSession();
StateHasChanged();
@@ -160,6 +264,8 @@
void ClearSession()
{
CancelSettingsSave();
loginSettings = null;
IsSignedIn = false;
RootUserName = null;
Personas = [];
@@ -228,6 +334,7 @@
{
await disposeCancellation.CancelAsync();
disposeCancellation.Dispose();
CancelSettingsSave();
onlineTimer?.Dispose();
refreshTimer?.Dispose();
cascadingStateReference?.Dispose();
@@ -1,5 +1,3 @@
using collAnon.Client.Services;
using decePubClient.Services;
using Microsoft.AspNetCore.Components;
-6
View File
@@ -1,6 +0,0 @@
namespace decePubClient.LayerComponents
{
public class PagesBase : LocalizableComponentBase
{
}
}
+9
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))]
@@ -58,6 +59,14 @@ namespace decePubClient;
[JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))]
[JsonSerializable(typeof(UnreadCount))]
[JsonSerializable(typeof(InstanceRule))]
[JsonSerializable(typeof(List<InstanceRule>))]
[JsonSerializable(typeof(InstanceDocument))]
[JsonSerializable(typeof(TermsOfService))]
[JsonSerializable(typeof(List<PublicDomainBlock>))]
[JsonSerializable(typeof(Announcement))]
[JsonSerializable(typeof(List<Announcement>))]
[JsonSerializable(typeof(AnnouncementReactionEvent))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+71 -4
View File
@@ -9,16 +9,30 @@ public static class APIs
{
public const string ClientApiPrefix = "clientapi/";
public const string MastodonPrefix = "api/";
public const string OAuthPrefix = "oauth/";
public static class ClientApi
{
public const string POST_Login = "clientapi/user/login";
public const string GET_Setup = "clientapi/user/setup";
public const string POST_Setup = "clientapi/user/setup";
public const string POST_InvitationSignUp = "clientapi/user/invitation/signup";
public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password";
public const string POST_LogoutEverywhere = "clientapi/user/logout/everywhere";
public const string POST_RecoverPassword = "clientapi/user/recover/password";
public const string POST_RecoverValid = "clientapi/user/recover/valid";
public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password";
public const string GET_Account = "clientapi/user/account";
public const string POST_Email = "clientapi/user/email";
public const string POST_EmailVerify = "clientapi/user/email/verify";
public const string POST_EmailRemove = "clientapi/user/email/remove";
public const string POST_TotpSetup = "clientapi/user/totp/setup";
public const string POST_TotpEnable = "clientapi/user/totp/enable";
public const string POST_TotpDisable = "clientapi/user/totp/disable";
public const string GET_UserSettings = "clientapi/user/settings";
public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_CurrentVersion = "clientapi/data/current-version";
public const string GET_Personas = "clientapi/avatar/private/list";
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
public const string GET_Groups = "clientapi/group/list";
@@ -33,14 +47,48 @@ 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_ModeratorLog = "clientapi/moderator/log";
public const string GET_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminSettings = "clientapi/admin/settings";
public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail";
public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list";
public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert";
public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update";
public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}";
public const string GET_AdminRelays = "clientapi/admin/relays";
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
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 static string GET_M_StatisticsHost(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}";
public static string POST_M_StatisticsDescribe(string host) => $"clientapi/admin/statistics/hosts/{Uri.EscapeDataString(host)}/describe";
public const string GET_StatisticsEvents = "clientapi/admin/statistics/events";
public const string GET_StatisticsServer = "clientapi/admin/statistics/server";
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
@@ -70,8 +118,22 @@ public static class APIs
public const string GET_Conversations = "api/v1/conversations";
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
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 static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public const string GET_InstanceV2 = "api/v2/instance";
public const string GET_InstanceRules = "api/v1/instance/rules";
public const string GET_InstanceExtendedDescription = "api/v1/instance/extended_description";
public const string GET_InstancePrivacyPolicy = "api/v1/instance/privacy_policy";
public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service";
public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks";
public const string GET_Announcements = "api/v1/announcements";
public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss";
public static string M_AnnouncementReaction(string announcementId, string emoji) =>
$"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}";
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";
@@ -81,6 +143,8 @@ public static class APIs
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}";
public static string GET_M_AccountStatuses(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/statuses";
public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following";
public static string GET_M_AccountFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/followers";
public static string POST_M_RemoveFromFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/remove_from_followers";
public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context";
public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
@@ -110,9 +174,13 @@ public static class APIs
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string PUT_M_StatusInteractionPolicy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/interaction_policy";
public static string POST_M_RevokeQuote(string quotedId, string quotingId) =>
$"api/v1/statuses/{Uri.EscapeDataString(quotedId)}/quotes/{Uri.EscapeDataString(quotingId)}/revoke";
public const string GET_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications";
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
public static string POST_M_DismissNotificationGroup(string groupKey) => $"api/v2/notifications/{Uri.EscapeDataString(groupKey)}/dismiss";
public const string POST_Markers = "api/v1/markers";
public const string GET_TrendingTags = "api/v1/trends/tags";
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
@@ -133,7 +201,6 @@ public static class APIs
public static class PrivaPub
{
public const string GET_Instances = "api/privapub/v1/instances";
public const string GET_Cdns = "api/privapub/v1/cdns";
public static string GET_M_InstanceHistory(string host) => $"api/privapub/v1/instances/{Uri.EscapeDataString(host)}/history";
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
-1
View File
@@ -2,7 +2,6 @@ namespace decePubClient.Models;
public class AppConfiguration
{
public string Version { get; set; }
public string ApiBaseAddress { get; set; }
/// <summary>PrivaPub's first-party application, the only one allowed to exchange the root JWT for persona tokens.</summary>
public string ClientId { get; set; } = "decepub";
-37
View File
@@ -1,37 +0,0 @@
namespace decePubClient.Models
{
public class ClientLogs
{
public long Id { get; set; }
public string Where { get; set; }
public string WarningMessage { get; set; }
public ClientLogException Exception { get; set; }
public DateTime TimeStamp { get; set; } = DateTime.Now;
/// <summary>The entry as HTML for a log viewer: the warning text, or the exception's details.</summary>
public string ErrorMessage()
{
if (Exception is null)
return WarningMessage;
var details = @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br>
<b>Message</b>: {Exception.Message ?? "N/A"}";
if (Exception.InnerExceptionMessage is null)
return details;
return $"{details}<br>\n<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage}";
}
}
public class ClientLogException
{
public string Type { get; set; }
public string HelpLink { get; set; }
public string HResult { get; set; }
public string Source { get; set; }
public string StackTrace { get; set; }
public string TargetSiteName { get; set; }
public string Message { get; set; }
public string InnerExceptionMessage { get; set; }
}
}
+8 -6
View File
@@ -22,8 +22,10 @@ public class ComposeForm
public string GroupId { get; set; }
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
public MessageType MessageType { get; set; } = MessageType.Public;
public Visibility Visibility { get; set; } = Visibility.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))]
@@ -62,12 +64,12 @@ public class ComposeForm
[JsonIgnore, Bindable(false)]
public bool IsScopeOptionsOpen { get; set; }
/// <summary>The Mastodon visibility of MessageType.</summary>
public string Visibility => MessageType switch
/// <summary>The Mastodon visibility of Visibility.</summary>
public string MastodonVisibility => Visibility switch
{
MessageType.Direct => "direct",
MessageType.FollowersOnly => "private",
MessageType.Unlisted => "unlisted",
Visibility.Direct => "direct",
Visibility.FollowersOnly => "private",
Visibility.Unlisted => "unlisted",
_ => "public"
};
}
+35
View File
@@ -0,0 +1,35 @@
namespace decePubClient.Models.Mastodon;
/// <summary>/api/v1/announcements: what the server's administrators announce, shown while published and not ended, as
/// the persona sees it (Read and each reaction's Me are the persona's own; signed out, neither is said).</summary>
public class Announcement
{
public string Id { get; set; }
/// <summary>Sanitized HTML, from the administrator's Markdown.</summary>
public string Content { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public bool AllDay { get; set; }
public DateTime PublishedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>Whether the persona dismissed it.</summary>
public bool? Read { get; set; }
public List<AnnouncementReaction> Reactions { get; set; } = [];
}
/// <summary>A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did.</summary>
public class AnnouncementReaction
{
public string Name { get; set; }
public int Count { get; set; }
public bool? Me { get; set; }
public string Url { get; set; }
}
/// <summary>The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which.</summary>
public class AnnouncementReactionEvent
{
public string Name { get; set; }
public int Count { get; set; }
public string AnnouncementId { get; set; }
}
+82
View File
@@ -0,0 +1,82 @@
namespace decePubClient.Models.Mastodon;
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
/// its rules, and what an upload may be.</summary>
public class InstanceV2
{
public string Domain { get; set; }
public string Title { get; set; }
/// <summary>The short description, as plain text.</summary>
public string Description { get; set; }
public InstanceThumbnail Thumbnail { get; set; } = new();
public InstanceContact Contact { get; set; } = new();
public List<InstanceRule> Rules { get; set; } = [];
public InstanceConfiguration Configuration { get; set; } = new();
}
public class InstanceThumbnail
{
public string Url { get; set; }
}
/// <summary>Who answers for the server: an address, and the persona its administrator named (null when none).</summary>
public class InstanceContact
{
public string Email { get; set; }
public Account Account { get; set; }
}
/// <summary>One of the server's rules (/api/v1/instance/rules and the instance's own), in their order.</summary>
public class InstanceRule
{
public string Id { get; set; }
public string Text { get; set; }
public string Hint { get; set; }
}
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; }
}
/// <summary>/api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it
/// last changed.</summary>
public class InstanceDocument
{
public DateTime? UpdatedAt { get; set; }
public string Content { get; set; }
}
/// <summary>/api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none
/// (404).</summary>
public class TermsOfService
{
/// <summary>yyyy-MM-dd.</summary>
public string EffectiveDate { get; set; }
/// <summary>Whether they apply already (false while their date is ahead).</summary>
public bool Effective { get; set; }
public string Content { get; set; }
public string SucceededBy { get; set; }
}
/// <summary>/api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is
/// obfuscated, its digest the SHA-256 of the whole name.</summary>
public class PublicDomainBlock
{
public string Domain { get; set; }
public string Digest { get; set; }
/// <summary>suspend, silence or noop.</summary>
public string Severity { get; set; }
public string Comment { get; set; }
}
+14 -2
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>
@@ -244,8 +254,10 @@ public class ReportRequest
public string AccountId { get; set; }
public List<string> StatusIds { get; set; } = [];
public string Comment { get; set; }
/// <summary>spam, legal or other.</summary>
/// <summary>spam, legal, violation or other.</summary>
public string Category { get; set; }
/// <summary>The server's rules a violation breaks (only a violation names rules).</summary>
public List<string> RuleIds { get; set; } = [];
public bool Forward { get; set; }
}
+4
View File
@@ -94,8 +94,12 @@ public class PrivaPubPlace
public string Url { get; set; }
}
/// <summary>A post's votes: its favourites are its upvotes. PrivaPub sends them on every post; a threadiverse post's
/// downvotes are the ones that mean something.</summary>
public class PrivaPubVotes
{
public int Up { get; set; }
public int Down { get; set; }
/// <summary>The persona's own downvote (POST /api/v1/statuses/:id/downvote, /undownvote); null with no persona.</summary>
public bool? Downvoted { get; set; }
}
+33 -5
View File
@@ -1,5 +1,6 @@
using PrivaPub.ClientModels.Resources;
using PrivaPub.ClientModels.User;
using System.ComponentModel.DataAnnotations;
@@ -10,20 +11,19 @@ namespace decePubClient.Models
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "LanguageId", ResourceType = typeof(FieldsNameResource))]
public string CurrentLanguageCode { get; set; } = "en";
//public bool NativeNotificationsEnabled { get; set; } = false;
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
public short LightThemeIndexColour { get; set; } = 25;
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
public short DarkThemeIndexColour { get; set; } = 215;
[Range(-2, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
public short IconsThemeIndexColour { get; set; } = 25;
public bool PreferSystemTheming { get; set; } = true;
public bool ThemeIsDarkMode { get; set; } = false;
public bool ThemeIsLightGray { get; set; } = true;
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;
// the theme is the login's (PrivaPub's user settings): taken at sign-in, saved there whenever it changes here
public bool SyncWithLogin { 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
@@ -59,5 +59,33 @@ namespace decePubClient.Models
LightThemeIndexColour = hue;
SetGray(false, isDark);
}
/// <summary>
/// These settings as the login keeps them: the language as the two letters of the culture the app runs in
/// (ExtensionMethods.ToCulture: "it" is it-IT, anything else en-GB), the icons' hue at its default, since the client
/// has no such setting.
/// </summary>
public ViewUserSettings ToLoginSettings() => new()
{
LanguageCode = CurrentLanguageCode is "it" ? "it" : "en",
LightThemeIndexColour = LightThemeIndexColour,
DarkThemeIndexColour = DarkThemeIndexColour,
IconsThemeIndexColour = DefaultLightThemeIndexColour,
PreferSystemTheming = PreferSystemTheming,
ThemeIsDarkMode = ThemeIsDarkMode,
ThemeIsLightGray = ThemeIsLightGray,
ThemeIsDarkGray = ThemeIsDarkGray
};
/// <summary>Takes the login's theme; its language stays this device's, since changing it reloads the app.</summary>
public void TakeLoginTheme(ViewUserSettings settings)
{
LightThemeIndexColour = Math.Clamp(settings.LightThemeIndexColour, (short)0, (short)359);
DarkThemeIndexColour = Math.Clamp(settings.DarkThemeIndexColour, (short)0, (short)359);
PreferSystemTheming = settings.PreferSystemTheming;
ThemeIsDarkMode = settings.ThemeIsDarkMode;
ThemeIsLightGray = settings.ThemeIsLightGray;
ThemeIsDarkGray = settings.ThemeIsDarkGray;
}
}
}
+12 -8
View File
@@ -21,7 +21,7 @@ namespace decePubClient.Models
public bool Bot { get; set; }
public bool Discoverable { get; set; }
public bool Indexable { get; set; }
public MessageType Privacy { get; set; } = MessageType.Public;
public Visibility Privacy { get; set; } = Visibility.Public;
public bool Sensitive { get; set; }
public string Language { get; set; }
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
@@ -43,9 +43,9 @@ namespace decePubClient.Models
Indexable = account.Indexable == true,
Privacy = source.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
"private" => Visibility.FollowersOnly,
"unlisted" => Visibility.Unlisted,
_ => Visibility.Public
},
Sensitive = source.Sensitive,
Language = source.Language,
@@ -71,7 +71,7 @@ namespace decePubClient.Models
{ new StringContent(Flag(Bot)), "bot" },
{ new StringContent(Flag(Discoverable)), "discoverable" },
{ new StringContent(Flag(Indexable)), "indexable" },
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
{ new StringContent(Privacy switch { Visibility.FollowersOnly => "private", Visibility.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
{ new StringContent(Language ?? ""), "source[language]" },
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
@@ -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;
}
}
+1 -11
View File
@@ -1,17 +1,7 @@
using BlazorZXingJs;
using System.Collections.ObjectModel;
namespace decePubClient.Models
namespace decePubClient.Models
{
public class PublicCacheData
{
public IReadOnlyList<ViewLanguage> Languages { get; set; } = [];
public List<MediaDeviceInfo> QrCodeDevices { get; set; } = [];
public string LastPage { get; set; }
public string CurrentSubPage { get; set; }
public PageSettings PageSettings { get; set; } = new();
public bool ShowLogs { get; set; } = false;
public IReadOnlyDictionary<string, string> Policies { get; set; } = ReadOnlyDictionary<string, string>.Empty;
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public class SettingsOptions
{
public bool IsOpen { get; set; }
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
{
Images = 1,
Video,
Audio,
Documents
Audio
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace decePubClient.Models.Types
{
public enum MessageType
{
Direct = 1,
FollowersOnly,
Unlisted,
Public
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
namespace decePubClient.Models.Types
{
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
/// <summary>Why an account is reported (Mastodon's categories). A violation names the server's rules it breaks.</summary>
public enum ReportCategory
{
Other = 0,
Spam = 1,
Legal = 2
Legal = 2,
Violation = 3
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace decePubClient.Models.Types
{
/// <summary>Who a post is for; the members are resource keys too (Localizer[value.ToString()]).</summary>
public enum Visibility
{
Direct = 1,
FollowersOnly,
Unlisted,
Public
}
}
+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;
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public static class VConstants
{
public const string HideClass = "hidden";
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace decePubClient.Models
{
public class ViewLanguage
{
public string Name { get; set; }
public string International2Code { get; set; }
}
}
+223
View File
@@ -0,0 +1,223 @@
@page "/about"
@inherits LocalizableComponentBase
<Title>@Localizer["About {0}", instance?.Title ?? "PrivaPub"]</Title>
@* What this server says of itself, to anyone, signed in or not (AboutService, the Mastodon instance API without
credentials): its name, short description and thumbnail, its long description, who answers for it, its rules, its
privacy policy and terms of service, and the domain blocks its administrator published (an obfuscated one partly
starred, as the server gives it). The documents are the server's sanitized HTML. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto" data-about>
@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 (instance is null)
{
<div class="flex flex-col items-center gap-2 mt-6">
<p class="text-sm text-error">@Localizer["The server's description could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<article class="flex flex-col w-full max-w-3xl rounded-xl neo-raised neo-sm overflow-hidden" data-about-server>
@if (instance.Thumbnail.Url is { Length: > 0 } thumbnail)
{
@* decorative: the server's name follows *@
<img src="@thumbnail" alt="" class="w-full aspect-[40/21] object-cover" />
}
<div class="flex flex-col gap-2 p-4 md:p-5">
<h1 class="text-2xl font-bold break-words" data-about-title>@instance.Title</h1>
@if (instance.Domain is { Length: > 0 } domain)
{
<p class="text-sm opacity-70 break-all">@domain</p>
}
@if (instance.Description is { Length: > 0 } summary)
{
<p class="text-base break-words" data-about-summary>@summary</p>
}
</div>
</article>
@if (description?.Content is { Length: > 0 } extended)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-description>
<div class="post-content doc-content break-words">@((MarkupString)extended)</div>
@if (description.UpdatedAt is { } describedAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-contact>
<h2 class="text-lg font-bold">@Localizer["Contact"]</h2>
@if (instance.Contact.Account is { } account)
{
<a class="flex items-center gap-3 p-2 rounded-lg neo-raised neo-2xs min-w-0" href="@AccountLink(account)" data-about-contact-account="@account.Id">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
@if (instance.Contact.Email is { Length: > 0 } email)
{
<a class="link inline-flex items-center gap-2 break-all touch-target self-start" href="@($"mailto:{email}")" data-about-contact-email>
<i class="ion-md-mail" aria-hidden="true"></i>@email
</a>
}
</article>
}
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-rules>
<h2 class="text-lg font-bold">@Localizer["Rules"]</h2>
@if (instance.Rules.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["The server has no rules."]</p>
}
else
{
<ol class="flex flex-col gap-2 list-decimal ps-6">
@foreach (var rule in instance.Rules)
{
<li class="break-words" data-about-rule="@rule.Id">
<span>@rule.Text</span>
@if (rule.Hint is { Length: > 0 } hint)
{
<p class="text-sm opacity-70">@hint</p>
}
</li>
}
</ol>
}
</article>
@if (privacy?.Content is { Length: > 0 } policy)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-privacy>
<h2 class="text-lg font-bold">@Localizer["Privacy policy"]</h2>
<div class="post-content doc-content break-words">@((MarkupString)policy)</div>
@if (privacy.UpdatedAt is { } policyAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (terms?.Content is { Length: > 0 } termsText)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-terms>
<h2 class="text-lg font-bold">@Localizer["Terms of service"]</h2>
@if (TermsDate is { } effectiveOn)
{
<p class="text-xs opacity-70">
@(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn])
</p>
}
<div class="post-content doc-content break-words">@((MarkupString)termsText)</div>
</article>
}
@if (blocks.Count > 0)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-blocks>
<h2 class="text-lg font-bold">@Localizer["Moderated servers"]</h2>
<p class="text-sm opacity-70">@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]</p>
<ul class="flex flex-col gap-2">
@foreach (var block in blocks)
{
<li class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs" data-about-block="@block.Domain">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 break-all font-mono text-sm" title="@($"SHA-256 {block.Digest}")">@block.Domain</b>
@switch (block.Severity)
{
case "suspend":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Suspended"]</DBadge>
break;
case "silence":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Silenced"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Limited"]</DBadge>
break;
}
</div>
@if (block.Comment is { Length: > 0 } reason)
{
<p class="text-xs break-words">@reason</p>
}
</li>
}
</ul>
</article>
}
}
</div>
</section>
@code {
[Inject] AboutService AboutService { get; set; }
InstanceV2 instance;
InstanceDocument description;
InstanceDocument privacy;
TermsOfService terms;
List<PublicDomainBlock> blocks = [];
// the terms' date as the reader writes dates
string TermsDate => DateTime.TryParseExact(terms?.EffectiveDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var on) ? on.ToString(SUtility.dateTimeFormat) : null;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
var reading = AboutService.Instance(fresh: true);
var describing = AboutService.ExtendedDescription();
var policing = AboutService.PrivacyPolicy();
var terming = AboutService.Terms();
var blocking = AboutService.DomainBlocks();
await Task.WhenAll(reading, describing, policing, terming, blocking);
instance = reading.Result;
description = describing.Result;
privacy = policing.Result;
terms = terming.Result;
blocks = blocking.Result;
}
async Task Retry()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
// signed in, the contact's profile in the app; signed out, its own page
string AccountLink(Account account) =>
CascadingState.IsSignedIn ? $"account/{Uri.EscapeDataString(account.Id)}" : account.Url;
}
+98 -159
View File
@@ -4,29 +4,15 @@
<Title>@Localizer["Administration"]</Title>
@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. The
other sections wait for PrivaPub endpoints that do not exist yet. *@
@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its
rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation
statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it.
Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Users"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="reports">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
@@ -39,8 +25,60 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-list-box text-xl"></i> <span>@Localizer["Moderation log"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<ModerationLogPanel />
</div>
</ChildContent>
</DCollapse>
<AuthorizeView Policy="@Policies.IsAdmin">
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-information-circle text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<InstancePanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="rules">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-book text-xl"></i> <span>@Localizer["Rules"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RulesPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="announcements">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-megaphone text-xl"></i> <span>@Localizer["Announcements"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<AnnouncementsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
@@ -53,7 +91,33 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="relays">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-radio text-xl"></i> <span>@Localizer["Relays"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RelaysPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="media">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-images text-xl"></i> <span>@Localizer["Media limits"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<MediaLimitsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="statistics">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
@@ -65,146 +129,21 @@
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="backups">
<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">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["ActivityPub?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Authentication"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Emoji?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Frontend"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Link formatter"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Metadata"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Policies"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Upload"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
</div>
</section>
@code {
}
+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,
+61 -6
View File
@@ -6,7 +6,9 @@
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
(Content.FilterIn), and a card's "Filter this post" adds a post to one. The posts a filter holds are read by id when it
opens (/api/v1/statuses?id[]=) and show their author and words, linking their thread. Filters are the persona's own
and never leave PrivaPub. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -124,11 +126,33 @@
@if (filter.Statuses.Count > 0)
{
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
@foreach (var item in filter.Statuses)
{
<div class="flex items-center gap-2 text-sm">
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
@if (PostOf(item) is { } post)
{
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<span class="truncate text-xs">
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
</span>
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
</span>
</a>
}
else if (readPostIds.Contains(item.StatusId))
{
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
}
else
{
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
}
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
</DButton>
@@ -170,6 +194,7 @@
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
[Inject] FilterService FilterService { get; set; }
[Inject] TimelineService TimelineService { get; set; }
List<Filter> filters = [];
TitleForm draft = new();
@@ -178,6 +203,9 @@
string loadedPersonaId;
bool isSaving;
bool isConfirmingDelete;
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
Dictionary<string, Status> filteredPosts = new();
HashSet<string> readPostIds = [];
(int Seconds, string Label)[] Expiries =>
[
@@ -240,6 +268,8 @@
{
IsLoading = true;
open = null;
filteredPosts = new();
readPostIds = [];
filters = await FilterService.All();
IsLoading = false;
}
@@ -260,10 +290,10 @@
filters.Add(filter);
draft = new();
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
Toggle(filter);
await Toggle(filter);
}
void Toggle(Filter filter)
async Task Toggle(Filter filter)
{
isConfirmingDelete = false;
if (open?.Id == filter.Id)
@@ -281,6 +311,31 @@
ExpiresIn = KeepExpiry,
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
};
await ReadPosts(filter);
}
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
async Task ReadPosts(Filter filter)
{
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
if (ids.Count == 0)
return;
foreach (var (id, status) in await TimelineService.Read(ids))
filteredPosts[id] = status;
readPostIds.UnionWith(ids);
}
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
// what the post says, as plain text: its content warning when it has one, else the start of its words
static string Excerpt(Status status)
{
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
if (string.IsNullOrEmpty(html))
return null;
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
}
void ToggleContext(FilterContext context, bool on)
+6
View File
@@ -110,6 +110,9 @@
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
aria-label="@Localizer["Invitation code"]" data-group-invitation />
<span>@Localizer["Invitation link, for someone without a login here"]</span>
<input class="input input-sm w-full font-mono text-xs" value="@($"{Navigation.BaseUri}join?code={Uri.EscapeDataString(invitation)}")" readonly
autocomplete="off" onfocus="this.select()" aria-label="@Localizer["Invitation link, for someone without a login here"]" data-group-invitation-link />
</div>
}
@@ -187,6 +190,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 +285,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;
+95 -19
View File
@@ -10,35 +10,70 @@
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
the stream tells is applied at once. *@
the stream tells is applied at once. The administrators' announcements show above the tabs (AnnouncementsBanner). *@
<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>
}
@* the administrators' announcements: a component of its own, outside the virtualized cards *@
<AnnouncementsBanner />
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
@* (on a phone the bottom stays clear of the New post button) *@
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 max-md:pb-24 md:p-4">
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
@if (isComposing)
{
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</div>
}
</PhoneSheet>
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
@if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0))
{
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
<div class="flex flex-wrap justify-center gap-2">
@if (followRequests > 0)
{
<DButton Size="DSize.Sm" OnClick="() => isFollowRequestsOpen = true" data-follow-requests-open>
<i class="ion-md-person-add" aria-hidden="true"></i>
<span>@Localizer["{0} follow requests waiting", followRequests]</span>
</DButton>
}
@if (filteredRequests > 0)
{
<a class="btn btn-sm" href="notifications/filtered" data-filtered-requests>
<i class="ion-md-notifications-off"></i>
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
</a>
}
</div>
}
@if (isFollowRequestsOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isFollowRequestsOpen = open" CloseText="@Localizer["Close"]"
data-follow-requests-dialog>
<Heading>@Localizer["Follow requests"]</Heading>
<ChildContent>
<div class="mt-3">
<FollowRequestsPanel OnAnswered="FollowRequestAnswered" />
</div>
</ChildContent>
</DModal>
}
@if (pending.Count > 0)
{
@@ -82,10 +117,9 @@
</p>
}
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
(tests/e2e/Perf/FeedPerfTests) *@
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnDismissed="Dismissed" OnReplied="Replied" OnTag="OpenTag" />
</Virtualize>
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@@ -119,6 +153,7 @@
[Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; }
[Inject] FeedMemory Memory { get; set; }
[Inject] AccountService AccountService { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
@@ -128,15 +163,28 @@
int unreadNotifications;
// the requests the persona's notification policy filtered, shown on the Notifications tab
int filteredRequests;
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
int followRequests;
bool isFollowRequestsOpen;
int unreadConversations;
string maxId;
string minId;
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 +213,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 +251,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();
}
@@ -403,8 +461,11 @@
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0;
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
}
void FollowRequestAnswered(bool accepted) => followRequests = Math.Max(0, followRequests - 1);
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
{
@@ -461,6 +522,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;
@@ -532,6 +601,13 @@
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
}
// a dismissed notification is that item alone: others about the same post stay
void Dismissed(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
pending.RemoveAll(p => p.Key == post.Key);
}
IEnumerable<NerdRow> FeedStats()
{
yield return NerdRow.Of(Localizer["items"], posts.Count);
+123
View File
@@ -0,0 +1,123 @@
@page "/join"
@inherits LocalizableComponentBase
<Title>@Localizer["Join with an invitation"]</Title>
@* A new login through a group's invitation (PrivaPub's /clientapi/user/invitation/signup): sign-up is otherwise closed,
so this is how everyone but the first administrator arrives. Its first persona joins the group. A group's page links
here with the code (?code=). *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-join-signed-in>
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
<p class="text-sm">@Localizer["A persona of yours joins a group by its invitation in Groups."]</p>
<DButton Href="groups" Color="DColor.Primary">@Localizer["Groups"]</DButton>
</div>
}
else
{
<EditForm Model="form" OnValidSubmit="SignUp" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-join>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Join with an invitation"]</h1>
<p class="text-sm opacity-70">@Localizer["A group's invitation makes your login here, and your first persona joins the group."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Invitation code"]</span>
<DInput TValue="string" class="w-full font-mono text-xs" @bind-Value="form.InvitationCode" autocomplete="off" autocapitalize="none" spellcheck="false"
maxlength="@PrivaPub.ClientModels.Constants.GroupInvitationLength" data-join-code />
</label>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.InvitationPassword" autocomplete="off"
placeholder="@Localizer["Invitation password, if it has one"]" data-join-code-password />
<b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-join-username>
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="new-password" data-join-password>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Repeat the password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-join-repeat>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<b class="text-sm">@Localizer["Your first persona"]</b>
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-join-persona-username>
<Prefix>@@</Prefix>
</DInput>
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
placeholder="@Localizer["Display name"]" data-join-persona-name />
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-join-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Join"]</span>
</DButton>
<a class="link text-sm self-center touch-target" href="login">@Localizer["I have a login: sign in"]</a>
</EditForm>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly InvitationLoginForm form = new();
string repeatedPassword;
string error;
bool isWorking;
[SupplyParameterFromQuery] public string Code { get; set; }
protected override void OnInitialized()
{
form.InvitationCode = Code?.Trim();
IsLoading = false;
}
async Task SignUp()
{
error = default;
if (form.Password != repeatedPassword)
{
error = Localizer["The two passwords don't match."];
return;
}
isWorking = true;
form.InvitationCode = form.InvitationCode?.Trim();
form.InvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? null : form.InvitationPassword;
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
var result = await AuthService.Join(form);
if (result.IsInvalid)
{
isWorking = false;
error = result.ErrorMessage;
return;
}
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
Navigation.NavigateTo("");
}
}
+128 -9
View File
@@ -3,16 +3,59 @@
<Title>@Localizer["Login"]</Title>
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
one (CascadingState.LoadSession picks the last one used, or the only one). *@
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode).
The heading names the server as its administrator calls it (/api/v2/instance, read without credentials); About tells
the rest. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (!CascadingState.IsSignedIn)
{
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<div class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16">
@if (setupPending)
{
<a href="setup" class="flex items-center gap-3 w-full p-4 rounded-xl neo-raised neo-sm ring-2 ring-primary" data-setup-offer>
<i class="ion-md-construct text-2xl text-primary" aria-hidden="true"></i>
<span class="flex flex-col">
<b>@Localizer["This server has no administrator yet"]</b>
<span class="text-sm">@Localizer["Set it up: the first login made becomes its administrator."]</span>
</span>
</a>
}
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
<h1 class="text-xl font-bold break-words" data-login-title>@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]</h1>
@if (needsCode)
{
@* The name and the password stay in the form, out of sight, and go again with the code. *@
<p class="text-sm" data-login-code-asked>@Localizer["Enter the code from your authenticator app, or a recovery code."]</p>
@if (useRecoveryCode)
{
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Recovery code"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.OneTimeCode" required="@true" autocomplete="one-time-code" inputmode="text"
autocapitalize="none" spellcheck="false" data-login-recovery>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
else
{
<div class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
<DOtp @bind-Value="form.OneTimeCode" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-login-code />
</div>
}
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-center" OnClick="ToggleRecoveryCode" data-login-use-recovery>
@(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
</DButton>
}
else
{
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
<label class="flex flex-col gap-1">
@@ -27,6 +70,7 @@
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
}
<ErrorViewer AdditionalError="@error" />
@@ -37,13 +81,31 @@
}
<span>@Localizer["Login"]</span>
</DButton>
@if (needsCode)
{
<DButton Variant="DButtonVariant.Ghost" Size="DSize.Sm" class="self-center" Disabled="isWorking" OnClick="Back" data-login-back>
<i class="ion-md-arrow-back"></i><span>@Localizer["Back"]</span>
</DButton>
}
else
{
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
}
</EditForm>
<p class="text-sm text-center">
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
</p>
<p class="text-sm text-center">
<a class="link touch-target" href="about" data-about-offer>@Localizer["About this server"]</a>
</p>
</div>
}
else if (CascadingState.Personas.Count == 0)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-no-persona>
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
<p class="text-sm">@Localizer["This login has no persona to act as. Create one on PrivaPub first."]</p>
<p class="text-sm">@Localizer["This login has no persona to act as yet: make one in Settings."]</p>
<DButton Href="settings" Color="DColor.Primary">@Localizer["Settings"]</DButton>
<DButton OnClick="SignOut">@Localizer["Logout"]</DButton>
</div>
}
@@ -77,29 +139,69 @@
@code {
[Inject] AuthService AuthService { get; set; }
[Inject] AboutService AboutService { get; set; }
readonly LoginForm form = new();
string error;
bool isWorking;
bool setupPending;
// the server's own name, once read
string serverTitle;
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
bool needsCode;
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
bool useRecoveryCode;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
protected override void OnInitialized() => IsLoading = false;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
serverTitle = (await AboutService.Instance())?.Title;
setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending();
}
async Task SignIn()
{
error = default;
isWorking = true;
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
var result = await AuthService.Login(form);
if (result.IsInvalid)
if (AuthService.NeedsOneTimeCode(result))
{
error = result.ErrorMessage;
needsCode = true;
form.OneTimeCode = default;
isWorking = false;
return;
}
await CascadingState.LoadSession();
if (result.IsInvalid)
{
// a wrong code is PrivaPub's 400, like a wrong password, with its own message; the code is typed again
error = result.ErrorMessage;
form.OneTimeCode = default;
isWorking = false;
return;
}
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue();
}
@@ -119,6 +221,23 @@
Continue();
}
void ToggleRecoveryCode()
{
useRecoveryCode = !useRecoveryCode;
form.OneTimeCode = default;
error = default;
}
/// <summary>Back to the name and the password, for another login: the password is typed again.</summary>
void Back()
{
needsCode = false;
useRecoveryCode = false;
form.OneTimeCode = default;
form.Password = default;
error = default;
}
async Task SignOut() => await CascadingState.SignOut();
/// <summary>Back where the reader was sent from, never off the app.</summary>
+216
View File
@@ -0,0 +1,216 @@
@page "/password-recovery"
@inherits LocalizableComponentBase
<Title>@Localizer["Forgot your password?"]</Title>
@* A forgotten password (PrivaPub's /clientapi/user/recover): the login's name, or its verified address, asks for a link,
and PrivaPub answers everyone with the same sentence, so the page never tells whether a login or an address exists.
The link comes back here with its code (?rc=), which leaves the address bar and the history at once and stays in the
page; a new password set with it ends every session the login had, and the reader signs in with it. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-signed-in>
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
</div>
}
else if (recoveryCode is null && sentence is not null)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-sent>
<h1 class="text-xl font-bold">@Localizer["Check your inbox"]</h1>
<p class="text-sm">@sentence</p>
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
</div>
}
else if (recoveryCode is null)
{
<EditForm Model="request" OnValidSubmit="Request" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-request>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Forgot your password?"]</h1>
<p class="text-sm opacity-70">@Localizer["Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username>
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<span class="text-sm text-center opacity-70">@Localizer["or"]</span>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Email address"]</span>
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
<Prefix><i class="ion-md-mail"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Send the recovery link"]</span>
</DButton>
<a class="link text-sm self-center touch-target" href="login">@Localizer["I remember it: sign in"]</a>
</EditForm>
}
else if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
}
else if (isCodeValid is false)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-invalid>
<h1 class="text-xl font-bold">@Localizer["This recovery link doesn't work"]</h1>
<p class="text-sm">@Localizer["A link works for one hour, and only once. Ask for a new one."]</p>
<DButton Color="DColor.Primary" OnClick="AskAgain" data-recovery-again>@Localizer["Ask for a new link"]</DButton>
</div>
}
else
{
@* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@
<EditForm Model="newPassword" OnValidSubmit="Recover" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-new>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Choose a new password"]</h1>
<p class="text-sm opacity-70">@Localizer["Every device signed in to this login is signed out."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["New password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.NewPassword" autocomplete="new-password" data-recovery-password>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Repeat the new password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.RepeatedPassword" autocomplete="new-password" data-recovery-repeat>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-new-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Change the password"]</span>
</DButton>
</EditForm>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly PasswordRecoveryForm request = new();
NewPasswordForm newPassword = new();
/// <summary>The code of the link the page was opened from, kept here once it left the address bar.</summary>
string recoveryCode;
/// <summary>Whether the code works: null until PrivaPub said.</summary>
bool? isCodeValid;
/// <summary>PrivaPub's answer to a request, the same for everyone.</summary>
string sentence;
string error;
bool isWorking;
[SupplyParameterFromQuery(Name = "rc")] public string LinkCode { get; set; }
protected override async Task OnInitializedAsync()
{
if (LinkCode is { Length: > 0 } code)
{
recoveryCode = code.Trim();
newPassword.RecoveryCode = recoveryCode;
// the code is as good as the password for an hour: out of the address bar and the history at once
Navigation.NavigateTo("password-recovery", replace: true);
}
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
if (recoveryCode is null)
return;
var result = await AuthService.CheckRecoveryCode(recoveryCode);
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
isCodeValid = result.Data is true;
}
static string NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
async Task Request()
{
error = default;
isWorking = true;
request.UserName = request.UserName?.Trim();
request.Email = request.Email?.Trim().ToLowerInvariant();
var result = await AuthService.RequestRecovery(request);
isWorking = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
sentence = result.Data is string { Length: > 0 } said
? said
: Localizer["If that account has an email address, a recovery link is on its way. It works for one hour."];
}
void AskAgain()
{
recoveryCode = null;
isCodeValid = null;
newPassword = new();
error = default;
}
async Task Recover()
{
error = default;
isWorking = true;
var result = await AuthService.Recover(newPassword);
isWorking = false;
if (result.StatusCode is (int)System.Net.HttpStatusCode.NotFound)
{
// used or expired meanwhile
isCodeValid = false;
return;
}
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
Toast.AddResponseSuccess(Localizer["Password changed: sign in with the new one."]);
Navigation.NavigateTo("login");
}
}
+39 -5
View File
@@ -6,7 +6,9 @@
@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is
to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts,
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
never from its server directly. *@
never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows
(AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow
requests waiting for it show above its posts. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -40,8 +42,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 +69,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>
@@ -141,13 +144,34 @@
</dl>
}
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
@if (IsOwn)
{
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = false" data-profile-following>
<b>@account.FollowingCount</b> @Localizer["following"]
</button>
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = true" data-profile-followers>
<b>@account.FollowersCount</b> @Localizer["followers"]
</button>
}
else
{
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
}
</div>
</article>
@if (IsOwn)
{
@if (showAccounts is { } followers)
{
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
}
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
}
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
@@ -190,6 +214,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; }
@@ -210,6 +235,8 @@
bool isLoadingMore;
bool isListMenuOpen;
bool isReporting;
// the persona's own followers (true) or the accounts it follows (false), while that list is open
bool? showAccounts;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
@@ -328,6 +355,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;
@@ -352,6 +384,8 @@
});
}
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
+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;
+10
View File
@@ -21,6 +21,13 @@
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" />
@* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@
<DToggle LabelText="@Localizer["Keep the theme the same on every device signed in to this login"]" Value="CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
ValueChanged="v => Update(s => s.SyncWithLogin = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
data-sync-with-login />
<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 +137,10 @@
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-6">
<PersonaArchivePanel />
<DataTransfer />
</div>
}
else
{
+137
View File
@@ -0,0 +1,137 @@
@page "/setup"
@inherits LocalizableComponentBase
<Title>@Localizer["Set up PrivaPub"]</Title>
@* The server's first login, made while it has no administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16,
first come, first served): it becomes the administrator, with its first persona, and setup closes for good. Everyone
else joins through a group's invitation (Join). *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (IsLoading)
{
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
}
else if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
</div>
}
else if (!isPending)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-setup-done>
<h1 class="text-xl font-bold">@Localizer["This server is set up"]</h1>
<p class="text-sm">@Localizer["It has an administrator already: sign in, or join with a group's invitation."]</p>
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2">
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
<DButton Href="join">@Localizer["Join with an invitation"]</DButton>
</div>
</div>
}
else
{
<EditForm Model="form" OnValidSubmit="SetUp" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-setup>
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Set up PrivaPub"]</h1>
<p class="text-sm opacity-70">@Localizer["This server has no administrator yet. The first login made here becomes it, with its first persona; after that, joining takes a group's invitation."]</p>
<b class="text-sm">@Localizer["Your login"]</b>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false" data-setup-username>
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="new-password" data-setup-password>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Repeat the password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="repeatedPassword" autocomplete="new-password" data-setup-repeat>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<b class="text-sm">@Localizer["Your first persona"]</b>
<p class="text-xs opacity-70">@Localizer["Others see your personas, never your login."]</p>
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarUserName" autocomplete="off" autocapitalize="none" spellcheck="false"
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-setup-persona-username>
<Prefix>@@</Prefix>
</DInput>
<DInput TValue="string" class="w-full" @bind-Value="form.AvatarName" autocomplete="off" maxlength="64"
placeholder="@Localizer["Display name"]" data-setup-persona-name />
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-setup-submit>
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Set up PrivaPub"]</span>
</DButton>
</EditForm>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly SetupForm form = new();
string repeatedPassword;
string error;
bool isPending;
bool isWorking;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => isPending = await AuthService.SetupPending();
async Task SetUp()
{
error = default;
if (form.Password != repeatedPassword)
{
error = Localizer["The two passwords don't match."];
return;
}
isWorking = true;
form.AvatarUserName = form.AvatarUserName?.Trim().TrimStart('@').ToLowerInvariant();
var result = await AuthService.SetUp(form);
if (result.IsInvalid)
{
isWorking = false;
// set up meanwhile: the page says so
isPending = result.StatusCode != (int)System.Net.HttpStatusCode.Forbidden;
error = result.ErrorMessage;
return;
}
await CascadingState.SignedIn(result.Data as JwtUser);
isWorking = false;
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
Navigation.NavigateTo("");
}
}
@@ -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>
+9 -4
View File
@@ -11,7 +11,6 @@ using Toolbelt.Blazor.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using PrivaPub.ClientModels;
using collAnon.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
@@ -45,11 +44,17 @@ builder.Services.AddOptions()
.AddScoped<AccountService>()
.AddScoped<PortabilityService>()
.AddScoped<AdminService>()
.AddScoped<InstanceAdminService>()
.AddScoped<AboutService>()
.AddScoped<AnnouncementService>()
.AddScoped<ArchiveService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
.AddScoped<DiscoveryService>()
.AddScoped<StreamingService>()
.AddScoped<PublicNoticeService>()
.AddScoped<ConfirmService>()
.AddScoped<OverlayService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
.AddScoped<InstanceService>()
@@ -60,15 +65,15 @@ builder.Services.AddOptions()
.AddTransient<IHttpService, HttpService>()
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
.AddLocalization()
.AddTransient<IStorage, Storage>()
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug))
.AddIndexedDb();
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug));
var apiBaseAddress = builder.Configuration["AppConfiguration:ApiBaseAddress"];
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 =>
File diff suppressed because it is too large. Load diff
+1335 -66
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -60,6 +60,24 @@ namespace decePubClient.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to The {0} field is not a valid date..
/// </summary>
public static string InvalidDate {
get {
return ResourceManager.GetString("InvalidDate", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The {0} field is not valid..
/// </summary>
public static string InvalidValue {
get {
return ResourceManager.GetString("InvalidValue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
/// </summary>
+6
View File
@@ -41,6 +41,12 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="InvalidDate" xml:space="preserve">
<value>Il campo {0} non è una data valida.</value>
</data>
<data name="InvalidValue" xml:space="preserve">
<value>Il campo {0} non è valido.</value>
</data>
<data name="MaxLength" xml:space="preserve">
<value>Il campo {0} può contenere al massimo {1} caratteri.</value>
</data>
+6
View File
@@ -117,6 +117,12 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="InvalidDate" xml:space="preserve">
<value>The {0} field is not a valid date.</value>
</data>
<data name="InvalidValue" xml:space="preserve">
<value>The {0} field is not valid.</value>
</data>
<data name="MaxLength" xml:space="preserve">
<value>The field {0} can be at most {1} characters long.</value>
</data>
+96
View File
@@ -0,0 +1,96 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// What this server says of itself to anyone, through the Mastodon instance API, never with credentials: its title,
/// descriptions, contact and rules (/api/v2/instance, read once a session and forgotten when the administrator saves),
/// its long description, privacy policy and terms of service (sanitized HTML from the administrator's Markdown), and
/// the domain blocks it publishes. Values come back null and lists empty on failure; nothing throws.
/// </summary>
public class AboutService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
InstanceV2 instance;
/// <summary>The instance's description, kept for the session; read again when `fresh`.</summary>
public async Task<InstanceV2> Instance(bool fresh = false, CancellationToken cancellationToken = default)
{
if (instance is not null && !fresh)
return instance;
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Instance), cancellationToken);
return instance;
}
return instance = await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) ?? instance;
}
/// <summary>Drops what was read, after the administrator changed it.</summary>
public void Forget() => instance = null;
/// <summary>The server's rules in their order (not the ones its administrator removed).</summary>
public async Task<List<InstanceRule>> Rules(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceRules, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<InstanceRule>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Rules), cancellationToken);
return [];
}
public Task<InstanceDocument> ExtendedDescription(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstanceExtendedDescription, nameof(ExtendedDescription), cancellationToken);
public Task<InstanceDocument> PrivacyPolicy(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstancePrivacyPolicy, nameof(PrivacyPolicy), cancellationToken);
/// <summary>The terms of service, or null while the server has none (its 404).</summary>
public async Task<TermsOfService> Terms(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceTermsOfService, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<TermsOfService>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Terms), cancellationToken);
return null;
}
/// <summary>The blocks the administrator published, as the server shows them (an obfuscated one partly starred).</summary>
public async Task<List<PublicDomainBlock>> DomainBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceDomainBlocks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<PublicDomainBlock>>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
async Task<InstanceDocument> Document(string route, string caller, CancellationToken cancellationToken)
{
using var response = await httpService.GetTotallyAnon(route, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<InstanceDocument>(cancellationToken);
await LogFailure(response, caller, cancellationToken);
return null;
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken 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(AboutService)}.{caller}", cancellationToken);
}
}
}
+40 -17
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
@@ -32,7 +30,7 @@ namespace decePubClient.Services
/// <summary>
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
/// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is
/// blocking and muting it, the persona's follow requests, followers and following, and search across the fediverse (a remote handle or URL is
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -156,16 +154,10 @@ namespace decePubClient.Services
return [];
}
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FollowRequests), cancellationToken);
return [];
}
/// <summary>A page of the accounts waiting for the persona to accept their follow, newest first; Data is an
/// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one).</summary>
public Task<WebResult> FollowRequests(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_FollowRequests, maxId, cancellationToken);
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
@@ -221,10 +213,39 @@ 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);
/// <summary>A page of the persona's followers (only the persona itself may read it: PrivaPub answers [] for anyone
/// else's); Data is an AccountPage.</summary>
public Task<WebResult> Followers(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowers(accountId), maxId, cancellationToken);
/// <summary>Removes an account from the persona's followers (its server is told); Data is the new Relationship.</summary>
public async Task<WebResult> RemoveFromFollowers(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_RemoveFromFollowers(accountId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
@@ -265,10 +286,11 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
CancellationToken cancellationToken = default)
/// <summary>Reports an account, with the posts named, to the persona's server's moderators (a violation with the
/// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming
/// the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, List<string> ruleIds,
bool forward, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
{
@@ -276,6 +298,7 @@ namespace decePubClient.Services
StatusIds = statusIds,
Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(),
RuleIds = category is ReportCategory.Violation ? ruleIds : [],
Forward = forward
}, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+143 -5
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
@@ -11,9 +9,11 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
/// call. Lists come back empty and values null on failure; nothing throws.
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
/// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and
/// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings,
/// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back
/// empty and values null on failure; nothing throws.
/// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
@@ -35,6 +35,21 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (before is { Length: > 0 })
parameters.Add($"before={Uri.EscapeDataString(before)}");
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorLog, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminActionsPage>(cancellationToken);
await LogFailure(response, nameof(Log), cancellationToken);
return null;
}
/// <summary>The servers blocked for everyone here, by name.</summary>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{
@@ -62,6 +77,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);
@@ -94,6 +177,61 @@ namespace decePubClient.Services
return null;
}
/// <summary>One server: what it says of itself, its days (counters keyed as the ledger keys them) and its latest 100
/// events; null when PrivaPub knows nothing of it or it cannot be read.</summary>
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_StatisticsHost(host), [$"days={days}"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewHostDetail>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Host), cancellationToken);
return null;
}
/// <summary>Asks PrivaPub to read the server's NodeInfo and instance description again (a queued job, answered 202).</summary>
public async Task<WebResult> Describe(string host, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_StatisticsDescribe(host), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The ledger's events, newest first, those before `before` when given (the next page starts at the last
/// one's time); each filter is left out when null.</summary>
public async Task<List<ViewInteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit,
CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (host is { Length: > 0 })
parameters.Add($"host={Uri.EscapeDataString(host)}");
if (channel is { Length: > 0 })
parameters.Add($"channel={Uri.EscapeDataString(channel)}");
if (outcome is { Length: > 0 })
parameters.Add($"outcome={Uri.EscapeDataString(outcome)}");
if (reason is { Length: > 0 })
parameters.Add($"reason={Uri.EscapeDataString(reason)}");
if (before is { } at)
parameters.Add($"before={Uri.EscapeDataString(at.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))}");
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsEvents, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewInteractionEvent>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Events), cancellationToken);
return [];
}
/// <summary>The server's own days, oldest first: its client API, the documents it served and its ledger.</summary>
public async Task<List<ViewServerDay>> ServerDays(int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsServer, [$"days={days}"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewServerDay>>(cancellationToken) ?? [];
await LogFailure(response, nameof(ServerDays), cancellationToken);
return [];
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+48
View File
@@ -0,0 +1,48 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now,
/// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and
/// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones
/// (StreamingService.Announced). The administrators write them through InstanceAdminService.
/// </summary>
public class AnnouncementService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The announcements shown now, with whether the persona dismissed each.</summary>
public async Task<List<Announcement>> List(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Announcements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Announcement>>(cancellationToken) ?? [];
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AnnouncementService)}.{nameof(List)}", cancellationToken);
return [];
}
public async Task<WebResult> Dismiss(string announcementId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissAnnouncement(announcementId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422).</summary>
public async Task<WebResult> React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default)
{
var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji);
using var response = add
? await httpService.Put(route, cancellationToken: cancellationToken)
: await httpService.Delete(route, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
}
}
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more