64 Commits
Author SHA1 Message Date
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
thepraandClaude Opus 5.5 511edf3591 The composer completes mentions and hashtags
Build / Build (push) Successful in 31s
Deploy / decepub.thepra.dev (push) Successful in 1m34s
A mention or hashtag being written at the end of the words is offered, once typing pauses, the accounts or tags
PrivaPub already knows (/api/v2/search, never resolving); a click writes it out. e2e: a town persona's handle,
begun, is offered and completed. CLAUDE.md also says that FEP-5624's moderated comments (PeerTube) show as pending
on a card, as GoToSocial's policies do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 658683f816 The persona's notification policy, and what it filtered
Settings has a Notifications section, also 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), and the filtered ones, one request per account, let in (with that account from then on) or
dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the
tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 a0f07abf8c Reports, and who liked or boosted a post
Another's profile and posts can be reported from the client (/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. Who liked or boosted
a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:19 +02:00
thepraandClaude Opus 5.5 bb68ff8d5e An edited post's versions open from its card
The card marks an edited post with a button that opens its versions, newest first (/statuses/:id/history), each with
its time, warning and words; PrivaPub now has a remote post's earlier versions from Pleroma's formerRepresentations
too. The composer's e2e test opens the history after its edit. The wall test retries smuser's post while Smithereen
has not yet had the persona's Accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 13:03:19 +02:00
thepraandClaude Opus 5.5 03824babbc A post on a wall says whose, and the persona takes posts off its own
PrivaPub hosts what a persona's followers write on its wall (Smithereen's, FEP-400e) and shows posts written on walls
elsewhere to their owners' followers. A card names the wall (privapub.wall, data-wall, linking the profile), and one
written on the persona's own wall is taken off from the card's menu, which PrivaPub tells as a Remove and Smithereen
applies as a deletion. WallTests checks it against Smithereen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 08:38:49 +02:00
thepraandClaude Opus 5.5 23dbc68fb4 Coming back to the feed shows it as it was left
Leaving the feed keeps it in Services/FeedMemory.cs (posts, waiting posts, cursors and scroll; ten minutes, for the same
feed of the same persona), so coming back from a thread or a profile shows it again at once, older pages included,
instead of reading it anew; newer posts come behind the "new" button and through the stream as before.
tests/e2e FeedMemoryTests goes to a thread and back without a new read of the home timeline; the whole suite passes
(123, 5 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:21:41 +02:00
thepraandClaude Opus 5.5 5a7e1804a9 Articles fold in a feed, with their cover, excerpt and a Read button
An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes
with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened
in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests
checks it with a WriteFreely blog; the render matrix still passes (70).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:00:57 +02:00
thepraandClaude Opus 5.5 2f29872745 The globe looks at the feed's posts only when the feed hands them over
GlobePanel built a string of every located post's place on each of its renders, the stats overlay's once a second
included: with two thousand posts loaded, a hundred kilobytes of formatting every second while the page sat idle. It
now looks at the posts only when the feed hands them over, and compares a hash. The feed perf test (Category=Perf)
loads 2000 posts as before (49 s, against 55.6 s for 1740 in the baseline run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:17:48 +02:00
thepraandClaude Opus 5.5 da98addf76 Pinned posts on profiles, and pinning from the card
A profile shows the posts the account pins above its posts (pinned=true: for an account elsewhere, what it features,
which PrivaPub now keeps); the persona pins and unpins its own public and unlisted posts from the card's menu, and
PrivaPub tells its followers. tests/e2e/Actions/PinTests.cs pins from the card and finds the post first on the profile;
the whole e2e suite passes against the reseeded village (122, 4 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:04:31 +02:00
thepraandClaude Opus 5.5 a5178a67b1 The feed stays light at thousands of posts
A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's
home, thousands of posts from SocialPub's load runs, to 2000 posts. Before:
100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the
main thread was busy almost all of that time, and the page held 224 000 DOM
nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did.

- A card re-renders only when its own events run or its render key changes
  (Content.RenderKey: the post, its status, notification, conversation,
  place and grouping, where it is shown, the persona and the connection).
  2000 cards then took 122 s.
- The list is a <Virtualize>: only the cards near the view are rendered.
  The page now holds about 3 000 DOM nodes and 145 MB however long the feed
  is, and each page of older posts costs the same (about 1.9 s in the test's
  scroll loop) at the 100th post as at the 1700th. The feed tells how many
  posts it holds in data-feed-count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:30:45 +02:00
thepraandClaude Opus 5.5 f9de55ffd5 A moved account's profile says where it went
PrivaPub now fills `moved` once a move is believed; the profile shows it, linking
the new account's profile. The e2e opens the account SocialPub's moves scenario
moved and finds the new one named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:39:56 +02:00
thepraandClaude Opus 5.5 228708c328 Settings: change the login's password, or delete the login
A Login section in Settings changes the root's password (the current one
given) and deletes the whole login, every persona with it, after its
password and an explicit "I understand"; the client then signs out. Both
use PrivaPub's existing /clientapi/user routes.

The e2e signs up a login of its own, changes its password and deletes it,
checking each against PrivaPub's login. Signing in now waits for either
the feed or an enabled persona chooser, so a login with one persona (which
goes on by itself) and a new persona's empty feed both sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:32:47 +02:00
thepraandClaude Opus 5.5 0bbab720a5 Groups: make, join, run and leave communities and circles
The Groups page (/groups, in the menu) is where a persona makes a community
or a circle (with its invitation code and an optional password), joins one
by invitation and leaves one. For the groups it runs it edits them, shows
the invitation, lets in or declines the requests to join, and takes members
out, through PrivaPub's new /clientapi/group/members, reject and remove.
The e2e makes a circle, joins it as another persona with the code, and has
the owner take that member out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:24:39 +02:00
thepraandClaude Opus 5.5 2024798732 Custom emoji, downvotes, blurhash and focal points on the cards
What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
  posts, names, content warnings, bios and fields: only the shortcodes the
  server listed, only http(s) pictures, never inside a tag, the shortcode
  kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
  small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
  tentative or cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:55:39 +02:00
thepraandClaude Opus 5.5 5e8fdc43af An event card joins and leaves the event
PrivaPub lets a persona join another server's event (owner decision
2026-10-05). The card of a remote event shows the persona's participation
(pending, accepted, rejected) with Join or Leave, except for events by
invitation or taken on another site. Joining is public on the event's
server, so the first join asks the persona once, as reactions and votes do,
remembered apart from them (PublicAct).

The e2e joins the event SocialPub's Mobilizon scenario keeps, checks the
participant on Mobilizon and PrivaPub's answer, and leaves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 15:19:07 +02:00
thepraandClaude Opus 5.5 6f8472bc9f A reply or boost awaiting its author's approval says so
GoToSocial lets an author approve replies, likes and boosts first. PrivaPub now asks that author and marks the
persona's reply or boost privapub.approval: pending until the answer, or rejected; the card shows which, with an
hourglass or a cross. One the policy shuts out is refused (422) and shown as the error it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:44:09 +02:00
thepraandClaude Opus 5.5 61631bb0a7 The composer says which language a post is in
A post went out in the reader's own interface language whatever it was
written in. The composer now has a language picker (each language named in
itself), starting at the persona's default from Settings, else the
interface's; a reply starts in its thread's language. The e2e poll test
picks Italian and finds it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:06:12 +02:00
thepraandClaude Opus 5.5 d7d836ba39 Settings, Administration and conversations; threads that fill in
Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
  name, biography and fields as written, locked, bot, discoverable,
  indexable, and how its posts start (visibility, language, sensitive
  media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
  there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
  and imported as the CSV files Mastodon writes (PortabilityService): an
  import resolves each row through PrivaPub, one at a time, and can stop.

Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).

The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.

A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.

The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 03f4f2ad53 E2E: a known gap skips its test, and reactions start clean
A test whose failure an open gap of the town's gaps.json names (a peer's
own bug, such as Misskey keeping its renote count after our Undo, G-0005)
is now skipped with the gap's id instead of failing the run; the report
still counts the cell an expected failure. The reaction test starts by
withdrawing a reaction a run cut short left behind, so one failure no
longer makes the next run fail too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 5c11727f58 Polls, quotes and edits from the composer
The composer can hold a poll instead of media (two to four choices, a
length, one choice or several), quote a post (offered on a card when the
server says the persona may quote it), and edit the persona's own post: its
words and warning come from /source, and saving keeps its media,
visibility and poll. A card's menu offers Edit and Quote, and now opens
sideways: upwards, the first card's menu was cut off by the top of the
feed. The e2e tests publish a poll, edit a post and quote it, each checked
on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:42:42 +02:00
thepraandClaude Opus 5.5 a4d9831691 Notifications in groups, and what is unread
The Notifications tab reads grouped notifications (/api/v2/notifications):
likes and boosts of one post, or follows of one hour, are one card naming
both accounts of a pair, or the newest and how many others, with their
avatars. A notification the stream tells joins its group and waits on top.
The tab's badge counts what is unread; opening the tab moves the
notifications marker to the newest shown, which reads it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:35:23 +02:00
thepraandClaude Opus 5.5 963565b316 The feed hears new posts as they come
decePub opens PrivaPub's streaming API (one WebSocket for the persona, its
token given as the socket's protocol, never in a URL) and subscribes to the
stream of the feed in view: home, a list, local, federated, a hashtag or
notifications. New posts wait behind the "new" button as polled ones did;
a deletion or an edit applies at once. Polling stays for the feeds PrivaPub
does not stream and for any feed while the socket is down, which opens again
after a pause growing to a minute. The e2e test sees a post made elsewhere
in seconds and its deletion take the card away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:15:54 +02:00
thepraandClaude Opus 5.5 6d5cf17723 Posts for later, hashtags followed, and Explore
The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:53:51 +02:00
thepraandClaude Opus 5.5 9a752d9288 Filters: made on /filters, applied by each card where it is
The persona's filters (PrivaPub's /api/v2/filters): made, given words and
whole-word rules, contexts, an action and an expiry on /filters (linked from
Settings), and deleted; posts filtered one by one can be taken out. Every
status says which filters it matches; each page tells its cards where they
are (home and lists, notifications, public feeds, the thread around the
opened post, profiles), and a card leaves its post out (hide), folds it
behind the filter's title with a way to show it anyway (warn), or blurs its
media (blur). The e2e test folds and then hides a post of the persona's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:10:27 +02:00
thepraandClaude Opus 5.5 e2491de16e Lists: made on /lists, read as a tab of the feed
A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a
replies policy and made exclusive on /lists, filled from the accounts it
follows (accounts/search?following=true), and read as the feed's Lists tab.
A followed account's profile has a Lists menu to put it in or take it out.
ListService does the calls; the e2e tests check each step against
PrivaPub's own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:49:51 +02:00
thepraandClaude Opus 5.5 011bafb6af End-to-end tests against the pasture; profiles, follows, search, votes, reactions
tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already
cached, never downloaded, never in CI) runs the published app against
SocialPub's pasture and its town: run.sh publishes it with the Pasture
environment (wwwroot/appsettings.Pasture.json, published only then; the
deploy fails if it ever ships) and serves it as https://decepub.test. The
tests sign in through the real login page and persona chooser, check that
every request goes only to decepub.test and privapub.test with the right
credential on the right API, render one post of each kind from each
platform as its author wrote it, and check every action on the other
side of the wire (town.sh stored): favourites, boosts and replies with
their undoing, follows from a profile, votes, reactions. Each test is also
a cell of the town's report (out/client.jsonl). They found two PrivaPub
bugs fixed there (a blocked author's status answered null; sensitive
remote media invented a content warning) and a reply addressed to nobody.

New in the app, all checked by those tests:
- profiles at /account/{id}: header, bio, fields, counts, the account's
  posts as posts, posts and replies, or media, and follow, unfollow,
  cancel a request, mute and block (AccountService); post authors and
  notifications open them in the app;
- search at /search: accounts, posts and hashtags, a handle or an address
  resolved by PrivaPub;
- poll voting on the card, and emoji reactions shown and given with one
  tap; before a persona's first reaction or vote it is told once that
  both are public (PublicNoticeService, the owner's decision);
- follow requests answered from their notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:23:18 +02:00
163 changed files with 19519 additions and 628 deletions

No files matched your search

+3
View File
@@ -55,6 +55,9 @@ jobs:
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; } [ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
done done
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; } ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
for f in appsettings.Development.json appsettings.Pasture.json; do
[ ! -e "$W/$f" ] || { echo "::error::publish output holds $f, which points the app at a test server"; exit 1; }
done
echo "publish OK, $(du -sh "$W" | cut -f1)" echo "publish OK, $(du -sh "$W" | cut -f1)"
- name: Snapshot the live directory - name: Snapshot the live directory
+4
View File
@@ -367,3 +367,7 @@ FodyWeavers.xsd
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate # Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
.tools/ .tools/
wwwroot/css/app.css wwwroot/css/app.css
# the e2e tests: the published app they serve and the browser state they keep
tests/e2e/.publish/
tests/e2e/.state/
+263 -12
View File
@@ -43,7 +43,65 @@ dotnet run # dev server: https://loc
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
``` ```
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There are no tests and no linter. `global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
## Testing: end to end against the pasture
`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.
```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
```
- **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`.
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
- sideways scroll, unless the strip is marked `data-scroll-x`;
- boxes past the screen's sides;
- open menus, sheets and dialogs that are cut or outside the screen;
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
- control text under 12px.
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
`client.mobile.touch.*`, with the phone as the ref.
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
- `DesktopUnchangedTests` holds desktop's control sizes.
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
turned to the subsolar point.
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
to the banner.
- An idle globe draws no frame.
Photos and stats go to `tests/e2e/out/globe/`.
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline ## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -87,6 +145,7 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
- picking a hue leaves grey. - picking a hue leaves grey.
- `AppStatusService.IsDarkTheme()` tells which variant is showing. - `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. - 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).
- `Styles/neo.css` holds the neumorphic layer: - `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 - `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
old `neomorph is-nxsmall` classes); old `neomorph is-nxsmall` classes);
@@ -95,9 +154,63 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the 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 daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
shadow with `shadow-none!`. 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 - Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
every collapse disappears. every collapse disappears.
## Phones
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
- **Width and pointer are separate.**
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
daisyUI's own size variables:
- buttons, tabs, inputs, selects and file inputs are 44px tall;
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
the padding box);
- checkboxes and radios are 24px, toggles and range thumbs 28;
- field text is 16px, so iOS doesn't zoom into a focused field.
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
phone sweep fails anything less.
- **Overlays on a phone rise from the bottom.**
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
close button (`ShowClose`, named by `CloseText`).
- `DDropdown SheetOnNarrow` turns its content into a bottom sheet.
- `Components/PhoneSheet.razor` shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a
sheet on a phone.
- All are capped by `dvh`, and the app itself is `h-dvh`.
- **The Back button closes the newest open overlay** instead of leaving the page.
- Overlays register through `IDOverlayHost` (`Components/Daisy`, cascaded from `MainLayout`):
- `DModal` and `DDropdown` through the cascade;
- the nav menu, `PhoneSheet` and the full-screen globe through `Services/OverlayService.cs`.
- `main.js` marks a real Back press (a capture-phase `popstate`), since Blazor can't tell it from the app's own
navigation, which closes every overlay and goes on.
- No history entries are pushed.
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and
signing out.
- **On a touch screen:**
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
pinned to the globe with a mouse only;
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
- pictures open in `Components/MediaViewer.razor`.
- **The feed on a phone:**
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
stats' Details are in More, and the author's picture is in the header;
- a new post starts from a floating button;
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
## daisyUI Blazor components (`Components/Daisy/`) ## daisyUI Blazor components (`Components/Daisy/`)
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
@@ -153,6 +266,7 @@ supplies:
messages. messages.
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages, - `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
`AdditionalError` and `AdditionalErrors`. `AdditionalError` and `AdditionalErrors`.
- `ConfirmService` asks before what can't be undone (see **Phones**).
**HTTP (`Services/IHttpService.cs`).** **HTTP (`Services/IHttpService.cs`).**
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from - Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
@@ -162,10 +276,12 @@ supplies:
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`, - It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload `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. can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` / - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
redirects it). PrivaPub redirects it).
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - `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 - 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. `Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's - Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
@@ -183,8 +299,115 @@ supplies:
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; - **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 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, `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads), threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
`GroupService`, `InstanceService` and `ProvenanceService`. leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `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
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`
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
persona pins and unpins its own public and unlisted posts from the card's menu.
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.
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
- **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.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out.
Only a group's owner and moderators see who is in it.
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
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).
- **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`); a post can hold
a poll instead of media.
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
- the ids are kept, so a post that fails never uploads its media again;
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
- a description changed after the upload is PUT before the post.
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
the reader turned `PageSettings.RemindAltText` off in Settings → General.
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
(`media_attributes`). Its visibility and poll stay.
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
(`/api/v2/search`, never resolving), once typing pauses.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
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
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 (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. Above it, `PersonaArchivePanel`
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
to disk.
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
has, and carries on from what PrivaPub says after a refused or lost piece.
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait.
- **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
it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too.
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from - **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
`PrivaPub.ClientModels`. `PrivaPub.ClientModels`.
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
@@ -195,8 +418,17 @@ supplies:
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host, are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
since the API may be reached under another name. since the API may be reached under another name.
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`, - **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while `GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll,
the page is visible (PrivaPub has no streaming) and wait behind a "new" button. ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
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.
- **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
card shows from outside it must replace one of those objects or be added to the key; mutating one in place leaves the
card as it was. Without these a feed took seven minutes to reach a thousand cards and held 900 MB at two thousand.
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the `wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
@@ -209,8 +441,28 @@ supplies:
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the 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 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, 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 `data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
in the panel's top corner. The theme changes only the atmosphere and the points. 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 - **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 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 (`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
@@ -221,8 +473,7 @@ supplies:
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on - **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 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 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, request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
the place's elevation and day length, the next equinox or solstice).
**Localization (`Services/CoalescingStringLocalizer.cs`).** **Localization (`Services/CoalescingStringLocalizer.cs`).**
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then - Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
+30
View File
@@ -3,6 +3,7 @@
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.Group; using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User;
@@ -22,6 +23,7 @@ namespace decePubClient;
[JsonSerializable(typeof(PageSettings))] [JsonSerializable(typeof(PageSettings))]
[JsonSerializable(typeof(AuthData))] [JsonSerializable(typeof(AuthData))]
[JsonSerializable(typeof(FeedFilter))] [JsonSerializable(typeof(FeedFilter))]
[JsonSerializable(typeof(PublicActionNotices))]
[JsonSerializable(typeof(ServerInfo))] [JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))] [JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(UploadMedia))] [JsonSerializable(typeof(UploadMedia))]
@@ -30,14 +32,42 @@ namespace decePubClient;
[JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))] [JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))] [JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(JwtUser))] [JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))] [JsonSerializable(typeof(List<ViewAvatar>))]
[JsonSerializable(typeof(InsertAvatarForm))]
[JsonSerializable(typeof(ViewGroup))] [JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))] [JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewGroupMember))]
[JsonSerializable(typeof(List<ViewGroupMember>))]
[JsonSerializable(typeof(InsertGroupForm))]
[JsonSerializable(typeof(UpdateGroupForm))]
[JsonSerializable(typeof(JoinGroupForm))]
[JsonSerializable(typeof(GroupMembershipForm))]
[JsonSerializable(typeof(ViewPost))] [JsonSerializable(typeof(ViewPost))]
[JsonSerializable(typeof(List<ViewPost>))] [JsonSerializable(typeof(List<ViewPost>))]
[JsonSerializable(typeof(InsertPostForm))] [JsonSerializable(typeof(InsertPostForm))]
[JsonSerializable(typeof(DeletePostForm))]
[JsonSerializable(typeof(ViewReport))]
[JsonSerializable(typeof(List<ViewReport>))]
[JsonSerializable(typeof(DomainBlockForm))]
[JsonSerializable(typeof(ViewDomainBlock))]
[JsonSerializable(typeof(List<ViewDomainBlock>))]
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewBackups))]
[JsonSerializable(typeof(ViewBackup))]
[JsonSerializable(typeof(BackupPasswordForm))]
[JsonSerializable(typeof(RestoreBackupForm))]
[JsonSerializable(typeof(ViewBackupTicket))]
[JsonSerializable(typeof(ViewBackupUpload))]
[JsonSerializable(typeof(ViewPersonaArchive))]
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))]
public partial class ClientJsonContext : JsonSerializerContext public partial class ClientJsonContext : JsonSerializerContext
{ {
} }
+448
View File
@@ -0,0 +1,448 @@
@inherits LocalizableComponentBase
@implements IAsyncDisposable
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
and everyone signs in again. A restore never undoes what protects. *@
<div class="flex flex-col gap-3 w-full" data-backups>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (restarting is not null)
{
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
</DAlert>
}
else
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
<i class="ion-md-archive"></i> @Localizer["Back up now"]
</DButton>
@if (backups.Running is { } running)
{
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
</span>
}
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
</div>
@if (backups.Waiting is { } waiting)
{
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
<span>
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
@if (waiting.Error is { Length: > 0 } error)
{
<br /><span class="text-xs">@error</span>
}
</span>
</DAlert>
}
@if (backups.LastRestore is { } last)
{
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
@if (last.Abandoned)
{
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
<p class="text-xs">@last.Error</p>
}
else
{
<p>
<b>@Localizer["Restored {0}", last.Backup]</b>
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
</p>
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
{
<p class="text-xs" data-last-restore-tombstoned>
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
</p>
}
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
}
</div>
}
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
data-backup-upload />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
</DButton>
</div>
@if (uploading)
{
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
}
</div>
@if (backups.Backups.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
}
@foreach (var backup in backups.Backups)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
</div>
<p class="text-xs opacity-70">
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
@(backup.DbOnly
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
@if (backup.MediaMissing > 0)
{
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
}
@if (!backup.Consistent)
{
<span>; @Localizer["not read at one instant"]</span>
}
</p>
@if (backup.RootsDeletedSince > 0)
{
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
<i class="ion-md-download"></i> @Localizer["Download"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
OnClick="() => AskRestore(backup)" data-backup-restore>
<i class="ion-md-refresh"></i> @Localizer["Restore"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
<i class="ion-md-trash"></i> @Localizer["Delete"]
</DButton>
</div>
</div>
}
}
</div>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
CloseText="@Localizer["Close"]" data-backup-download-dialog>
<Heading>@Localizer["Download a backup"]</Heading>
<ChildContent>
@if (ticket is null)
{
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
else
{
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
</a>
}
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
@if (ticket is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
@Localizer["Get the link"]
</DButton>
}
</Actions>
</DModal>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 text-sm">
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
@if (restoring?.RootsDeletedSince > 0)
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
<ErrorViewer AdditionalError="@dialogError" />
</div>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error"
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
OnClick="Restore" data-backup-confirm>
@Localizer["Restore"]
</DButton>
</Actions>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] AppConfiguration Configuration { get; set; }
[Inject] IJSRuntime JS { get; set; }
const string UploadInput = "input[data-backup-upload]";
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
ViewBackups backups = new();
bool busy;
string restarting;
ViewBackup downloading;
ViewBackup restoring;
string password;
string typedHost;
string ticket;
long ticketSize;
string dialogError;
string pickedName;
bool uploading;
long uploaded;
long uploadSize;
double uploadedPercent;
IJSObjectReference transfer;
DotNetObjectReference<BackupsPanel> self;
CancellationTokenSource polling;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
backups = await AdminService.Backups() ?? backups;
if (backups.Running is not null || backups.Waiting is not null)
Poll();
}
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
void Poll()
{
if (polling is not null)
return;
polling = new();
var token = polling.Token;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(PollEvery);
while (await timer.WaitForNextTickAsync(token))
{
var read = await AdminService.Backups(token);
if (read is not null)
backups = read;
await InvokeAsync(StateHasChanged);
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
break;
}
polling = null;
}, token);
}
static DColor KindColor(string kind) => kind switch
{
"nightly" => DColor.Neutral,
"manual" => DColor.Primary,
"pre-deploy" => DColor.Info,
"pre-restore" => DColor.Warning,
"uploaded" => DColor.Accent,
_ => DColor.Neutral
};
string KindName(string kind) => kind switch
{
"nightly" => Localizer["Nightly"],
"manual" => Localizer["Made by hand"],
"pre-deploy" => Localizer["Before a deploy"],
"pre-restore" => Localizer["Before a restore"],
"uploaded" => Localizer["Uploaded"],
_ => kind
};
async Task BackUp()
{
busy = true;
var result = await AdminService.BackUp();
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Running = "backup";
Toast.AddResponseSuccess(Localizer["The backup has started."]);
Poll();
}
async Task Delete(ViewBackup backup)
{
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
return;
busy = true;
var result = await AdminService.DeleteBackup(backup.Id);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
}
void AskDownload(ViewBackup backup)
{
(password, ticket, dialogError) = (null, null, null);
downloading = backup;
}
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
async Task Download()
{
busy = true;
var result = await AdminService.BackupTicket(downloading.Id, password);
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
var granted = (ViewBackupTicket)result.Data;
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
ticketSize = granted.Bytes;
password = null;
dialogError = null;
}
void AskRestore(ViewBackup backup)
{
(password, typedHost, dialogError) = (null, null, null);
restoring = backup;
}
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
async Task Restore()
{
busy = true;
var asked = restoring;
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
CloseRestore();
restarting = asked.Id;
Poll();
}
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
async Task Upload()
{
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
self ??= DotNetObjectReference.Create(this);
var started = await AdminService.StartBackupUpload();
if (started.IsInvalid)
{
Toast.AddResponseError(started);
return;
}
var upload = (ViewBackupUpload)started.Data;
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
if (!sent.Ok)
{
uploading = false;
await AdminService.CancelBackupUpload(upload.Id);
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
return;
}
var finished = await AdminService.FinishBackupUpload(upload.Id);
uploading = false;
if (finished.IsInvalid)
{
Toast.AddResponseError(finished);
return;
}
var backup = (ViewBackup)finished.Data;
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
pickedName = null;
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
}
[JSInvokable]
public Task Progress(long received, long total)
{
(uploaded, uploadSize) = (received, total);
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
return InvokeAsync(StateHasChanged);
}
sealed class UploadOutcome
{
public bool Ok { get; set; }
public int Status { get; set; }
public string Error { get; set; }
}
public async ValueTask DisposeAsync()
{
polling?.Cancel();
self?.Dispose();
if (transfer is null)
return;
try
{
await transfer.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
+138
View File
@@ -0,0 +1,138 @@
@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. *@
<div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@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 (reports.Count == 0)
{
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
}
@foreach (var report in reports)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
<div class="flex flex-wrap items-center gap-2">
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
@if (report.Forwarded)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
}
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
@report.CreatedAt.GetPassedTime(Localizer)
</span>
<span class="flex-1"></span>
@if (!report.IsResolved)
{
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
data-report-resolve>
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
</DButton>
}
else if (report.ResolvedAt is { } resolvedAt)
{
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
}
</div>
<p class="text-sm">
<b>@Localizer["About"]</b>
@if (report.TargetAccountId is { Length: > 0 } targetId)
{
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
}
else
{
<span class="break-all">@report.TargetActorURI</span>
}
</p>
<p class="text-sm">
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
}
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
{
<div class="flex flex-wrap gap-2 text-xs">
@foreach (var postId in report.PostIds)
{
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
}
@* a report from another server names posts by their ids there when we hold none of them *@
@if (report.PostIds.Count == 0)
{
@foreach (var uri in report.ObjectURIs)
{
<span class="break-all opacity-70">@uri</span>
}
}
</div>
}
</article>
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
List<ViewReport> reports = [];
bool resolved;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => reports = await AdminService.Reports(resolved);
async Task ShowResolved(bool value)
{
resolved = value;
IsLoading = true;
await LoadData();
IsLoading = false;
}
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
"legal" => Localizer["Illegal"],
"violation" => Localizer["Breaks the rules"],
_ => Localizer["Other"]
};
async Task Resolve(ViewReport report)
{
busy.Add(report.ID);
var result = await AdminService.Resolve(report.ID);
busy.Remove(report.ID);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
reports.Remove(report);
Toast.AddResponseSuccess(Localizer["Report resolved."]);
}
}
+122
View File
@@ -0,0 +1,122 @@
@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. *@
<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 />
<div class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Domain" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["A server, e.g. example.social"]" data-server-block-domain>
<Prefix><i class="ion-md-globe"></i></Prefix>
</DInput>
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div>
<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))">
@Localizer["Block server"]
</DButton>
</EditForm>
@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 (blocks.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
}
@foreach (var block in blocks)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-server-block="@block.Domain">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 truncate">@block.Domain</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@(block.Severity == "Suspend" ? DColor.Error : DColor.Warning)">
@(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"])
</DBadge>
@if (block.RejectMedia)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</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="() => Unblock(block)">
@Localizer["Unblock"]
</DButton>
</div>
@if (block.PublicComment is { Length: > 0 } publicComment)
{
<p class="text-xs">@publicComment</p>
}
@if (block.PrivateComment is { Length: > 0 } privateComment)
{
<p class="text-xs opacity-70"><i class="ion-md-lock"></i> @privateComment</p>
}
</div>
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
List<ViewDomainBlock> blocks = [];
DomainBlockForm form = new();
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() => blocks = await AdminService.ServerBlocks();
async Task Block()
{
isSaving = true;
var result = await AdminService.BlockServer(form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var block = (ViewDomainBlock)result.Data;
blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)];
form = new();
Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]);
}
async Task Unblock(ViewDomainBlock block)
{
busy.Add(block.Domain);
var result = await AdminService.UnblockServer(block.Domain);
busy.Remove(block.Domain);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
blocks.Remove(block);
}
}
+209
View File
@@ -0,0 +1,209 @@
@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. *@
<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">
<span>@Localizer["Period"]</span>
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
@foreach (var period in Periods)
{
<option value="@period">@Localizer["{0} days", period]</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Servers by"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
<option value="traffic">@Localizer["Traffic"]</option>
<option value="failures">@Localizer["Failures"]</option>
<option value="latency">@Localizer["Latency"]</option>
<option value="host">@Localizer["Name"]</option>
</DSelect>
</label>
</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 (overview is null)
{
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
}
else
{
<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>
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
</DStat>
<DStat>
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
<ChildContent>@overview.Hosts.Active</ChildContent>
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
</DStat>
<DStat>
<Heading>@Localizer["Accounts seen"]</Heading>
<ChildContent>@overview.Accounts</ChildContent>
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
</DStat>
</DStats>
<div class="flex flex-wrap gap-4 text-xs">
@foreach (var (title, counts) in Outcomes)
{
<div class="flex flex-col gap-1 min-w-40">
<b class="text-sm">@title</b>
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
{
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
}
</div>
}
</div>
@if (hosts is not null)
{
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
<thead>
<tr>
<th>@Localizer["server"]</th>
<th>@Localizer["software"]</th>
<th class="text-right">@Localizer["In"]</th>
<th class="text-right">@Localizer["Out"]</th>
<th class="text-right">@Localizer["Refused"]</th>
<th class="text-right">@Localizer["Failures"]</th>
<th class="text-right">p50</th>
<th>@Localizer["Delivery"]</th>
</tr>
</thead>
<tbody>
@foreach (var host in hosts.Hosts)
{
<tr data-statistics-host="@host.Host">
<td class="max-w-48 truncate">@host.Host</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>
<td class="text-right tabular-nums">@host.Refused</td>
<td class="text-right tabular-nums">@host.Failures</td>
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
<td>
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
{
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
}
else if (host.Delivery?.ConsecutiveFailures > 0)
{
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
}
</td>
</tr>
}
</tbody>
</DTable>
@if (hosts.Total > PageSize)
{
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
}
<p class="text-xs opacity-70">@hosts.Attribution</p>
}
@if (crawler is not null)
{
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
<b class="text-sm">@Localizer["Crawler"]</b>
<span>
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
</span>
@foreach (var host in crawler.Recent)
{
<span class="flex flex-wrap gap-2">
<span class="truncate max-w-48">@host.Host</span>
<span class="opacity-70">@host.Software</span>
@if (host.LastCrawledAt is { } crawled)
{
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
}
@if (host.CrawlError is { Length: > 0 } crawlError)
{
<span class="text-error">@crawlError</span>
}
</span>
}
</div>
}
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
const int PageSize = 50;
static readonly int[] Periods = [1, 7, 30, 90];
ViewStatisticsOverview overview;
ViewHostsPage hosts;
ViewCrawler crawler;
int days = 30;
string sort = "traffic";
int page = 1;
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
[
(Localizer["Received"], overview.Inbound),
(Localizer["Delivered"], overview.Outbound),
(Localizer["Channels"], overview.Channels)
];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
overview = await AdminService.Overview(days);
hosts = await AdminService.Hosts(days, sort, page);
crawler = await AdminService.Crawler();
}
static string Ms(int? value) => value?.ToString() ?? "—";
async Task ChangeDays(int value)
{
days = value;
page = 1;
await LoadData();
}
async Task ChangeSort(string value)
{
sort = value;
page = 1;
hosts = await AdminService.Hosts(days, sort, page);
}
async Task ChangePage(int value)
{
page = value;
hosts = await AdminService.Hosts(days, sort, page);
}
}
+461 -52
View File
@@ -1,8 +1,14 @@
@using System.Text.RegularExpressions
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a @* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of 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. *@ 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,
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"> <EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
@@ -13,38 +19,127 @@
<Prefix><i class="ion-md-warning text-warning"></i></Prefix> <Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput> </DInput>
} }
<div class="relative"> <div class="flex flex-col gap-1">
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true" <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" class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" /> 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> </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" 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>
</DButton>
}
@foreach (var tag in suggestedTags)
{
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
}
</div>
}
@if (Quoting is not null)
{
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
}
@if (form.HasPoll)
{
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
@for (var i = 0; i < form.PollOptions.Count; i++)
{
var index = i;
<div class="flex items-center gap-2">
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
@if (form.PollOptions.Count > 2)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
}
</div>
}
<div class="flex flex-wrap items-center gap-2">
@if (form.PollOptions.Count < 4)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
}
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
@foreach (var (seconds, label) in PollLengths)
{
<option value="@seconds">@label</option>
}
</DSelect>
<label class="label text-xs">
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
<span>@Localizer["Several choices"]</span>
</label>
</div>
</div>
}
@if (form.Media.Count > 0) @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) @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"> <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.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image")) @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" /> <img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
} }
else 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"> <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> <p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500" <p class="inline-flex items-center gap-2 text-xs">
placeholder="@Localizer["Alternative text"]" rows="1" /> @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> </div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]"> <DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
<i class="ion-md-trash text-base text-error"></i> <i class="ion-md-trash text-base text-error"></i>
</DButton> </DButton>
</div> </div>
} }
</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) @if (form.IsLocated)
@@ -69,34 +164,47 @@
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null) @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")" <DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"> ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger> <Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i> <i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
</Trigger> </Trigger>
<ChildContent> <ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>()) @foreach (var messageType in Enum.GetValues<MessageType>())
{ {
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
<i class="@messageType.GetMessageTypeIcon() text-base"></i> <i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
</DButton> </DButton>
} }
</ChildContent> </ChildContent>
</DDropdown> </DDropdown>
} }
@if (!form.IsLocated && GroupId is null && Editing is null)
{
@* the language the post is written in, as each language names itself *@
<DSelect TValue="string" Size="DSize.Sm" class="w-auto" @bind-Value="form.Language" title="@Localizer["Language"]" data-compose-language>
@foreach (var (languageCode, languageName) in Languages.All)
{
<option value="@languageCode">@languageName</option>
}
</DSelect>
}
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]"> <DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
<i class="ion-md-warning text-base"></i> <i class="ion-md-warning text-base"></i>
</DButton> </DButton>
@if (!form.IsLocated && GroupId is null) @if (!form.IsLocated && GroupId is null && !form.HasPoll)
{ {
@* A label styled as a button opens the hidden file input. *@ @* 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"]"> <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" <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> <i class="ion-md-attach text-base"></i>
@if (form.Media.Count != 0) @if (form.Media.Count != 0)
{ {
@@ -115,12 +223,26 @@
</DSelect> </DSelect>
} }
@if (InReplyTo is null && GroupId is null) @if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
{
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
<i class="ion-md-stats text-base"></i>
</DButton>
}
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
{ {
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]"> <DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i> <i class="ion-md-locate text-base"></i>
</DButton> </DButton>
} }
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
<i class="ion-md-time text-base"></i>
</DButton>
}
</div> </div>
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)"> <DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
@@ -128,14 +250,28 @@
{ {
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /> <DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
} }
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span> <span>@SubmitLabel</span>
<i class="ion-md-send"></i> <i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
</DButton> </DButton>
</div> </div>
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
{
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex flex-wrap items-center gap-2">
<span>@Localizer["Publish it on"]</span>
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
</label>
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
</div>
}
</EditForm> </EditForm>
@code { @code {
[Inject] StatusService StatusService { get; set; } [Inject] StatusService StatusService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] ILoggingService Logger { get; set; } [Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; } [Inject] IJSRuntime JS { get; set; }
@@ -143,37 +279,169 @@
[Parameter] public Status InReplyTo { get; set; } [Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary> /// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; } [Parameter] public string GroupId { get; set; }
/// <summary>The post this one quotes (shown under the words).</summary>
[Parameter] public Status Quoting { get; set; }
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
[Parameter] public Status Editing { get; set; }
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary> /// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; } [Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
ComposeForm form = new(); ComposeForm form = new();
List<string> fileErrors = []; List<string> fileErrors = [];
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
List<Account> suggestedAccounts = [];
List<StatusTag> suggestedTags = [];
// the newest lookup: an older one's answer comes too late to show
int suggesting;
string error; string error;
bool isPublishing; bool isPublishing;
int ContentLength => form.Content?.Length ?? 0; int ContentLength => form.Content?.Length ?? 0;
// what is being written, once typing pauses, among the accounts and hashtags this server knows
async Task Typed()
{
var typed = Typing.Match(form.Content ?? "");
var lookup = ++suggesting;
if (!typed.Success || typed.Groups[2].Length < 2)
{
suggestedAccounts = [];
suggestedTags = [];
return;
}
await Task.Delay(300);
if (lookup != suggesting)
return;
var hashtags = typed.Groups[1].Value == "#";
var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
if (lookup != suggesting)
return;
suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
}
// the mention or hashtag being written, written out
void Complete(string written)
{
var typed = Typing.Match(form.Content ?? "");
suggesting++;
suggestedAccounts = [];
suggestedTags = [];
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"]
: Quoting is not null ? Localizer["Say something about it"]
: InReplyTo is null ? Localizer["What's happening?"]
: Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct];
string SubmitLabel => Editing is not null ? Localizer["Save"]
: form.IsScheduled ? Localizer["Schedule"]
: Quoting is not null ? Localizer["Quote"]
: Localizer[InReplyTo is null ? "Post" : "Reply"];
(int Seconds, string Label)[] PollLengths =>
[
(300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]),
(86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"])
];
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1; 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.Images => type.StartsWith("image/"),
MediaType.Audio => 41_943_040, MediaType.Video => type.StartsWith("video/"),
_ => 99_614_720 _ => 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", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
MediaType.Video => ".mp4,.m4v,.webm,.mov", [".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a", [".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
_ => default [".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
}; };
protected override void OnInitialized() // 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
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
bool focusPending = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await textarea.Element.FocusAsync();
}
protected override async Task OnInitializedAsync()
{ {
IsLoading = false;
form.GroupId = GroupId; form.GroupId = GroupId;
form.MessageType = DefaultVisibility;
form.Language = DefaultLanguage;
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
form.QuotedStatusId = Quoting?.Id;
limits = await MediaService.Limits();
if (Editing is not null)
{
var source = await StatusService.Source(Editing.Id);
form.Content = source?.Text;
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
form.SpoilerText = source?.SpoilerText;
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
form.Language = Editing.Language ?? form.Language;
form.Sensitive = Editing.Sensitive;
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
{
Id = m.Id,
State = UploadState.Ready,
Posted = true,
AltText = m.Description,
SentAltText = m.Description,
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
FileName = Path.GetFileName(m.Url ?? string.Empty)
})];
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
}
IsLoading = false;
if (InReplyTo is null) if (InReplyTo is null)
return; return;
@@ -185,6 +453,8 @@
"unlisted" => MessageType.Unlisted, "unlisted" => MessageType.Unlisted,
_ => MessageType.Public _ => MessageType.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;
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 }; form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
form.SpoilerText = InReplyTo.SpoilerText; form.SpoilerText = InReplyTo.SpoilerText;
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct) var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
@@ -198,6 +468,14 @@
form.IsScopeOptionsOpen = false; form.IsScopeOptionsOpen = false;
} }
// a scheduled post starts an hour ahead, which the reader moves as it likes
void ToggleScheduled()
{
form.IsScheduled = !form.IsScheduled;
if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt))
form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm");
}
async Task ToggleLocated() async Task ToggleLocated()
{ {
form.IsLocated = !form.IsLocated; form.IsLocated = !form.IsLocated;
@@ -227,40 +505,121 @@
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount)) 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, 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; continue;
} }
if (await ReadFile(file) is { } media) // the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
form.Media.Add(media); var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
else form.Media.Add(media);
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> // a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default) async Task<string> Thumbnail(IBrowserFile file)
{ {
try try
{ {
using var memoryStream = new MemoryStream(); var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken); await using var stream = small.OpenReadStream(2 * 1024 * 1024);
await fileStream.CopyToAsync(memoryStream, cancellationToken); using var buffer = new MemoryStream();
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() }; await stream.CopyToAsync(buffer);
if (file.ContentType.StartsWith("image")) return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
return media;
} }
catch (Exception ex) 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; return default;
} }
} }
void Start(UploadMedia media)
{
media.Cancel = new();
media.State = UploadState.Uploading;
media.Error = default;
media.Done = Upload(media, media.Cancel.Token);
}
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
{
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (uploaded.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = uploaded.ErrorMessage;
await InvokeAsync(StateHasChanged);
return;
}
var attachment = (MediaAttachment)uploaded.Data;
media.Id = attachment.Id;
media.SentAltText = attachment.Description;
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
await InvokeAsync(StateHasChanged);
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(2000, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var processed = await MediaService.Get(media.Id, cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (processed.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = processed.ErrorMessage;
}
else if (processed.Data is MediaAttachment)
media.State = UploadState.Ready;
await InvokeAsync(StateHasChanged);
}
}
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
void Retry(UploadMedia media) => Start(media);
void Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
}
// the reader chose to publish its pictures without descriptions
async Task PublishAnyway()
{
altTextConfirmed = true;
askingAltText = false;
await Publish();
}
public override async ValueTask DisposeAsync()
{
foreach (var media in form.Media)
media.Cancel?.Cancel();
await base.DisposeAsync();
}
async Task Publish() async Task Publish()
{ {
error = default; error = default;
@@ -269,12 +628,54 @@
error = Localizer["Missing content, either message or media"]; error = Localizer["Missing content, either message or media"];
return; return;
} }
if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2)
{
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;
var edited = await StatusService.Edit(Editing.Id, form);
isPublishing = false;
if (edited.IsInvalid)
{
error = edited.ErrorMessage;
return;
}
Toast.AddResponseSuccess(Localizer["Saved."]);
await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data));
return;
}
if (CascadingState.PersonaId is null) if (CascadingState.PersonaId is null)
{ {
error = Localizer["Choose a persona first."]; error = Localizer["Choose a persona first."];
return; return;
} }
if (form.IsScheduled && form.ScheduledAtUtc is not { } at)
{
error = Localizer["Choose when to publish it."];
return;
}
if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5))
{
error = Localizer["A post is scheduled at least five minutes ahead."];
return;
}
isPublishing = true; isPublishing = true;
var result = await StatusService.Publish(form, CascadingState.PersonaId); var result = await StatusService.Publish(form, CascadingState.PersonaId);
isPublishing = false; isPublishing = false;
@@ -284,9 +685,17 @@
return; return;
} }
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 };
await OnPublished.InvokeAsync(null);
return;
}
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]); Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
var published = result.Data as FeedPost; var published = result.Data as FeedPost;
form = new() { GroupId = GroupId }; form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
await OnPublished.InvokeAsync(published); 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;
}
+903 -211
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -3,7 +3,8 @@
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@ @* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
@if (Href is null) @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 @ChildContent
</button> </button>
} }
@@ -27,6 +28,8 @@ else
[Parameter] public string Type { get; set; } = "button"; [Parameter] public string Type { get; set; } = "button";
[Parameter] public string Href { get; set; } [Parameter] public string Href { get; set; }
[Parameter] public EventCallback<MouseEventArgs> OnClick { 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, string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class); Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
+69 -12
View File
@@ -1,19 +1,24 @@
@inherits DComponentBase @inherits DComponentBase
@implements IDisposable
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen): @* 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. 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 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 @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"> @onclick="Toggle">
@Trigger @Trigger
</div> </div>
@if (IsOpen) @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 @ChildContent
</div> </div>
</div> </div>
@@ -31,6 +36,21 @@
[Parameter] public bool CloseOnContentClick { get; set; } [Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; } [Parameter] public string TriggerClass { get; set; }
[Parameter] public string ContentClass { 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, string CssClass => Cx("dropdown", PlacementClass, AlignClass,
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class); IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
@@ -50,17 +70,54 @@
_ => null _ => null
}; };
Task Toggle() => SetOpen(!IsOpen); protected override void OnParametersSet() => TrackOverlay();
Task Close() => SetOpen(false); void TrackOverlay()
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
async Task SetOpen(bool isOpen)
{ {
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; IsOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen); await IsOpenChanged.InvokeAsync(isOpen);
} }
} }
+48 -4
View File
@@ -1,14 +1,32 @@
@inherits DComponentBase @inherits DComponentBase
@implements IDisposable
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen) @* 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 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)" <div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
class="@CssClass" @onkeydown="OnKeyDown"> class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)"> <div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
@if (Heading is not null) @if (Heading is not null || ShowClose)
{ {
<h3 id="@uid" class="text-lg font-bold">@Heading</h3> <div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
@if (Heading is not null)
{
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
}
else
{
<span class="flex-1"></span>
}
@if (ShowClose)
{
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
@onclick="Close" data-modal-close>
<span aria-hidden="true">✕</span>
</button>
}
</div>
} }
@ChildContent @ChildContent
@if (Actions is not null) @if (Actions is not null)
@@ -35,11 +53,17 @@
[Parameter] public DModalPlacement Placement { get; set; } [Parameter] public DModalPlacement Placement { get; set; }
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary> /// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
[Parameter] public bool CloseOnBackdrop { get; set; } = true; [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"; [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; } [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]; readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
IDisposable overlay;
ElementReference box; ElementReference box;
bool wasOpen; bool wasOpen;
bool focusPending; bool focusPending;
@@ -52,6 +76,7 @@
DModalPlacement.Bottom => "modal-bottom", DModalPlacement.Bottom => "modal-bottom",
DModalPlacement.Start => "modal-start", DModalPlacement.Start => "modal-start",
DModalPlacement.End => "modal-end", DModalPlacement.End => "modal-end",
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
_ => null _ => null
}; };
@@ -60,8 +85,26 @@
if (IsOpen && !wasOpen) if (IsOpen && !wasOpen)
focusPending = true; focusPending = true;
wasOpen = IsOpen; 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!focusPending) if (!focusPending)
@@ -78,6 +121,7 @@
{ {
IsOpen = isOpen; IsOpen = isOpen;
wasOpen = isOpen; wasOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen); await IsOpenChanged.InvokeAsync(isOpen);
} }
} }
+3 -1
View File
@@ -7,5 +7,7 @@ public enum DModalPlacement
Top, Top,
Bottom, Bottom,
Start, Start,
End End,
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
ResponsiveBottom
} }
+3 -2
View File
@@ -2,10 +2,11 @@
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and @* 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 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) @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> <table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
</div> </div>
} }
+4 -1
View File
@@ -3,10 +3,13 @@
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound @* 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. *@ 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> @oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
@code { @code {
/// <summary>The textarea itself, for focusing it.</summary>
public ElementReference Element { get; private set; }
[Parameter] public DColor Color { get; set; } [Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; } [Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { 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 RenderFragment ChildContent { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
[Parameter] public string Class { 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() protected override void OnInitialized()
{ {
+1
View File
@@ -12,6 +12,7 @@
var index = i; var index = i;
var tab = tabs[i]; var tab = tabs[i];
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled" <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)" class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
@onclick="() => Select(index)"> @onclick="() => Select(index)">
@tab.Header @tab.Header
+115 -9
View File
@@ -1,18 +1,30 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the @* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
persona's communities and circles, Tag the hashtag being followed. The stats icon shows how the current feed was persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being
read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was
read; the buttons on the right open the composer and, on narrow screens, the globe. *@ 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 flex-col gap-2">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="flex-1 min-w-0 overflow-x-auto"> @* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"> 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) @foreach (var type in Types)
{ {
<DTab Class="gap-1 whitespace-nowrap"> <DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
<Header> <Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i> <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>
}
else if (type is TimelineType.Direct && UnreadConversations > 0)
{
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadConversations]" data-unread-conversations>@UnreadConversations</DBadge>
}
</Header> </Header>
</DTab> </DTab>
} }
@@ -21,10 +33,15 @@
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End"> <NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i> <i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
</NerdTip> </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> <i class="ion-md-planet text-base"></i>
</DButton> </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> <i class="ion-md-create text-base"></i>
<span class="hidden sm:inline">@Localizer["Post"]</span> <span class="hidden sm:inline">@Localizer["Post"]</span>
</DButton> </DButton>
@@ -49,35 +66,116 @@
</DSelect> </DSelect>
} }
} }
else if (Filter.TimelineType is TimelineType.Lists)
{
<div class="flex flex-wrap items-center gap-2">
@if (Lists.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
}
else
{
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.ListId" ValueChanged="SelectList" ValueExpression="() => Filter.ListId">
<Prefix><i class="ion-md-list"></i></Prefix>
<ChildContent>
@foreach (var list in Lists)
{
<option value="@list.Id">@list.Title</option>
}
</ChildContent>
</DSelect>
}
<a class="btn btn-sm" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
</div>
}
else if (Filter.TimelineType is TimelineType.Tag) else if (Filter.TimelineType is TimelineType.Tag)
{ {
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p> <div class="flex flex-wrap items-center gap-2 text-sm">
<i class="ion-md-pricetag"></i><b>#@Filter.Tag</b>
@if (tag is not null)
{
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts]</span>
<DButton Size="DSize.Xs" Color="@(tag.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isFollowing)"
OnClick="ToggleTagFollow" data-tag-follow>
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
</DButton>
}
</div>
} }
</div> </div>
@code { @code {
[Parameter, EditorRequired] public FeedFilter Filter { get; set; } [Parameter, EditorRequired] public FeedFilter Filter { get; set; }
[Parameter] public List<ViewGroup> Groups { get; set; } = []; [Parameter] public List<ViewGroup> Groups { get; set; } = [];
[Parameter] public List<MastodonList> Lists { get; set; } = [];
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
[Parameter] public int UnreadNotifications { get; set; }
/// <summary>How many of the newest direct conversations hold a message the persona has not read.</summary>
[Parameter] public int UnreadConversations { get; set; }
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = []; [Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; } [Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { 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> /// <summary>Raised after Filter changed (tab, group or globe).</summary>
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; } [Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
static readonly TimelineType[] Always = static readonly TimelineType[] Always =
[ [
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct, TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks, TimelineType.Lists
]; ];
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always]; List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
int ActiveIndex => Types.IndexOf(Filter.TimelineType); 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); ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
[Inject] DiscoveryService DiscoveryService { get; set; }
Tag tag;
string loadedTag;
bool isFollowing;
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
// the hashtag being read: its week and whether the persona follows it, read again when it changes
protected override async Task OnParametersSetAsync()
{
var wanted = Filter.TimelineType is TimelineType.Tag ? Filter.Tag : null;
if (wanted == loadedTag)
return;
loadedTag = wanted;
tag = wanted is { Length: > 0 } ? await DiscoveryService.Tag(wanted) : null;
}
async Task ToggleTagFollow()
{
isFollowing = true;
var result = await DiscoveryService.FollowTag(tag.Name, tag.Following != true);
isFollowing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
tag = (Tag)result.Data;
Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]);
}
string TabName(TimelineType type) => type switch string TabName(TimelineType type) => type switch
{ {
TimelineType.Federation => Localizer["Federated"], TimelineType.Federation => Localizer["Federated"],
@@ -90,6 +188,14 @@
Filter.TimelineType = Types[index]; Filter.TimelineType = Types[index];
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null) if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
Filter.GroupId = Groups.FirstOrDefault()?.Id; Filter.GroupId = Groups.FirstOrDefault()?.Id;
if (Filter.TimelineType is TimelineType.Lists && Lists.All(l => l.Id != Filter.ListId))
Filter.ListId = Lists.FirstOrDefault()?.Id;
await OnChanged.InvokeAsync(Filter);
}
async Task SelectList(string listId)
{
Filter.ListId = listId;
await OnChanged.InvokeAsync(Filter); await OnChanged.InvokeAsync(Filter);
} }
+147 -123
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 @* 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 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 was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The 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
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@ module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
<div class="relative w-full h-full"> polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div> above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
<dl id="@($"{uid}-sun")" class="sr-only">
@if (sun is not null)
{
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt>@row.Label</dt>
<dd>@row.Value</dd>
}
}
</dl>
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
@if (stats?.Sun is { } sun) @* the moment the Sun is shown at: now, or the focused post's *@
@if (sun is not null)
{ {
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto"> <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"
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start"> data-globe-moment>
<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"> <i class="@SunIcon(sun)" aria-hidden="true"></i>
<span class="flex items-center gap-1 font-semibold"> <span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
<i class="@SunIcon(sun)"></i> </p>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span> }
</span> else if (error is { Length: > 0 })
@if (sun.Phase is { } phase) {
{ <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>
<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>
} @if (Expanded)
else {
{ <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"
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span> aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
} <i class="ion-md-close" aria-hidden="true"></i>
</div> </button>
</NerdTip> }
</div> else
{
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
</button>
} }
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
}
else
{
<span>@Localizer["loading"]…</span>
}
</span>
</NerdTip>
</div>
</div> </div>
@code { @code {
@@ -61,19 +64,85 @@
/// <summary>The id of the element that scrolls the feed's cards.</summary> /// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; } [Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { 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; ElementReference element;
IJSObjectReference module; IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference; DotNetObjectReference<GlobePanel> reference;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new(); readonly CancellationTokenSource cancellation = new();
string sentFingerprint; int? sentFingerprint;
GlobeStats stats; // the posts are looked at again only when the feed hands them over, never on the panel's own renders
bool postsHandedOver = true;
SunStats sun;
string error;
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
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) 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); await base.OnAfterRenderAsync(firstRender);
try try
{ {
@@ -81,12 +150,17 @@
{ {
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js"); module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
reference = DotNetObjectReference.Create(this); reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName }); {
statsTimer = new(TimeSpan.FromSeconds(1)); Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
_ = ReadStats(cancellation.Token); Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
});
}
if (postsHandedOver)
{
postsHandedOver = false;
await SendPosts();
} }
await SendPosts();
} }
catch (JSDisconnectedException) catch (JSDisconnectedException)
{ {
@@ -105,7 +179,17 @@
var located = Posts.Where(p => p.Location is not null).ToList(); var located = Posts.Where(p => p.Location is not null).ToList();
var self = InstanceService.Known(InstanceService.SelfHost); var self = InstanceService.Known(InstanceService.SelfHost);
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}:{p.Location.CdnKey}")) + $"#{self?.Geo?.Latitude}"; // a hash, not a string of every place: a feed of thousands is looked at on every page it loads
var hash = new HashCode();
foreach (var post in located)
{
hash.Add(post.Key);
hash.Add(post.Location.Latitude);
hash.Add(post.Location.Longitude);
hash.Add(post.Location.CdnKey);
}
hash.Add(self?.Geo?.Latitude);
var fingerprint = hash.ToHashCode();
if (fingerprint == sentFingerprint) if (fingerprint == sentFingerprint)
return; return;
sentFingerprint = fingerprint; sentFingerprint = fingerprint;
@@ -130,7 +214,8 @@
: null; : 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] [JSInvokable]
public async Task SunChanged() public async Task SunChanged()
{ {
@@ -139,7 +224,7 @@
try try
{ {
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token); sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
StateHasChanged(); StateHasChanged();
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -150,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 error = message;
{ StateHasChanged();
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);
} }
IEnumerable<NerdRow> SunRows(SunStats sun) IEnumerable<NerdRow> SunRows(SunStats sun)
@@ -254,9 +309,9 @@
public override async ValueTask DisposeAsync() public override async ValueTask DisposeAsync()
{ {
overlay?.Dispose();
await cancellation.CancelAsync(); await cancellation.CancelAsync();
cancellation.Dispose(); cancellation.Dispose();
statsTimer?.Dispose();
reference?.Dispose(); reference?.Dispose();
try try
{ {
@@ -277,37 +332,6 @@
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host); 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> /// <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 sealed class SunStats
{ {
+59
View File
@@ -0,0 +1,59 @@
@inherits LocalizableComponentBase
@* 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 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)
{
<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">@Localizer["Nobody this server knows of."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var account in accounts)
{
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
@* 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>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] StatusService StatusService { get; set; }
[Parameter, EditorRequired] public string StatusId { get; set; }
/// <summary>Who boosted it, rather than who liked it.</summary>
[Parameter] public bool Boosts { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
List<Account> accounts = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts);
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+75
View File
@@ -0,0 +1,75 @@
@inherits LocalizableComponentBase
@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole
screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow
keys), and the original in a new tab for whoever wants the file. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
<Heading>
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
</Heading>
<ChildContent>
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
<figure class="flex flex-col gap-2 min-h-0">
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
data-blurhash="@Current.Blurhash" />
@if (Current.Description is { Length: > 0 } description)
{
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
}
</figure>
<div class="flex items-center justify-between gap-2">
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
title="@Localizer["Previous picture"]" data-picture-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
}
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-open" aria-hidden="true"></i>
<span>@Localizer["Open original"]</span>
</a>
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
title="@Localizer["Next picture"]" data-picture-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
}
</div>
</div>
</ChildContent>
</DModal>
@code {
/// <summary>The post's pictures, in its order.</summary>
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
/// <summary>The one opened.</summary>
[Parameter] public int Start { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
int index;
MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)];
protected override void OnInitialized()
{
index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1));
IsLoading = false;
}
void Previous() => index = Math.Max(0, index - 1);
void Next() => index = Math.Min(Pictures.Count - 1, index + 1);
void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "ArrowLeft")
Previous();
else if (e.Key == "ArrowRight")
Next();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync();
}
+30
View File
@@ -0,0 +1,30 @@
@inherits LocalizableComponentBase
@* A block of nerd stats: a monospace list of label/value rows under its heading, rows with no value left out; in a
NerdTip's tooltip, in the sheet a tap opens on a touch screen, and in a card's Details on a phone. *@
<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] max-md:text-xs leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>
@code {
[Parameter] public string Heading { get; set; }
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
/// <summary>Shown under the rows while the stats being read on demand arrive.</summary>
[Parameter] public bool Pending { get; set; }
protected override void OnInitialized() => IsLoading = false;
}
+39 -22
View File
@@ -1,10 +1,32 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the @* Nerd stats on hover or focus: a DTooltip whose content is the rows (NerdRows). A touch screen has no hover, so there
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@ a tap opens the same rows in a sheet from the bottom (TapOpens; not for a trigger that is a link itself, the
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open"> pictures). OnOpen runs on the first hover, focus or tap, for stats read on demand (a post's provenance). *@
@ChildContent @if (TapOpens)
</DTooltip> {
@* (on touch the trigger is at least 44px: daisyUI's tooltip draws its arrow with ::after, so padding-free min sizes) *@
<DTooltip Placement="Placement" Align="Align" class="@($"{Class} pointer-coarse:inline-flex pointer-coarse:items-center pointer-coarse:min-h-11 pointer-coarse:min-w-11")"
TipContent="Body" @onmouseenter="Open" @onfocusin="Open" @onclick="Tap"
tabindex="0" role="button" aria-haspopup="dialog" aria-label="@Heading">
@ChildContent
</DTooltip>
}
else
{
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@ChildContent
</DTooltip>
}
@if (isSheetOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isSheetOpen = open" CloseText="@Localizer["Close"]" data-nerd-sheet>
<Heading>@Heading</Heading>
<ChildContent>
<NerdRows Rows="Rows" Pending="Pending" />
</ChildContent>
</DModal>
}
@code { @code {
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
@@ -16,8 +38,19 @@
[Parameter] public DTooltipPlacement Placement { get; set; } [Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; } [Parameter] public DTooltipAlign Align { get; set; }
[Parameter] public string Class { 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 opened;
bool isSheetOpen;
async Task Tap()
{
if (CascadingState.Status.CanHover())
return;
isSheetOpen = true;
await Open();
}
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
@@ -29,21 +62,5 @@
await OnOpen.InvokeAsync(); 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"> RenderFragment Body => @<NerdRows Heading="@Heading" Rows="Rows" Pending="Pending" />;
@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>;
} }
+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;
}
+40
View File
@@ -0,0 +1,40 @@
@inherits LocalizableComponentBase
@implements IDisposable
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
(PublicNoticeService), in MainLayout. *@
<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">
@(Notices.Act == PublicAct.Participation
? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
: Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""])
</p>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="() => Notices.Answer(true)" data-public-notice-yes>@Localizer["Continue"]</DButton>
</Actions>
</DModal>
@code {
[Inject] PublicNoticeService Notices { get; set; }
protected override void OnInitialized()
{
Notices.Changed += Refresh;
IsLoading = false;
}
void Refresh() => InvokeAsync(StateHasChanged);
void Closed(bool open)
{
if (!open)
Notices.Answer(false);
}
public void Dispose() => Notices.Changed -= Refresh;
}
+65
View File
@@ -0,0 +1,65 @@
@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 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">
<DataAnnotationsValidator />
<ErrorViewer AdditionalError="@error" />
<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>
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect>
<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)
{
<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; }
[Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary>
[Parameter] public Status Post { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
ReportForm form = new();
bool isSending;
string error;
// 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;
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);
isSending = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]);
await OnClosed.InvokeAsync();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+76
View File
@@ -0,0 +1,76 @@
@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. *@
<div class="flex flex-col gap-4 w-full" data-account>
<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>
<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">
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.NewPassword" Immediate="true" autocomplete="new-password"
placeholder="@Localizer["New password"]" data-account-new />
<DInput TValue="string" type="password" class="flex-1 min-w-40" @bind-Value="password.RepeatedPassword" Immediate="true" autocomplete="new-password"
placeholder="@Localizer["Repeat the new password"]" data-account-repeat />
</div>
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-password-save>
<i class="ion-md-key"></i><span>@Localizer["Change the password"]</span>
</DButton>
</EditForm>
<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>
<p class="text-sm">@Localizer["Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone."]</p>
<DInput TValue="string" type="password" @bind-Value="removal.Password" Immediate="true" autocomplete="current-password"
placeholder="@Localizer["Current password"]" data-account-delete-password />
<DCheckbox @bind-Value="understood" Size="DSize.Sm" LabelText="@Localizer["I understand: delete this login and every persona of it"]" data-account-delete-understood />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || !understood)" data-account-delete-confirm>
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
</DButton>
</EditForm>
</div>
@code {
[Inject] AuthService AuthService { get; set; }
UserPasswordForm password = new();
RemoveSelfForm removal = new();
bool understood;
bool isSaving;
protected override void OnInitialized() => IsLoading = false;
async Task ChangePassword()
{
isSaving = true;
var result = await AuthService.ChangePassword(password);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
password = new();
Toast.AddResponseSuccess(Localizer["Password changed."]);
}
async Task Delete()
{
isSaving = true;
var result = await AuthService.DeleteAccount(removal);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
}
+156
View File
@@ -0,0 +1,156 @@
@inherits LocalizableComponentBase
@* The persona's follows, lists, blocks, mutes, blocked servers and bookmarks, exported and imported as the CSV files
Mastodon writes, which most fediverse servers read too: a persona moves here from another server, or away, with what
it gathered there. An import acts one row at a time, through PrivaPub, and can be stopped. *@
<div class="flex flex-col gap-5 w-full">
<section class="flex flex-col gap-2" data-export>
<h3 class="font-semibold text-sm">@Localizer["Export"]</h3>
<p class="text-sm opacity-70">@Localizer["Files in Mastodon's format, which most fediverse servers import."]</p>
<div class="flex flex-wrap gap-2">
@foreach (var data in Kinds)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || exporting is not null || importing is not null)" OnClick="() => Export(data)"
data-export-kind="@data">
@if (exporting == data)
{
<DLoading Size="DSize.Xs" />
}
else
{
<i class="ion-md-download"></i>
}
<span>@Name(data)</span>
</DButton>
}
</div>
</section>
<section class="flex flex-col gap-2" data-import>
<h3 class="font-semibold text-sm">@Localizer["Import"]</h3>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["What the file holds"]</span>
<DSelect TValue="PortableData" Size="DSize.Sm" @bind-Value="importKind" disabled="@(importing is not null)" data-import-kind>
@foreach (var data in Kinds)
{
<option value="@data">@Name(data)</option>
}
</DSelect>
</label>
<DFileInput Size="DSize.Sm" Accept=".csv,text/csv" OnChange="Pick" disabled="@(importing is not null)" data-import-file />
@if (importing is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || file is null || exporting is not null)" OnClick="Import"
data-import-start>
<i class="ion-md-cloud-upload"></i><span>@Localizer["Import"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Warning" OnClick="Stop">@Localizer["Stop"]</DButton>
}
</div>
@if (report is not null)
{
<div class="flex flex-col gap-1" data-import-report>
<DProgress class="w-full max-w-md" Value="@(report.Done + report.Failed.Count)" Max="@Math.Max(1, report.Total)" />
<span class="text-sm tabular-nums">
@Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]
</span>
<ErrorViewer AdditionalErrors="report.Failed" />
</div>
}
</section>
</div>
@code {
[Inject] PortabilityService PortabilityService { get; set; }
[Inject] IBlazorDownloadFileService DownloadFile { get; set; }
[Inject] ILoggingService Logger { get; set; }
// an export file of 5000 rows stays well under this
const long MaximumFileSize = 4 * 1024 * 1024;
static readonly PortableData[] Kinds = Enum.GetValues<PortableData>();
PortableData? exporting;
PortableData? importing;
PortableData importKind = PortableData.Follows;
string file;
ImportReport report;
CancellationTokenSource stopping;
protected override void OnInitialized() => IsLoading = false;
string Name(PortableData data) => data switch
{
PortableData.Follows => Localizer["Follows"],
PortableData.Lists => Localizer["Lists"],
PortableData.Blocks => Localizer["Blocked accounts"],
PortableData.Mutes => Localizer["Muted accounts"],
PortableData.ServerBlocks => Localizer["Blocked servers"],
_ => Localizer["Bookmarks"]
};
async Task Export(PortableData data)
{
exporting = data;
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
exporting = null;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
}
async Task Pick(InputFileChangeEventArgs eventArgs)
{
file = null;
report = null;
if (eventArgs.File.Size > MaximumFileSize)
{
Toast.AddWarning(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", eventArgs.File.Name,
eventArgs.File.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
return;
}
try
{
using var reader = new StreamReader(eventArgs.File.OpenReadStream(MaximumFileSize));
file = await reader.ReadToEndAsync();
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(DataTransfer)}.{nameof(Pick)}:{eventArgs.File.Name}");
Toast.AddWarning(Localizer["The file \"{0}\" could not be read.", eventArgs.File.Name]);
}
}
async Task Import()
{
importing = importKind;
stopping = new();
report = new();
var progress = new Progress<ImportReport>(step =>
{
report = step;
StateHasChanged();
});
report = await PortabilityService.Import(importKind, file, progress, stopping.Token);
importing = null;
stopping.Dispose();
stopping = null;
Toast.AddResponseSuccess(Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]);
}
void Stop() => stopping?.Cancel();
public override async ValueTask DisposeAsync()
{
stopping?.Cancel();
await base.DisposeAsync();
}
}
+229
View File
@@ -0,0 +1,229 @@
@inherits LocalizableComponentBase
@* The accounts the current persona muted or blocked (/api/v1/mutes, /api/v1/blocks), and the servers it blocked
(/api/v1/domain_blocks), each one undone here. A server blocked hides everything from it and removes the persona's
followers there. *@
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else
{
<div class="flex flex-col gap-5 w-full">
<section class="flex flex-col gap-2" data-mutes>
<h3 class="font-semibold text-sm">@Localizer["Muted accounts"]</h3>
@if (mutes.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["Nobody is muted."]</p>
}
@foreach (var account in mutes)
{
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
</a>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unmute)">
@Localizer["Unmute"]
</DButton>
</div>
}
@if (mutesMaxId is not null)
{
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreMutes">@Localizer["Load more"]</DButton>
}
</section>
<section class="flex flex-col gap-2" data-blocks>
<h3 class="font-semibold text-sm">@Localizer["Blocked accounts"]</h3>
@if (blocks.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["Nobody is blocked."]</p>
}
@foreach (var account in blocks)
{
<div class="flex items-center gap-2 min-w-0" data-account-id="@account.Id">
<img class="size-8 rounded-full object-cover" src="@account.Avatar" alt="" />
<a class="flex-1 min-w-0 truncate link link-hover" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
<b>@account.Name</b> <span class="text-xs opacity-70">@@@account.Acct</span>
</a>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(account.Id))" OnClick="() => Undo(account, AccountAction.Unblock)">
@Localizer["Unblock"]
</DButton>
</div>
}
@if (blocksMaxId is not null)
{
<DButton Size="DSize.Sm" class="self-start" Disabled="IsDefaultDisabled" OnClick="MoreBlocks">@Localizer["Load more"]</DButton>
}
</section>
<section class="flex flex-col gap-2" data-domain-blocks>
<h3 class="font-semibold text-sm">@Localizer["Blocked servers"]</h3>
<EditForm Model="draft" OnValidSubmit="BlockDomain" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Domain" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["A server, e.g. example.social"]" data-domain-new>
<Prefix><i class="ion-md-globe"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || string.IsNullOrWhiteSpace(draft.Domain) || busy.Contains(Domain(draft.Domain)))">
@Localizer["Block server"]
</DButton>
</EditForm>
@if (domains.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
}
@foreach (var domain in domains)
{
<div class="flex items-center gap-2 min-w-0" data-domain="@domain">
<i class="ion-md-globe opacity-70"></i>
<span class="flex-1 min-w-0 truncate">@domain</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(domain))" OnClick="() => UnblockDomain(domain)">
@Localizer["Unblock"]
</DButton>
</div>
}
</section>
</div>
}
@code {
[Inject] AccountService AccountService { get; set; }
sealed class DomainDraft
{
public string Domain { get; set; }
}
List<Account> mutes = [];
List<Account> blocks = [];
List<string> domains = [];
string mutesMaxId;
string blocksMaxId;
DomainDraft draft = new();
// the accounts and servers whose undo is on its way
HashSet<string> busy = [];
string loadedPersonaId;
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
mutes = [];
blocks = [];
mutesMaxId = null;
blocksMaxId = null;
await Task.WhenAll(MoreMutes(), MoreBlocks());
domains = await AccountService.DomainBlocks();
}
async Task MoreMutes()
{
var result = await AccountService.Mutes(mutesMaxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
mutes.AddRange(page.Accounts.Where(a => mutes.All(m => m.Id != a.Id)));
mutesMaxId = page.MaxId;
}
async Task MoreBlocks()
{
var result = await AccountService.Blocks(blocksMaxId);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (AccountPage)result.Data;
blocks.AddRange(page.Accounts.Where(a => blocks.All(b => b.Id != a.Id)));
blocksMaxId = page.MaxId;
}
async Task Undo(Account account, AccountAction action)
{
busy.Add(account.Id);
var result = await AccountService.Act(account.Id, action);
busy.Remove(account.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
if (action is AccountAction.Unmute)
mutes.RemoveAll(m => m.Id == account.Id);
else
blocks.RemoveAll(b => b.Id == account.Id);
}
// what a person types as a server (a URL, a handle's host, any case) as the bare host PrivaPub keys it by
static string Domain(string typed)
{
var text = typed?.Trim().TrimStart('@') ?? "";
if (Uri.TryCreate(text.Contains("://") ? text : "https://" + text, UriKind.Absolute, out var uri))
text = uri.Host;
return text.ToLowerInvariant();
}
async Task BlockDomain()
{
var domain = Domain(draft.Domain);
if (domain.Length == 0 || domains.Contains(domain))
{
draft = new();
return;
}
busy.Add(domain);
var result = await AccountService.BlockDomain(domain);
busy.Remove(domain);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
draft = new();
domains = [.. domains.Append(domain).Order()];
Toast.AddResponseSuccess(Localizer["{0} is blocked.", domain]);
}
async Task UnblockDomain(string domain)
{
busy.Add(domain);
var result = await AccountService.UnblockDomain(domain);
busy.Remove(domain);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
domains.Remove(domain);
}
}
@@ -0,0 +1,149 @@
@using Microsoft.AspNetCore.Components.Rendering
@inherits LocalizableComponentBase
@* The persona's notification policy (/api/v2/notifications/policy): what notifications from people it does not follow,
people who do not follow it, new accounts, private mentions it did not ask for and accounts this server limits become.
Filtered ones wait in a request per account (/api/v1/notifications/requests), let in (with that account from then
on) or dismissed here. *@
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else
{
<div class="flex flex-col gap-4" data-notification-policy>
<EditForm Model="policy" OnValidSubmit="Save" class="flex flex-col gap-2">
<label class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm">@Localizer["People you don't follow"]</span>
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowing" data-policy="for_not_following">@Actions</DSelect>
</label>
<label class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm">@Localizer["People who don't follow you"]</span>
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowers" data-policy="for_not_followers">@Actions</DSelect>
</label>
<label class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm">@Localizer["New accounts"]</span>
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNewAccounts" data-policy="for_new_accounts">@Actions</DSelect>
</label>
<label class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm">@Localizer["Private mentions you did not ask for"]</span>
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForPrivateMentions" data-policy="for_private_mentions">@Actions</DSelect>
</label>
<label class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm">@Localizer["Accounts this server limits"]</span>
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForLimitedAccounts" data-policy="for_limited_accounts">@Actions</DSelect>
</label>
<p class="text-xs opacity-70">@Localizer["Filtered notifications wait below, one request per account; dropped ones are never kept."]</p>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isSaving)" data-policy-save>
@Localizer["Save"]
</DButton>
</EditForm>
<section class="flex flex-col gap-2" data-notification-requests>
<h3 class="font-semibold text-sm">@Localizer["Filtered notifications"]</h3>
@if (requests.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No filtered notifications."]</p>
}
@foreach (var request in requests)
{
<div class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0" data-request="@request.Account.Acct">
<div class="flex flex-wrap items-center gap-3 min-w-0">
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(request.Account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(request.Account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(request.Account.Name, request.Account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + request.Account.Acct)</span>
</span>
</a>
<DBadge Size="DSize.Sm">@Localizer["{0} notifications", request.NotificationsCount]</DBadge>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
OnClick="@(() => Answer(request, true))" data-request-accept>@Localizer["Let in"]</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
OnClick="@(() => Answer(request, false))" data-request-dismiss>@Localizer["Dismiss"]</DButton>
</div>
@if (request.LastStatus?.Content is { Length: > 0 } content)
{
<div class="post-content text-sm opacity-80 break-words line-clamp-3">@((MarkupString)CustomEmojis.Html(content, request.LastStatus.Emojis))</div>
}
</div>
}
</section>
</div>
}
@code {
[Inject] TimelineService TimelineService { get; set; }
NotificationPolicy policy = new();
// what a notification can become, the same for every rule
void Actions(RenderTreeBuilder __builder)
{
<option value="accept">@Localizer["Accept"]</option>
<option value="filter">@Localizer["Filter"]</option>
<option value="drop">@Localizer["Drop"]</option>
}
List<NotificationRequest> requests = [];
// the requests whose answer is on its way
HashSet<string> busy = [];
bool isSaving;
string loadedPersonaId;
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
policy = await TimelineService.NotificationPolicy() ?? new();
requests = await TimelineService.NotificationRequests();
}
async Task Save()
{
isSaving = true;
var result = await TimelineService.SetNotificationPolicy(policy);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
policy = (NotificationPolicy)result.Data ?? policy;
Toast.AddResponseSuccess(Localizer["Notification policy saved."]);
}
async Task Answer(NotificationRequest request, bool accept)
{
busy.Add(request.Id);
var result = await TimelineService.AnswerNotificationRequest(request.Id, accept);
busy.Remove(request.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
requests.Remove(request);
}
}
@@ -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)
{
}
}
}
+78
View File
@@ -0,0 +1,78 @@
@inherits LocalizableComponentBase
@* The root's personas (/clientapi/avatar/private): each one used in turn, and a new one made. Personas are never linked
to one another: no server, PrivaPub's included, shows that they share a root. *@
<div class="flex flex-col gap-4 w-full" data-personas>
<div class="flex flex-col gap-2">
@foreach (var persona in CascadingState.Personas)
{
<div class="flex items-center gap-2 min-w-0" data-persona="@persona.UserName">
<img class="size-8 rounded-full object-cover" src="@(persona.PictureURL ?? "imgs/icon-192.png")" alt="" />
<span class="flex-1 min-w-0 truncate"><b>@(persona.Name ?? persona.UserName)</b> <span class="text-xs opacity-70">@persona.Handle</span></span>
@if (persona.Id == CascadingState.PersonaId)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["in use"]</DBadge>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || switching is not null)" OnClick="() => Use(persona)">@Localizer["Use"]</DButton>
}
</div>
}
</div>
<EditForm Model="form" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-persona-new>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["A new persona"]</b>
<div class="flex flex-wrap gap-2">
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.UserName" Immediate="true" autocomplete="off"
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-persona-username>
<Prefix>@@</Prefix>
</DInput>
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="form.Name" Immediate="true" autocomplete="off"
maxlength="@PrivaPub.ClientModels.Constants.MaxAvatarNameLength" placeholder="@Localizer["Display name"]" data-persona-name />
</div>
<DTextarea @bind-Value="form.Biography" Immediate="true" rows="3" autocomplete="off" maxlength="@ProfileForm.NoteLength"
placeholder="@Localizer["Biography"]" data-persona-biography />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-persona-create>
<i class="ion-md-person-add"></i><span>@Localizer["Make the persona"]</span>
</DButton>
</EditForm>
</div>
@code {
[Inject] AuthService AuthService { get; set; }
InsertAvatarForm form = new();
bool isSaving;
string switching;
protected override void OnInitialized() => IsLoading = false;
async Task Use(ViewAvatar persona)
{
switching = persona.Id;
var result = await CascadingState.SwitchPersona(persona.Id);
switching = null;
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task Create()
{
isSaving = true;
form.UserName = form.UserName?.Trim().TrimStart('@').ToLowerInvariant();
var result = await AuthService.CreatePersona(form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
form = new();
await CascadingState.ReloadPersonas();
Toast.AddResponseSuccess(Localizer["Persona made."]);
}
}
+257
View File
@@ -0,0 +1,257 @@
@inherits LocalizableComponentBase
@* The current persona's profile (/api/v1/accounts/update_credentials): its pictures, name, biography and fields as it
wrote them, whether it approves followers by hand, is a bot, is listed and searchable, and how its new posts start.
PrivaPub tells the persona's followers' servers of the change. *@
@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 (account is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
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?.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">
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Avatar"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
@if (HasOwn(account.Avatar) && form.Avatar is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
</label>
@if (HasOwn(account.Header) && form.Header is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
</DButton>
}
</div>
</div>
<label class="flex flex-col gap-1 max-w-md">
<span class="text-sm">@Localizer["Display name"]</span>
<DInput TValue="string" @bind-Value="form.DisplayName" Immediate="true" autocomplete="off" maxlength="@ProfileForm.DisplayNameLength"
data-profile-name />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Biography"]</span>
<DTextarea @bind-Value="form.Note" Immediate="true" rows="4" autocomplete="off" maxlength="@ProfileForm.NoteLength" data-profile-note />
<span class="text-xs opacity-70 tabular-nums self-end">@(form.Note?.Length ?? 0)/@ProfileForm.NoteLength</span>
</label>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-semibold pb-1">@Localizer["Profile fields"]</legend>
@foreach (var field in form.Fields)
{
<div class="flex flex-wrap gap-2" data-profile-field="@form.Fields.IndexOf(field)">
<DInput TValue="string" class="flex-1 min-w-36" @bind-Value="field.Name" Immediate="true" autocomplete="off"
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Label"]" />
<DInput TValue="string" class="flex-[2] min-w-48" @bind-Value="field.Value" Immediate="true" autocomplete="off"
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Content"]" />
</div>
}
</fieldset>
<div class="flex flex-col gap-3">
<DToggle LabelText="@Localizer["Approve followers by hand"]" @bind-Value="form.Locked" data-profile-locked />
<DToggle LabelText="@Localizer["This persona is automated (a bot)"]" @bind-Value="form.Bot" />
<DToggle LabelText="@Localizer["List it in the directory and suggest it to others"]" @bind-Value="form.Discoverable" />
<DToggle LabelText="@Localizer["Let its public posts be found by search"]" @bind-Value="form.Indexable" />
</div>
<fieldset class="flex flex-wrap gap-4">
<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>
@foreach (var type in Visibilities)
{
<option value="@type">@Localizer[type.ToString()]</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Language"]</span>
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="form.Language">
<option value="">—</option>
@foreach (var (languageCode, languageName) in Languages.All)
{
<option value="@languageCode">@languageName</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Who may quote them"]</span>
<DSelect TValue="QuotePolicy" Size="DSize.Sm" @bind-Value="form.QuotePolicy">
@foreach (var policy in Enum.GetValues<QuotePolicy>())
{
<option value="@policy">@QuotePolicyName(policy)</option>
}
</DSelect>
</label>
<DToggle class="self-end" LabelText="@Localizer["Mark media as sensitive"]" @bind-Value="form.Sensitive" />
</fieldset>
<ErrorViewer AdditionalErrors="pictureErrors" />
<div class="flex flex-wrap gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving)">@Localizer["Reset"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-profile-save>
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
}
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] ILoggingService Logger { get; set; }
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.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()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
// 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();
}
void Reset()
{
pictureErrors = [];
form = account is null ? new() : ProfileForm.Of(account);
}
string QuotePolicyName(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => Localizer["Its followers"],
QuotePolicy.Nobody => Localizer["Nobody"],
_ => Localizer["Anyone"]
};
async Task Pick(InputFileChangeEventArgs eventArgs, bool isAvatar)
{
pictureErrors = [];
var file = eventArgs.File;
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), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
return;
}
// the file is sent as it is when saving, and is the form's at once (a save meanwhile sends it); only a small picture
// of it is kept to show it, made afterwards, and PrivaPub decides about a file the browser can't draw
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
if (isAvatar)
form.Avatar = picture;
else
form.Header = picture;
try
{
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.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;
var result = await AccountService.UpdateProfile(form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
account = (Account)result.Data;
CascadingState.PersonaChanged(account);
Reset();
Toast.AddResponseSuccess(Localizer["Profile saved."]);
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@ @* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
@if (Toast.Messages.Count > 0) @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) @foreach (var message in Toast.Messages)
{ {
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm"> <DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
+10
View File
@@ -57,6 +57,15 @@ public static class GenericExtensions
}; };
} }
// a count as a phone's narrow action buttons show it: 999, 1.2k, 12k, 1.2M
public static string Compact(this int count) => count switch
{
< 1000 => count.ToString(CultureInfo.CurrentCulture),
< 10_000 => (count / 1000d).ToString("0.#", CultureInfo.CurrentCulture) + "k",
< 1_000_000 => (count / 1000).ToString(CultureInfo.CurrentCulture) + "k",
_ => (count / 1_000_000d).ToString("0.#", CultureInfo.CurrentCulture) + "M"
};
public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
{ {
>= 1_048_576 => localizer["{0}MB", size / 1_048_576], >= 1_048_576 => localizer["{0}MB", size / 1_048_576],
@@ -92,6 +101,7 @@ public static class GenericExtensions
TimelineType.Favourites => "ion-md-heart", TimelineType.Favourites => "ion-md-heart",
TimelineType.Bookmarks => "ion-md-bookmark", TimelineType.Bookmarks => "ion-md-bookmark",
TimelineType.Tag => "ion-md-pricetag", TimelineType.Tag => "ion-md-pricetag",
TimelineType.Lists => "ion-md-list",
_ => default _ => default
}; };
+47
View File
@@ -0,0 +1,47 @@
using System.Net;
using System.Text.RegularExpressions;
using decePubClient.Models.Mastodon;
using Microsoft.AspNetCore.Components;
namespace decePubClient.Helpers
{
/// <summary>
/// A post's or an account's custom emoji (`:shortcode:` among its <c>emojis</c>) shown as their pictures. The shortcode
/// stays as the picture's alt text, so it is what a screen reader reads and what is copied. Only shortcodes the
/// server listed for that post or account are replaced, and only with an http(s) picture.
/// </summary>
public static partial class CustomEmojis
{
[GeneratedRegex(@":([A-Za-z0-9_+-]{1,64}):")]
private static partial Regex Shortcode();
[GeneratedRegex(@"(<[^>]*>)")]
private static partial Regex Tag();
/// <summary>Plain text (a name, a content warning): escaped, then its shortcodes as pictures.</summary>
public static MarkupString Text(string text, IReadOnlyCollection<CustomEmoji> emojis) =>
new(Replace(WebUtility.HtmlEncode(text ?? string.Empty), emojis));
/// <summary>HTML the server sanitized: the shortcodes in its text, never inside a tag.</summary>
public static string Html(string html, IReadOnlyCollection<CustomEmoji> emojis) =>
emojis.Count == 0 || string.IsNullOrEmpty(html)
? html
: string.Concat(Tag().Split(html).Select(part => part.StartsWith('<') ? part : Replace(part, emojis)));
static string Replace(string text, IReadOnlyCollection<CustomEmoji> emojis)
{
if (emojis.Count == 0 || !text.Contains(':'))
return text;
var known = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var emoji in emojis)
if (!string.IsNullOrEmpty(emoji.Shortcode) && Uri.TryCreate(emoji.Url, UriKind.Absolute, out var url)
&& url.Scheme is "https" or "http")
known.TryAdd(emoji.Shortcode, url.AbsoluteUri);
return Shortcode().Replace(text, match => known.TryGetValue(match.Groups[1].Value, out var url)
? $"<img class=\"custom-emoji\" src=\"{WebUtility.HtmlEncode(url)}\" alt=\"{match.Value}\" title=\"{match.Value}\" draggable=\"false\" />"
: match.Value);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace decePubClient.Helpers
{
/// <summary>The languages a post can be marked as written in: ISO 639-1 codes and each language's own name.</summary>
public static class Languages
{
public static readonly IReadOnlyList<(string Code, string Name)> All =
[
("ar", "العربية"), ("bg", "Български"), ("ca", "Català"), ("cs", "Čeština"), ("cy", "Cymraeg"), ("da", "Dansk"),
("de", "Deutsch"), ("el", "Ελληνικά"), ("en", "English"), ("eo", "Esperanto"), ("es", "Español"), ("et", "Eesti"),
("eu", "Euskara"), ("fa", "فارسی"), ("fi", "Suomi"), ("fr", "Français"), ("ga", "Gaeilge"), ("gl", "Galego"),
("he", "עברית"), ("hi", "हिन्दी"), ("hr", "Hrvatski"), ("hu", "Magyar"), ("id", "Bahasa Indonesia"), ("is", "Íslenska"),
("it", "Italiano"), ("ja", "日本語"), ("ko", "한국어"), ("lt", "Lietuvių"), ("lv", "Latviešu"), ("nl", "Nederlands"),
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"), ("ru", "Русский"), ("sk", "Slovenčina"),
("sl", "Slovenščina"), ("sr", "Српски"), ("sv", "Svenska"), ("th", "ไทย"), ("tr", "Türkçe"), ("uk", "Українська"),
("vi", "Tiếng Việt"), ("zh", "中文")
];
}
}
+31
View File
@@ -27,6 +27,8 @@
public PublicCacheData PublicCacheData { get; private set; } = new(); public PublicCacheData PublicCacheData { get; private set; } = new();
/// <summary>Whether a root is signed in (its JWT is still valid).</summary> /// <summary>Whether a root is signed in (its JWT is still valid).</summary>
public bool IsSignedIn { get; private set; } 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> /// <summary>The root's login name, shown to its owner only.</summary>
public string RootUserName { get; private set; } public string RootUserName { get; private set; }
/// <summary>The root's personas.</summary> /// <summary>The root's personas.</summary>
@@ -93,6 +95,7 @@
if (!IsSignedIn) if (!IsSignedIn)
{ {
ClearSession(); ClearSession();
IsSessionLoaded = true;
StateHasChanged(); StateHasChanged();
return; return;
} }
@@ -105,11 +108,16 @@
{ {
PersonaId = null; PersonaId = null;
Persona = null; Persona = null;
IsSessionLoaded = true;
StateHasChanged(); StateHasChanged();
return; 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); await SwitchPersona(personaId, cancellationToken);
IsSessionLoaded = true;
StateHasChanged();
} }
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary> /// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
@@ -127,6 +135,29 @@
return result; return result;
} }
/// <summary>Reads the root's personas again, after one was made.</summary>
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
{
Personas = await AuthService.Personas(cancellationToken);
StateHasChanged();
}
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
/// chooser shows the new name and picture too.</summary>
public void PersonaChanged(Account account)
{
if (account is null || PersonaId is null)
return;
Persona = account;
if (Personas.FirstOrDefault(p => p.Id == PersonaId) is { } persona)
{
persona.Name = account.DisplayName;
persona.PictureURL = account.Avatar;
}
StateHasChanged();
}
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary> /// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default) public async Task SignOut(CancellationToken cancellationToken = default)
{ {
+28
View File
@@ -17,7 +17,11 @@ namespace decePubClient;
[JsonSerializable(typeof(Account))] [JsonSerializable(typeof(Account))]
[JsonSerializable(typeof(List<Account>))] [JsonSerializable(typeof(List<Account>))]
[JsonSerializable(typeof(Relationship))] [JsonSerializable(typeof(Relationship))]
[JsonSerializable(typeof(List<Relationship>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(SearchResults))]
[JsonSerializable(typeof(MediaAttachment))] [JsonSerializable(typeof(MediaAttachment))]
[JsonSerializable(typeof(InstanceV2))]
[JsonSerializable(typeof(Notification))] [JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(List<Notification>))] [JsonSerializable(typeof(List<Notification>))]
[JsonSerializable(typeof(Conversation))] [JsonSerializable(typeof(Conversation))]
@@ -31,6 +35,30 @@ namespace decePubClient;
[JsonSerializable(typeof(TokenResponse))] [JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(MastodonError))] [JsonSerializable(typeof(MastodonError))]
[JsonSerializable(typeof(PostStatusForm))] [JsonSerializable(typeof(PostStatusForm))]
[JsonSerializable(typeof(StatusEditForm))]
[JsonSerializable(typeof(StatusSource))]
[JsonSerializable(typeof(List<StatusEdit>))]
[JsonSerializable(typeof(ReportRequest))]
[JsonSerializable(typeof(NotificationPolicy))]
[JsonSerializable(typeof(List<NotificationRequest>))]
[JsonSerializable(typeof(Poll))]
[JsonSerializable(typeof(PollVoteForm))]
[JsonSerializable(typeof(MastodonList))]
[JsonSerializable(typeof(List<MastodonList>))]
[JsonSerializable(typeof(ListAccountsForm))]
[JsonSerializable(typeof(Filter))]
[JsonSerializable(typeof(List<Filter>))]
[JsonSerializable(typeof(FilterForm))]
[JsonSerializable(typeof(FilterStatus))]
[JsonSerializable(typeof(ScheduledStatus))]
[JsonSerializable(typeof(List<ScheduledStatus>))]
[JsonSerializable(typeof(ScheduleForm))]
[JsonSerializable(typeof(Tag))]
[JsonSerializable(typeof(List<Tag>))]
[JsonSerializable(typeof(List<TrendLink>))]
[JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))]
[JsonSerializable(typeof(UnreadCount))]
public partial class MastodonJsonContext : JsonSerializerContext public partial class MastodonJsonContext : JsonSerializerContext
{ {
} }
+99
View File
@@ -16,12 +16,49 @@ public static class APIs
public const string POST_Login = "clientapi/user/login"; public const string POST_Login = "clientapi/user/login";
public const string GET_Logout = "clientapi/user/logout"; public const string GET_Logout = "clientapi/user/logout";
public const string GET_SniffAgain = "clientapi/user/sniff/again"; public const string GET_SniffAgain = "clientapi/user/sniff/again";
public const string POST_UpdatePassword = "clientapi/user/update/password";
public const string POST_DeleteAccount = "clientapi/user/delete";
public const string GET_CurrentVersion = "clientapi/data/current-version"; public const string GET_CurrentVersion = "clientapi/data/current-version";
public const string GET_Personas = "clientapi/avatar/private/list"; 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"; public const string GET_Groups = "clientapi/group/list";
public const string GET_GroupMembers = "clientapi/group/members";
public const string POST_InsertGroup = "clientapi/group/insert";
public const string POST_UpdateGroup = "clientapi/group/update";
public const string POST_JoinGroup = "clientapi/group/join";
public const string POST_LeaveGroup = "clientapi/group/leave";
public const string POST_ApproveGroupMember = "clientapi/group/approve";
public const string POST_RejectGroupMember = "clientapi/group/reject";
public const string POST_RemoveGroupMember = "clientapi/group/remove";
public const string GET_Posts = "clientapi/post/list"; public const string GET_Posts = "clientapi/post/list";
public const string GET_Nearby = "clientapi/post/nearby"; public const string GET_Nearby = "clientapi/post/nearby";
public const string POST_InsertPost = "clientapi/post/insert"; public const string POST_InsertPost = "clientapi/post/insert";
public const string POST_DeletePost = "clientapi/post/delete";
public const string GET_ModeratorReports = "clientapi/moderator/reports";
public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve";
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
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 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 public static class OAuth
@@ -36,6 +73,12 @@ public static class APIs
public static class Mastodon public static class Mastodon
{ {
public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials"; public const string GET_VerifyCredentials = "api/v1/accounts/verify_credentials";
public const string PATCH_UpdateCredentials = "api/v1/accounts/update_credentials";
public const string GET_Mutes = "api/v1/mutes";
public const string GET_Blocks = "api/v1/blocks";
public const string GET_DomainBlocks = "api/v1/domain_blocks";
public const string POST_DomainBlocks = "api/v1/domain_blocks";
public const string DELETE_DomainBlocks = "api/v1/domain_blocks";
public const string GET_HomeTimeline = "api/v1/timelines/home"; public const string GET_HomeTimeline = "api/v1/timelines/home";
public const string GET_PublicTimeline = "api/v1/timelines/public"; public const string GET_PublicTimeline = "api/v1/timelines/public";
public const string GET_Statuses = "api/v1/statuses"; public const string GET_Statuses = "api/v1/statuses";
@@ -43,17 +86,71 @@ public static class APIs
public const string GET_Favourites = "api/v1/favourites"; public const string GET_Favourites = "api/v1/favourites";
public const string GET_Bookmarks = "api/v1/bookmarks"; public const string GET_Bookmarks = "api/v1/bookmarks";
public const string GET_Conversations = "api/v1/conversations"; 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 GET_Notifications = "api/v1/notifications";
public const string POST_Media = "api/v2/media"; public const string POST_Media = "api/v2/media";
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
public const string GET_InstanceV2 = "api/v2/instance";
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
public const string DELETE_ProfileHeader = "api/v1/profile/header";
public const string GET_AccountLookup = "api/v1/accounts/lookup";
public const string GET_Relationships = "api/v1/accounts/relationships";
public const string GET_FollowRequests = "api/v1/follow_requests";
public const string GET_Search = "api/v2/search";
public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}"; public static string GET_M_TagTimeline(string tag) => $"api/v1/timelines/tag/{Uri.EscapeDataString(tag)}";
public static string GET_M_Account(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}"; 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_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_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; 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 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)}"; public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}"; public static string POST_M_StatusAction(string statusId, string action) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/{action}";
public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}"; public static string POST_M_AccountAction(string accountId, string action) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/{action}";
public static string M_Reaction(string statusId, string emoji) =>
$"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}";
public static string POST_M_PollVotes(string pollId) => $"api/v1/polls/{Uri.EscapeDataString(pollId)}/votes";
public static string POST_M_FollowRequest(string accountId, string answer) => $"api/v1/follow_requests/{Uri.EscapeDataString(accountId)}/{answer}";
public const string GET_Lists = "api/v1/lists";
public const string POST_List = "api/v1/lists";
public const string GET_AccountSearch = "api/v1/accounts/search";
public static string PUT_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
public static string DELETE_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}";
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source";
public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
public const string POST_Reports = "api/v1/reports";
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
public const string GET_NotificationRequests = "api/v1/notifications/requests";
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 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 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";
public const string GET_TrendingLinks = "api/v1/trends/links";
public const string GET_Directory = "api/v1/directory";
public static string GET_M_Tag(string name) => $"api/v1/tags/{Uri.EscapeDataString(name)}";
public static string POST_M_TagAction(string name, string action) => $"api/v1/tags/{Uri.EscapeDataString(name)}/{action}";
public static string PUT_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
public static string DELETE_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}";
public const string GET_Filters = "api/v2/filters";
public const string POST_Filter = "api/v2/filters";
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
public static string DELETE_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
public static string POST_M_FilterStatus(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}/statuses";
public static string DELETE_M_FilterStatus(string filterStatusId) => $"api/v2/filters/statuses/{Uri.EscapeDataString(filterStatusId)}";
} }
public static class PrivaPub public static class PrivaPub
@@ -65,5 +162,7 @@ public static class APIs
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}"; public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance"; public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
public static string POST_M_Join(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/join";
public static string POST_M_Leave(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/leave";
} }
} }
+22
View File
@@ -24,6 +24,8 @@ public class ComposeForm
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))] [Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
public MessageType MessageType { get; set; } = MessageType.Public; public MessageType MessageType { get; set; } = MessageType.Public;
public bool HasContentWarning { get; set; } 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))] [StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string SpoilerText { get; set; } public string SpoilerText { get; set; }
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))] [StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
@@ -37,6 +39,26 @@ public class ComposeForm
public double? Latitude { get; set; } public double? Latitude { get; set; }
public double? Longitude { get; set; } public double? Longitude { get; set; }
/// <summary>A poll instead of media: two to four choices, open ExpiresIn seconds, one choice or several.</summary>
public bool HasPoll { get; set; }
public List<string> PollOptions { get; set; } = ["", ""];
public int PollExpiresIn { get; set; } = 86400;
public bool PollMultiple { get; set; }
/// <summary>The language the post is written in (ISO 639-1); null leaves it to PrivaPub.</summary>
public string Language { get; set; }
/// <summary>The post this one quotes.</summary>
public string QuotedStatusId { get; set; }
/// <summary>Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now.</summary>
public bool IsScheduled { get; set; }
public string ScheduledAt { get; set; }
/// <summary>When the post is to be published, in UTC, or null when it goes now or ScheduledAt is not a time.</summary>
public DateTime? ScheduledAtUtc => IsScheduled && DateTime.TryParse(ScheduledAt, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeLocal, out var local) ? local.ToUniversalTime() : null;
[JsonIgnore, Bindable(false)] [JsonIgnore, Bindable(false)]
public MediaType MediaType { get; set; } = MediaType.Images; public MediaType MediaType { get; set; } = MediaType.Images;
[JsonIgnore, Bindable(false)] [JsonIgnore, Bindable(false)]
+2
View File
@@ -8,6 +8,8 @@ public class FeedFilter
public TimelineType TimelineType { get; set; } = TimelineType.Home; public TimelineType TimelineType { get; set; } = TimelineType.Home;
/// <summary>The community or circle the Groups tab shows.</summary> /// <summary>The community or circle the Groups tab shows.</summary>
public string GroupId { get; set; } public string GroupId { get; set; }
/// <summary>The persona's list the Lists tab shows.</summary>
public string ListId { get; set; }
/// <summary>The hashtag the Tag tab shows, without #.</summary> /// <summary>The hashtag the Tag tab shows, without #.</summary>
public string Tag { get; set; } public string Tag { get; set; }
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary> /// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
+33 -3
View File
@@ -17,7 +17,11 @@ public class FeedPost
/// <summary>The post shown; null for a notification without one (a follow).</summary> /// <summary>The post shown; null for a notification without one (a follow).</summary>
public Status Status { get; set; } public Status Status { get; set; }
public Account BoostedBy { get; init; } public Account BoostedBy { get; init; }
public Notification Notification { get; init; } public Notification Notification { get; set; }
/// <summary>For a notification standing for a group (likes of one post, follows of one hour): how many, and a few of
/// the accounts, newest first.</summary>
public int GroupCount { get; set; } = 1;
public List<Account> GroupAccounts { get; set; } = [];
public Conversation Conversation { get; init; } public Conversation Conversation { get; init; }
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary> /// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
public bool Located { get; init; } public bool Located { get; init; }
@@ -43,12 +47,38 @@ public class FeedPost
public static FeedPost Of(Notification notification) => new() public static FeedPost Of(Notification notification) => new()
{ {
Key = notification.Id, Key = notification.GroupKey ?? notification.Id,
CursorId = notification.Id, CursorId = notification.Id,
Status = notification.Status?.Reblog ?? notification.Status, Status = notification.Status?.Reblog ?? notification.Status,
Notification = notification Notification = notification,
GroupAccounts = notification.Account is null ? [] : [notification.Account]
}; };
/// <summary>A group of notifications as one item: its newest notification, how many it holds, and a few accounts.</summary>
public static FeedPost Of(NotificationGroup group, IReadOnlyDictionary<string, Account> accounts, IReadOnlyDictionary<string, Status> statuses)
{
var sample = group.SampleAccountIds.Select(accounts.GetValueOrDefault).Where(a => a is not null).ToList();
var status = group.StatusId is null ? null : statuses.GetValueOrDefault(group.StatusId);
return new()
{
Key = group.GroupKey,
CursorId = group.MostRecentNotificationId,
Status = status?.Reblog ?? status,
Notification = new Notification
{
Id = group.MostRecentNotificationId,
Type = group.Type,
CreatedAt = group.LatestPageNotificationAt,
Account = sample.FirstOrDefault(),
Status = status,
Emoji = group.Emoji,
GroupKey = group.GroupKey
},
GroupCount = Math.Max(1, group.NotificationsCount),
GroupAccounts = sample
};
}
public static FeedPost Of(Conversation conversation) => new() public static FeedPost Of(Conversation conversation) => new()
{ {
Key = conversation.Id, Key = conversation.Id,
+44
View File
@@ -25,6 +25,15 @@ public class Account
public string LastStatusAt { get; set; } public string LastStatusAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = []; public List<CustomEmoji> Emojis { get; set; } = [];
public List<AccountField> Fields { get; set; } = []; public List<AccountField> Fields { get; set; } = [];
/// <summary>The account it moved to (PrivaPub shows it once the new account names this one among its aliases).</summary>
public Account Moved { get; set; }
/// <summary>Whether it is listed in the directory and suggested; null when its server does not say.</summary>
public bool? Discoverable { get; set; }
/// <summary>Whether its public posts may be found by full-text search.</summary>
public bool? Indexable { get; set; }
/// <summary>The persona's own account only (verify_credentials, update_credentials): its profile as written and its
/// posting defaults.</summary>
public AccountSource Source { get; set; }
/// <summary>The account's server: the host of its ActivityPub id.</summary> /// <summary>The account's server: the host of its ActivityPub id.</summary>
public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default; public string Host => System.Uri.TryCreate(Uri, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default;
@@ -33,6 +42,27 @@ public class Account
public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName; public string Name => string.IsNullOrWhiteSpace(DisplayName) ? Username : DisplayName;
} }
public class AccountSource
{
/// <summary>The biography as written, before PrivaPub renders it.</summary>
public string Note { get; set; }
public List<AccountField> Fields { get; set; } = [];
/// <summary>The default visibility of a new post: public, unlisted or private.</summary>
public string Privacy { get; set; }
public bool Sensitive { get; set; }
public string Language { get; set; }
/// <summary>Who may quote the persona's posts: public, followers or nobody.</summary>
public string QuotePolicy { get; set; }
public int FollowRequestsCount { get; set; }
}
/// <summary>A page of accounts (mutes, blocks), and the cursor of the next one: null at the end.</summary>
public class AccountPage
{
public List<Account> Accounts { get; set; } = [];
public string MaxId { get; set; }
}
public class AccountField public class AccountField
{ {
public string Name { get; set; } public string Name { get; set; }
@@ -53,6 +83,20 @@ public class Relationship
public bool Following { get; set; } public bool Following { get; set; }
public bool FollowedBy { get; set; } public bool FollowedBy { get; set; }
public bool Blocking { get; set; } public bool Blocking { get; set; }
public bool BlockedBy { get; set; }
public bool Muting { get; set; } public bool Muting { get; set; }
public bool MutingNotifications { get; set; }
public bool ShowingReblogs { get; set; }
public bool Notifying { get; set; }
public bool Requested { get; set; } public bool Requested { get; set; }
public bool RequestedBy { get; set; }
public bool DomainBlocking { get; set; }
}
/// <summary>/api/v2/search: what matched, a remote handle or URL resolved by PrivaPub.</summary>
public class SearchResults
{
public List<Account> Accounts { get; set; } = [];
public List<Status> Statuses { get; set; } = [];
public List<StatusTag> Hashtags { get; set; } = [];
} }
+33
View File
@@ -0,0 +1,33 @@
namespace decePubClient.Models.Mastodon;
/// <summary>A hashtag as PrivaPub describes it: its last week of use in public posts it holds, and whether the persona
/// follows it (null when nobody is signed in).</summary>
public class Tag
{
public string Name { get; set; }
public string Url { get; set; }
public List<TagDay> History { get; set; } = [];
public bool? Following { get; set; }
public int WeekUses => History.Sum(h => h.UsesCount);
public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0;
}
/// <summary>One day of a tag's or a link's use; Mastodon sends the numbers as strings, the day as unix time.</summary>
public class TagDay
{
public string Day { get; set; }
public string Uses { get; set; }
public string Accounts { get; set; }
public int UsesCount => int.TryParse(Uses, out var n) ? n : 0;
public int AccountsCount => int.TryParse(Accounts, out var n) ? n : 0;
}
/// <summary>A link many authors shared this week: its preview card with its days.</summary>
public class TrendLink : PreviewCard
{
public List<TagDay> History { get; set; } = [];
public int WeekAccounts => History.Sum(h => h.AccountsCount);
}
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
namespace decePubClient.Models.Mastodon;
/// <summary>A persona's filter (Mastodon's v2 filters): words and posts it would rather not see in some contexts, to be
/// warned about, hidden or blurred. Statuses carry the filters they match (Status.Filtered); the client applies the
/// context and the action.</summary>
public class Filter
{
public string Id { get; set; }
public string Title { get; set; }
public List<FilterContext> Context { get; set; } = [];
public DateTime? ExpiresAt { get; set; }
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
public List<FilterKeyword> Keywords { get; set; } = [];
public List<FilterStatus> Statuses { get; set; } = [];
public bool IsActive => ExpiresAt is null || ExpiresAt > DateTime.UtcNow;
}
public class FilterKeyword
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
}
public class FilterStatus
{
public string Id { get; set; }
public string StatusId { get; set; }
}
public class FilterResult
{
public Filter Filter { get; set; }
public List<string> KeywordMatches { get; set; } = [];
public List<string> StatusMatches { get; set; } = [];
}
/// <summary>Where a filter applies; a card is told which of these it is shown in.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<FilterContext>))]
public enum FilterContext
{
[JsonStringEnumMemberName("home")] Home,
[JsonStringEnumMemberName("notifications")] Notifications,
[JsonStringEnumMemberName("public")] Public,
[JsonStringEnumMemberName("thread")] Thread,
[JsonStringEnumMemberName("account")] Account
}
[JsonConverter(typeof(JsonStringEnumConverter<FilterAction>))]
public enum FilterAction
{
/// <summary>The post is folded behind the filter's title.</summary>
[JsonStringEnumMemberName("warn")] Warn,
/// <summary>The post is not shown at all.</summary>
[JsonStringEnumMemberName("hide")] Hide,
/// <summary>The post's media are blurred, as sensitive media are.</summary>
[JsonStringEnumMemberName("blur")] Blur
}
/// <summary>What making or changing a filter sends; ExpiresIn 0 means never.</summary>
public class FilterForm
{
public string Title { get; set; }
public List<FilterContext> Context { get; set; } = [];
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
public int? ExpiresIn { get; set; }
public List<FilterKeywordForm> KeywordsAttributes { get; set; } = [];
}
public class FilterKeywordForm
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
[JsonPropertyName("_destroy")]
public bool? Destroy { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
namespace decePubClient.Models.Mastodon;
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
public class InstanceV2
{
public InstanceConfiguration Configuration { get; set; } = new();
}
public class InstanceConfiguration
{
public MediaConfiguration MediaAttachments { get; set; } = new();
}
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
public class MediaConfiguration
{
public List<string> SupportedMimeTypes { get; set; } = [];
public long ImageSizeLimit { get; set; }
public long VideoSizeLimit { get; set; }
public long ImageMatrixLimit { get; set; }
public long VideoMatrixLimit { get; set; }
public int VideoFrameRateLimit { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace decePubClient.Models.Mastodon;
/// <summary>A persona's list of accounts it follows (Mastodon's List): read as its own feed, and when exclusive kept out of
/// the home feed. Lists are the persona's own; PrivaPub never federates them.</summary>
public class MastodonList
{
public string Id { get; set; }
public string Title { get; set; }
public ListRepliesPolicy RepliesPolicy { get; set; } = ListRepliesPolicy.List;
public bool Exclusive { get; set; }
}
/// <summary>Which replies a list's feed shows besides its members' replies to themselves and to the persona.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ListRepliesPolicy>))]
public enum ListRepliesPolicy
{
/// <summary>Replies to any account the persona follows.</summary>
[JsonStringEnumMemberName("followed")] Followed,
/// <summary>Replies to the list's members.</summary>
[JsonStringEnumMemberName("list")] List,
[JsonStringEnumMemberName("none")] None
}
public class ListAccountsForm
{
public List<string> AccountIds { get; set; } = [];
}
+39
View File
@@ -12,6 +12,45 @@ public class Notification
public Account Account { get; set; } public Account Account { get; set; }
public Status Status { get; set; } public Status Status { get; set; }
public string Emoji { get; set; } public string Emoji { get; set; }
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
public string GroupKey { get; set; }
}
/// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary>
public class GroupedNotifications
{
public List<Account> Accounts { get; set; } = [];
public List<Status> Statuses { get; set; } = [];
public List<NotificationGroup> NotificationGroups { get; set; } = [];
}
public class NotificationGroup
{
public string GroupKey { get; set; }
public int NotificationsCount { get; set; }
public string Type { get; set; }
public string MostRecentNotificationId { get; set; }
public string PageMinId { get; set; }
public string PageMaxId { get; set; }
public DateTime LatestPageNotificationAt { get; set; }
public List<string> SampleAccountIds { get; set; } = [];
public string StatusId { get; set; }
public string Emoji { get; set; }
}
public class MarkersForm
{
public MarkerForm Notifications { get; set; }
}
public class MarkerForm
{
public string LastReadId { get; set; }
}
public class UnreadCount
{
public int Count { get; set; }
} }
/// <summary>A direct conversation: its participants and its last post.</summary> /// <summary>A direct conversation: its participants and its last post.</summary>
+113
View File
@@ -176,4 +176,117 @@ public class PostStatusForm
public string SpoilerText { get; set; } public string SpoilerText { get; set; }
public string Visibility { get; set; } public string Visibility { get; set; }
public string Language { get; set; } public string Language { get; set; }
/// <summary>When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now.</summary>
public string ScheduledAt { get; set; }
public PollForm Poll { get; set; }
public string QuotedStatusId { get; set; }
}
public class PollForm
{
public List<string> Options { get; set; } = [];
public int ExpiresIn { get; set; }
public bool Multiple { get; set; }
}
/// <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>
public class StatusSource
{
public string Id { get; set; }
public string Text { get; set; }
public string SpoilerText { get; set; }
}
/// <summary>/api/v2/notifications/policy: what notifications from people the persona does not follow, people who do not
/// follow it, new accounts, unasked private mentions and limited accounts become: accept, filter or drop.</summary>
public class NotificationPolicy
{
public string ForNotFollowing { get; set; }
public string ForNotFollowers { get; set; }
public string ForNewAccounts { get; set; }
public string ForPrivateMentions { get; set; }
public string ForLimitedAccounts { get; set; }
public NotificationPolicySummary Summary { get; set; }
}
public class NotificationPolicySummary
{
public int PendingRequestsCount { get; set; }
public int PendingNotificationsCount { get; set; }
}
/// <summary>/api/v1/notifications/requests: the notifications the policy filtered from one account, waiting to be let in
/// (and that account from then on) or dismissed.</summary>
public class NotificationRequest
{
public string Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>A number, as a string (Mastodon's).</summary>
public string NotificationsCount { get; set; }
public Account Account { get; set; }
public Status LastStatus { get; set; }
}
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
/// a remote account's server gets it too, from the server's own actor.</summary>
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>
public string Category { get; set; }
public bool Forward { get; set; }
}
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
public class StatusEdit
{
public string Content { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public DateTime CreatedAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
}
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
public class ScheduledStatus
{
public string Id { get; set; }
public DateTime ScheduledAt { get; set; }
public ScheduledParams Params { get; set; } = new();
public List<MediaAttachment> MediaAttachments { get; set; } = [];
}
public class ScheduledParams
{
public string Text { get; set; }
public string SpoilerText { get; set; }
public string Visibility { get; set; }
public string InReplyToId { get; set; }
}
public class ScheduleForm
{
public string ScheduledAt { get; set; }
} }
+11
View File
@@ -5,6 +5,11 @@ public class PrivaPubStatus
{ {
/// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary> /// <summary>The ActivityPub type of a remote object: Note, Article, Page, Video, Audio, Event, Question…</summary>
public string ObjectType { get; set; } public string ObjectType { get; set; }
/// <summary>Its community's moderators locked it: PrivaPub refuses a reply.</summary>
public bool Locked { get; set; }
/// <summary>The persona's reply or boost of a post whose author must approve it first (GoToSocial's interaction
/// policies): "pending" until the author answers, "rejected" if they refuse; null otherwise.</summary>
public string Approval { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Excerpt { get; set; } public string Excerpt { get; set; }
public string Cover { get; set; } public string Cover { get; set; }
@@ -16,6 +21,8 @@ public class PrivaPubStatus
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary> /// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
public PrivaPubPlace Place { get; set; } public PrivaPubPlace Place { get; set; }
public PrivaPubVotes Votes { get; set; } public PrivaPubVotes Votes { get; set; }
/// <summary>Whose wall it was written on (Smithereen's walls, FEP-400e), when not its author's own.</summary>
public Account Wall { get; set; }
} }
public class PrivaPubSource public class PrivaPubSource
@@ -68,9 +75,13 @@ public class PrivaPubEvent
public string TimeZone { get; set; } public string TimeZone { get; set; }
public List<PrivaPubPlace> Places { get; set; } = []; public List<PrivaPubPlace> Places { get; set; } = [];
public string OnlineUrl { get; set; } public string OnlineUrl { get; set; }
/// <summary>Mobilizon's: "free", "restricted" (the organiser approves), "invite" or "external" (not joined from here).</summary>
public string JoinMode { get; set; }
public int? Participants { get; set; } public int? Participants { get; set; }
public int? Capacity { get; set; } public int? Capacity { get; set; }
public string Status { get; set; } public string Status { get; set; }
/// <summary>The persona's own: "pending", "accepted" or "rejected"; null when it has not joined.</summary>
public string Participation { get; set; }
} }
public class PrivaPubPlace public class PrivaPubPlace
+32
View File
@@ -40,7 +40,13 @@ public class Status
public string Text { get; set; } public string Text { get; set; }
public PrivaPubStatus Privapub { get; set; } public PrivaPubStatus Privapub { get; set; }
public Quote Quote { get; set; } public Quote Quote { get; set; }
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
public List<EmojiReaction> EmojiReactions { get; set; } = [];
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
public List<FilterResult> Filtered { get; set; } = [];
public int QuotesCount { get; set; } public int QuotesCount { get; set; }
/// <summary>Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona.</summary>
public QuoteApproval QuoteApproval { get; set; }
} }
public class StatusMention public class StatusMention
@@ -85,9 +91,28 @@ public class Poll
public int VotesCount { get; set; } public int VotesCount { get; set; }
public int? VotersCount { get; set; } public int? VotersCount { get; set; }
public bool? Voted { get; set; } public bool? Voted { get; set; }
/// <summary>The options the current persona chose (indexes).</summary>
public List<int> OwnVotes { get; set; } = [];
public List<PollOption> Options { get; set; } = []; public List<PollOption> Options { get; set; } = [];
} }
public class EmojiReaction
{
/// <summary>The emoji itself, or a custom emoji's shortcode.</summary>
public string Name { get; set; }
public int Count { get; set; }
/// <summary>Whether the current persona reacted with it.</summary>
public bool Me { get; set; }
/// <summary>A custom emoji's image, through PrivaPub's proxy.</summary>
public string Url { get; set; }
}
/// <summary>POST /api/v1/polls/{id}/votes.</summary>
public class PollVoteForm
{
public List<int> Choices { get; set; } = [];
}
public class PollOption public class PollOption
{ {
public string Title { get; set; } public string Title { get; set; }
@@ -107,3 +132,10 @@ public class PreviewCard
public int Height { get; set; } public int Height { get; set; }
public string Blurhash { get; set; } public string Blurhash { get; set; }
} }
public class QuoteApproval
{
public List<string> Automatic { get; set; } = [];
public List<string> Manual { get; set; } = [];
public string CurrentUser { get; set; }
}
+2
View File
@@ -24,6 +24,8 @@ namespace decePubClient.Models
public bool ThemeIsDarkGray { get; set; } = false; public bool ThemeIsDarkGray { get; set; } = false;
public bool ShowDonatorBadge { get; set; } = false; public bool ShowDonatorBadge { get; set; } = false;
// before publishing pictures without a description, the composer asks once
public bool RemindAltText { get; set; } = true;
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark; // 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 // otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
+105
View File
@@ -0,0 +1,105 @@
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
namespace decePubClient.Models
{
/// <summary>
/// The persona's profile as Settings edits it, which is what update_credentials takes: the words as written (its
/// account's Source), the flags, the posting defaults, and a new avatar or header when one was picked. Fields always
/// holds FieldCount rows; a row without a name is dropped.
/// </summary>
public class ProfileForm
{
public const int FieldCount = 4;
public const int DisplayNameLength = 30;
public const int NoteLength = 500;
public const int FieldLength = 255;
public string DisplayName { get; set; }
public string Note { get; set; }
public bool Locked { get; set; }
public bool Bot { get; set; }
public bool Discoverable { get; set; }
public bool Indexable { get; set; }
public MessageType Privacy { get; set; } = MessageType.Public;
public bool Sensitive { get; set; }
public string Language { get; set; }
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
public List<AccountField> Fields { get; set; } = [];
public UploadMedia Avatar { get; set; }
public UploadMedia Header { get; set; }
public static ProfileForm Of(Account account)
{
var source = account.Source ?? new();
var fields = source.Fields.Count > 0 ? source.Fields : account.Fields;
return new()
{
DisplayName = account.DisplayName,
Note = source.Note,
Locked = account.Locked,
Bot = account.Bot,
Discoverable = account.Discoverable == true,
Indexable = account.Indexable == true,
Privacy = source.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
},
Sensitive = source.Sensitive,
Language = source.Language,
QuotePolicy = source.QuotePolicy switch
{
"followers" => QuotePolicy.Followers,
"nobody" => QuotePolicy.Nobody,
_ => QuotePolicy.Public
},
Fields = [.. fields.Take(FieldCount).Select(f => new AccountField { Name = f.Name, Value = f.Value }),
.. Enumerable.Range(0, Math.Max(0, FieldCount - fields.Count)).Select(_ => new AccountField())]
};
}
/// <summary>The form as update_credentials reads it, with the pictures as files.</summary>
public MultipartFormDataContent Content()
{
var content = new MultipartFormDataContent
{
{ new StringContent(DisplayName?.Trim() ?? ""), "display_name" },
{ new StringContent(Note ?? ""), "note" },
{ new StringContent(Flag(Locked)), "locked" },
{ 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(Flag(Sensitive)), "source[sensitive]" },
{ new StringContent(Language ?? ""), "source[language]" },
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
};
// every row is sent, so a row emptied here is removed there
for (var i = 0; i < Fields.Count; i++)
{
content.Add(new StringContent(Fields[i].Name?.Trim() ?? ""), $"fields_attributes[{i}][name]");
content.Add(new StringContent(Fields[i].Value?.Trim() ?? ""), $"fields_attributes[{i}][value]");
}
if (Avatar is not null)
content.Add(Picture(Avatar), "avatar", Avatar.FileName);
if (Header is not null)
content.Add(Picture(Header), "header", Header.FileName);
return content;
}
static string Flag(bool value) => value ? "true" : "false";
// 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 StreamContent(media.File.OpenReadStream(MaximumPictureSize));
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
return picture;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace decePubClient.Models;
/// <summary>
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name.
/// </summary>
public class PublicActionNotices
{
/// <summary>Told about reactions and poll votes.</summary>
public List<string> PersonaIds { get; set; } = [];
/// <summary>Told about joining events.</summary>
public List<string> EventPersonaIds { get; set; } = [];
}
public enum PublicAct
{
Reaction = 0,
Participation = 1
}
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using decePubClient.Models.Types;
using decePubClient.Resources;
namespace decePubClient.Models;
/// <summary>A report of an account, and of the post it was made from, as the report dialog edits it.</summary>
public class ReportForm
{
public const int CommentLength = 1000;
public ReportCategory Category { get; set; } = ReportCategory.Other;
[StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string Comment { get; set; }
/// <summary>A copy to a remote account's own server, from PrivaPub's actor.</summary>
public bool Forward { get; set; } = true;
}
+10
View File
@@ -0,0 +1,10 @@
namespace decePubClient.Models.Types
{
/// <summary>Who may quote a persona's posts (Mastodon's quote_policy).</summary>
public enum QuotePolicy
{
Public = 1,
Followers,
Nobody
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace decePubClient.Models.Types
{
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
public enum ReportCategory
{
Other = 0,
Spam = 1,
Legal = 2
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ namespace decePubClient.Models.Types
Notifications, Notifications,
Favourites, Favourites,
Bookmarks, Bookmarks,
Tag Tag,
Lists
} }
} }
+36 -5
View File
@@ -1,17 +1,48 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.Forms;
namespace decePubClient.Models; 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 class UploadMedia
{ {
public string ContentType { get; set; } public string ContentType { get; set; }
public string FileName { get; set; } public string FileName { get; set; }
public string AltText { get; set; } public string AltText { get; set; }
public byte[] Blob { get; set; } = []; public long Size { get; set; }
[JsonIgnore, Bindable(false)] [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)] [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;
}
+49 -5
View File
@@ -1,7 +1,12 @@
@page "/administration" @page "/administration"
@attribute [Authorize(Policy = Policies.IsModerator)]
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<Title>@Localizer["Settings"]</Title> <Title>@Localizer["Administration"]</Title>
@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back
up and restore the server. The
other sections wait for PrivaPub endpoints that do not exist yet. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -25,18 +30,57 @@
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Reports"]</span> <i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
</p> </p>
</Header> </Header>
<ChildContent> <ChildContent>
<div class="block w-full p-3 md:p-4"> <div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg"> <ReportsPanel />
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div> </div>
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<AuthorizeView Policy="@Policies.IsAdmin">
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<ServerBlocksPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<StatisticsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<BackupsPanel />
</div>
</ChildContent>
</DCollapse>
</AuthorizeView>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
+58 -3
View File
@@ -4,7 +4,9 @@
<Title>@Localizer["Thread"]</Title> <Title>@Localizer["Thread"]</Title>
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below. @* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
The cards do their own actions; a reply or a deletion updates the thread here. *@ The cards do their own actions; a reply or a deletion updates the thread here. The persona's thread filters apply
to the posts around the one it opened, never to that one. Opening a remote thread makes PrivaPub read its replies
from their servers, so the thread is read twice more, a few seconds later, and what was found joins it. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl"> <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"> <div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -32,14 +34,14 @@
{ {
@foreach (var post in ancestors) @foreach (var post in ancestors)
{ {
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" 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" /> <Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
@foreach (var post in descendants) @foreach (var post in descendants)
{ {
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" 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> </div>
@@ -53,10 +55,14 @@
[Parameter] public string StatusId { get; set; } [Parameter] public string StatusId { get; set; }
// when the thread is read again, after PrivaPub has had time to fetch the replies held on other servers
static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)];
FeedPost current; FeedPost current;
List<FeedPost> ancestors = []; List<FeedPost> ancestors = [];
List<FeedPost> descendants = []; List<FeedPost> descendants = [];
string loadedId; string loadedId;
CancellationTokenSource rereading;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
@@ -77,6 +83,9 @@
async Task LoadData() async Task LoadData()
{ {
rereading?.Cancel();
rereading?.Dispose();
rereading = null;
IsLoading = true; IsLoading = true;
var status = await StatusService.Get(StatusId); var status = await StatusService.Get(StatusId);
if (status is null) if (status is null)
@@ -95,6 +104,42 @@
var all = ancestors.Append(current).Concat(descendants).ToList(); var all = ancestors.Append(current).Concat(descendants).ToList();
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost)); await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(all); InstanceService.Locate(all);
if (new[] { current, ancestors.FirstOrDefault() }.Any(p => p is not null && p.Host != InstanceService.SelfHost))
{
rereading = new();
_ = Reread(status.Id, rereading.Token);
}
}
// the answers PrivaPub found since, in the thread's order; the cards already shown stay as they are, and one the
// thread no longer lists (a reply just made, not yet in it) stays at the end
async Task Reread(string statusId, CancellationToken cancellationToken)
{
foreach (var pause in Rereads)
{
try
{
await Task.Delay(pause, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var context = await StatusService.Context(statusId, cancellationToken);
if (cancellationToken.IsCancellationRequested || context is null)
return;
var shown = descendants.ToDictionary(p => p.Key);
var found = context.Descendants.Where(s => !shown.ContainsKey(s.Id)).Select(FeedPost.Of).ToList();
if (found.Count == 0)
continue;
var listed = context.Descendants.Select(s => s.Id).ToHashSet();
descendants = [.. context.Descendants.Select(s => shown.GetValueOrDefault(s.Id) ?? found.First(p => p.Key == s.Id)),
.. descendants.Where(p => !listed.Contains(p.Key))];
await InstanceService.Resolve(found.Select(p => p.Host));
InstanceService.Locate(found);
await InvokeAsync(StateHasChanged);
}
} }
void Remove(FeedPost post) void Remove(FeedPost post)
@@ -127,4 +172,14 @@
} }
async Task Back() => await JS.InvokeVoidAsync("history.back"); async Task Back() => await JS.InvokeVoidAsync("history.back");
public override async ValueTask DisposeAsync()
{
if (rereading is not null)
{
await rereading.CancelAsync();
rereading.Dispose();
}
await base.DisposeAsync();
}
} }
+283
View File
@@ -0,0 +1,283 @@
@page "/explore"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Explore"]</Title>
@* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already
holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the
hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@
<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">
<div class="flex items-center gap-2">
<i class="ion-md-compass text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
</div>
<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>
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
</DTabs>
</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 (tab is ExploreTab.Hashtags or ExploreTab.Followed)
{
@if (tags.Count == 0)
{
<p class="w-full text-center" data-feed-empty>
<i class="ion-ios-remove-circle-outline"></i> @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."])
</p>
}
<div class="flex flex-col gap-2">
@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 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>
<span class="flex items-end gap-px h-6" aria-hidden="true">
@foreach (var day in Enumerable.Reverse(item.History))
{
<span class="w-1.5 rounded-sm bg-primary/60" style="@($"height:{Bar(day.UsesCount, item)}%")"></span>
}
</span>
<DButton Size="DSize.Xs" Color="@(item.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isActing)"
OnClick="@(() => ToggleFollow(item))">
<span>@(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"])</span>
</DButton>
</div>
}
</div>
}
else if (tab is ExploreTab.Posts)
{
@if (posts.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
}
<div class="flex flex-col gap-4">
@foreach (var post in posts)
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Public" OnTag="OpenTag" />
}
</div>
}
else if (tab is ExploreTab.Links)
{
@if (links.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var link in links)
{
<a class="flex gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@link.Url" target="_blank" rel="noopener noreferrer" data-link="@link.Url">
@if (link.Image is { Length: > 0 } image)
{
@* alt="": the title is next to it. *@
<img alt="" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-lg" src="@image" />
}
<span class="flex flex-col min-w-0 gap-1">
<b class="text-sm break-words">@link.Title</b>
@if (link.Description is { Length: > 0 } description)
{
<span class="text-xs opacity-80 line-clamp-2">@description</span>
}
<span class="text-xs opacity-60">@link.ProviderName · @Localizer["{0} people this week", link.WeekAccounts]</span>
</span>
</a>
}
</div>
}
else
{
<div class="flex flex-wrap items-center gap-3">
<DSelect TValue="DirectoryOrder" Size="DSize.Sm" class="w-auto" Value="order" ValueChanged="ChangeOrder" ValueExpression="() => order">
<option value="@DirectoryOrder.Active">@Localizer["Recently active"]</option>
<option value="@DirectoryOrder.New">@Localizer["Newcomers"]</option>
</DSelect>
<label class="label text-sm">
<input type="checkbox" class="checkbox checkbox-sm" checked="@localOnly" @onchange="ToggleLocal" />
<span>@Localizer["Only this server"]</span>
</label>
</div>
@if (people.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
}
<div class="flex flex-col gap-2">
@foreach (var account in people)
{
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Id">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
@if (morePeople)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isActing)" OnClick="LoadMorePeople">@Localizer["Load more"]</DButton>
}
</div>
}
</div>
</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,
Posts,
Links,
People,
Followed
}
[Inject] DiscoveryService DiscoveryService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
ExploreTab tab = ExploreTab.Hashtags;
List<Tag> tags = [];
List<FeedPost> posts = [];
List<TrendLink> links = [];
List<Account> people = [];
DirectoryOrder order = DirectoryOrder.Active;
bool localOnly;
bool morePeople;
bool isActing;
string loadedPersonaId;
static int Bar(int uses, Tag tag) => tag.History.Max(h => h.UsesCount) is > 0 and var most ? Math.Max(8, uses * 100 / most) : 8;
// The persona changed (NavMenu's switcher): what it follows is another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
switch (tab)
{
case ExploreTab.Hashtags:
tags = await DiscoveryService.TrendingTags();
break;
case ExploreTab.Followed:
tags = await DiscoveryService.FollowedTags();
break;
case ExploreTab.Posts:
posts = [.. (await DiscoveryService.TrendingStatuses()).Select(FeedPost.Of)];
await InstanceService.Resolve(posts.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(posts);
break;
case ExploreTab.Links:
links = await DiscoveryService.TrendingLinks();
break;
default:
people = await DiscoveryService.Directory(order, localOnly, 0);
morePeople = people.Count == DiscoveryService.DirectoryPage;
break;
}
IsLoading = false;
}
async Task SelectTab(int index)
{
tab = (ExploreTab)index;
await LoadData();
}
async Task ChangeOrder(DirectoryOrder value)
{
order = value;
await LoadData();
}
async Task ToggleLocal(ChangeEventArgs e)
{
localOnly = e.Value is true;
await LoadData();
}
async Task LoadMorePeople()
{
isActing = true;
var known = people.Select(a => a.Id).ToHashSet();
var page = await DiscoveryService.Directory(order, localOnly, people.Count);
isActing = false;
people.AddRange(page.Where(a => known.Add(a.Id)));
morePeople = page.Count == DiscoveryService.DirectoryPage;
}
async Task ToggleFollow(Tag item)
{
isActing = true;
var result = await DiscoveryService.FollowTag(item.Name, item.Following != true);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
item.Following = ((Tag)result.Data).Following;
if (tab is ExploreTab.Followed && item.Following != true)
tags.Remove(item);
Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]);
}
async Task OpenTag(string tagName)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tagName;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
}
+20
View File
@@ -0,0 +1,20 @@
@page "/notifications/filtered"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Filtered notifications"]</Title>
@* The persona's notification policy and the requests it filtered, as in Settings; the Notifications tab links here when
requests are waiting. *@
<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">
<div class="flex items-center gap-2">
<i class="ion-md-notifications-off text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Filtered notifications"]</h1>
</div>
<NotificationPolicyEditor />
</div>
</section>
@code {
protected override void OnInitialized() => IsLoading = false;
}
+399
View File
@@ -0,0 +1,399 @@
@page "/filters"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Filters"]</Title>
@* 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. *@
<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">
<div class="flex items-center gap-2">
<i class="ion-md-funnel text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Filters"]</h1>
</div>
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
placeholder="@Localizer["A new filter's title"]" data-filter-new>
<Prefix><i class="ion-md-add"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
<span>@Localizer["Make a filter"]</span>
</DButton>
</EditForm>
@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 (filters.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no filter yet."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var filter in filters)
{
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-filter-id="@filter.Id">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 truncate">@filter.Title</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@ActionName(filter.FilterAction)</DBadge>
@if (!filter.IsActive)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Expired"]</DBadge>
}
else if (filter.ExpiresAt is { } expires)
{
<span class="text-xs opacity-70" title="@NerdRow.Time(expires)">@Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]</span>
}
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count]</span>
<DButton Size="DSize.Sm" Active="@(open?.Id == filter.Id)" OnClick="@(() => Toggle(filter))" title="@Localizer["Edit"]">
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
</DButton>
</div>
@if (open?.Id == filter.Id)
{
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
<DInput TValue="string" class="w-full" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
aria-label="@Localizer["Title"]" data-filter-title />
<fieldset class="flex flex-wrap gap-x-4 gap-y-2">
<legend class="text-sm font-semibold pb-1">@Localizer["Where"]</legend>
@foreach (var context in Contexts)
{
<label class="label text-sm">
<input type="checkbox" class="checkbox checkbox-sm" checked="@edit.Contexts.Contains(context)"
@onchange="@((ChangeEventArgs e) => ToggleContext(context, e.Value is true))" />
<span>@ContextName(context)</span>
</label>
}
</fieldset>
<div class="flex flex-wrap gap-2 items-center">
<DSelect TValue="FilterAction" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.Action" aria-label="@Localizer["How"]">
<Prefix><i class="ion-md-funnel"></i></Prefix>
<ChildContent>
@foreach (var action in Actions)
{
<option value="@action">@ActionText(action)</option>
}
</ChildContent>
</DSelect>
<DSelect TValue="int" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.ExpiresIn" aria-label="@Localizer["Expires"]">
<Prefix><i class="ion-md-time"></i></Prefix>
<ChildContent>
@foreach (var (seconds, label) in Expiries)
{
<option value="@seconds">@label</option>
}
</ChildContent>
</DSelect>
</div>
<h2 class="font-semibold text-sm">@Localizer["Words"]</h2>
<div class="flex flex-col gap-2" data-filter-keywords>
@foreach (var row in edit.Keywords.Where(k => !k.Removed))
{
<div class="flex flex-wrap items-center gap-2" data-keyword="@row.Keyword">
<span class="flex-1 min-w-0 break-words text-sm">@row.Keyword</span>
<label class="label text-xs">
<input type="checkbox" class="checkbox checkbox-xs" checked="@row.WholeWord" @onchange="@((ChangeEventArgs e) => row.WholeWord = e.Value is true)" />
<span>@Localizer["Whole word"]</span>
</label>
<DButton Size="DSize.Xs" OnClick="@(() => RemoveKeyword(row))" title="@Localizer["Remove"]">
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
</DButton>
</div>
}
<div class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.NewKeyword" Immediate="true" autocomplete="off" maxlength="100"
placeholder="@Localizer["A word or a phrase"]" data-filter-keyword-new />
<DButton Size="DSize.Sm" Disabled="@(string.IsNullOrWhiteSpace(edit.NewKeyword))" OnClick="AddKeyword">
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
</DButton>
</div>
</div>
@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">
@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>
<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>
</div>
}
</div>
}
<div class="flex flex-wrap gap-2">
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary"
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title) || edit.Contexts.Count == 0)">
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
</DButton>
@if (isConfirmingDelete)
{
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
</DButton>
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
</DButton>
}
</div>
</EditForm>
}
</article>
}
</div>
</div>
</section>
@code {
const int KeepExpiry = -1;
static readonly FilterContext[] Contexts = [FilterContext.Home, FilterContext.Notifications, FilterContext.Public, FilterContext.Thread, FilterContext.Account];
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
[Inject] FilterService FilterService { get; set; }
List<Filter> filters = [];
TitleForm draft = new();
FilterEdit edit = new();
Filter open;
string loadedPersonaId;
bool isSaving;
bool isConfirmingDelete;
(int Seconds, string Label)[] Expiries =>
[
(KeepExpiry, Localizer["Keep its expiry"]),
(0, Localizer["Never expires"]),
(1800, Localizer["Expires in 30 minutes"]),
(3600, Localizer["Expires in 1 hour"]),
(43200, Localizer["Expires in 12 hours"]),
(86400, Localizer["Expires in 1 day"]),
(604800, Localizer["Expires in 1 week"])
];
string ContextName(FilterContext context) => context switch
{
FilterContext.Home => Localizer["Home and lists"],
FilterContext.Notifications => Localizer["Notifications"],
FilterContext.Public => Localizer["Public feeds"],
FilterContext.Thread => Localizer["Threads"],
_ => Localizer["Profiles"]
};
string ActionName(FilterAction action) => action switch
{
FilterAction.Hide => Localizer["Hide"],
FilterAction.Blur => Localizer["Blur"],
_ => Localizer["Warn"]
};
string ActionText(FilterAction action) => action switch
{
FilterAction.Hide => Localizer["Leave the post out"],
FilterAction.Blur => Localizer["Blur its media"],
_ => Localizer["Fold the post behind the filter's title"]
};
// The persona changed (NavMenu's switcher): its filters are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
open = null;
filters = await FilterService.All();
IsLoading = false;
}
// a new filter applies everywhere and warns until it is told otherwise
async Task Create()
{
isSaving = true;
var result = await FilterService.Save(null, new FilterForm { Title = draft.Title.Trim(), Context = [.. Contexts], FilterAction = FilterAction.Warn });
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var filter = (Filter)result.Data;
filters.Add(filter);
draft = new();
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
Toggle(filter);
}
void Toggle(Filter filter)
{
isConfirmingDelete = false;
if (open?.Id == filter.Id)
{
open = null;
return;
}
open = filter;
edit = new FilterEdit
{
Title = filter.Title,
Contexts = [.. filter.Context],
Action = filter.FilterAction,
ExpiresIn = KeepExpiry,
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
};
}
void ToggleContext(FilterContext context, bool on)
{
if (on)
edit.Contexts.Add(context);
else
edit.Contexts.Remove(context);
}
void AddKeyword()
{
var keyword = edit.NewKeyword?.Trim();
if (string.IsNullOrEmpty(keyword) || edit.Keywords.Any(k => !k.Removed && string.Equals(k.Keyword, keyword, StringComparison.OrdinalIgnoreCase)))
return;
edit.Keywords.Add(new KeywordRow { Keyword = keyword, WholeWord = true });
edit.NewKeyword = null;
}
void RemoveKeyword(KeywordRow row)
{
if (row.Id is null)
edit.Keywords.Remove(row);
else
row.Removed = true;
}
async Task Save()
{
AddKeyword();
var form = new FilterForm
{
Title = edit.Title.Trim(),
Context = [.. Contexts.Where(edit.Contexts.Contains)],
FilterAction = edit.Action,
ExpiresIn = edit.ExpiresIn == KeepExpiry ? null : edit.ExpiresIn,
KeywordsAttributes =
[
.. edit.Keywords.Where(k => k.Id is not null && k.Removed).Select(k => new FilterKeywordForm { Id = k.Id, Destroy = true }),
.. edit.Keywords.Where(k => k.Id is not null && !k.Removed && k.WholeWord != k.WasWholeWord)
.Select(k => new FilterKeywordForm { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord }),
.. edit.Keywords.Where(k => k.Id is null).Select(k => new FilterKeywordForm { Keyword = k.Keyword, WholeWord = k.WholeWord })
]
};
isSaving = true;
var result = await FilterService.Save(open.Id, form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var saved = (Filter)result.Data;
var index = filters.FindIndex(f => f.Id == saved.Id);
if (index >= 0)
filters[index] = saved;
open = null;
Toast.AddResponseSuccess(Localizer["Filter {0} saved.", saved.Title]);
}
async Task Delete()
{
isSaving = true;
var result = await FilterService.Delete(open.Id);
isSaving = false;
isConfirmingDelete = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
filters.RemoveAll(f => f.Id == open.Id);
Toast.AddResponseSuccess(Localizer["Filter {0} deleted.", open.Title]);
open = null;
}
async Task RemoveStatus(Filter filter, FilterStatus item)
{
isSaving = true;
var result = await FilterService.RemoveStatus(item.Id);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
filter.Statuses.Remove(item);
}
sealed class TitleForm
{
public string Title { get; set; }
}
sealed class FilterEdit
{
public string Title { get; set; }
public HashSet<FilterContext> Contexts { get; set; } = [];
public FilterAction Action { get; set; } = FilterAction.Warn;
public int ExpiresIn { get; set; } = KeepExpiry;
public List<KeywordRow> Keywords { get; set; } = [];
public string NewKeyword { get; set; }
}
sealed class KeywordRow
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
public bool WasWholeWord { get; set; }
public bool Removed { get; set; }
}
}
+372
View File
@@ -0,0 +1,372 @@
@page "/groups"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Groups"]</Title>
@* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones
it runs, edited, their invitation shown, the requests to join answered and members taken out. A circle's members are
never shown to anyone but its owner and moderators, here as on every server. *@
<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">
<div class="flex items-center gap-2">
<i class="ion-md-people text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Groups"]</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-new>
<DataAnnotationsValidator />
<b class="text-sm">@Localizer["A new group"]</b>
<div class="flex flex-wrap gap-2">
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.UserName" Immediate="true" autocomplete="off" maxlength="32"
placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-group-username>
<Prefix>@@</Prefix>
</DInput>
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.Name" Immediate="true" autocomplete="off" maxlength="64"
placeholder="@Localizer["Name"]" data-group-name />
</div>
<DTextarea @bind-Value="draft.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" placeholder="@Localizer["Description"]" />
<DToggle Size="DSize.Sm" @bind-Value="draft.IsCommunity" LabelText="@Localizer["A community, open to the fediverse (otherwise a private circle)"]" data-group-community />
@if (draft.IsCommunity)
{
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="draft.PostingPolicy" aria-label="@Localizer["Who may post"]">
<Prefix><i class="ion-md-create"></i></Prefix>
<ChildContent>
@foreach (var policy in PostingPolicies)
{
<option value="@policy">@PolicyName(policy)</option>
}
</ChildContent>
</DSelect>
<DToggle Size="DSize.Sm" @bind-Value="draft.IsDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
}
<DToggle Size="DSize.Sm" @bind-Value="draft.ManuallyApprovesMembers" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
<DInput TValue="string" type="password" @bind-Value="draft.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
placeholder="@Localizer["Invitation password (optional)"]" />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-create>
<i class="ion-md-add"></i><span>@Localizer["Make the group"]</span>
</DButton>
</EditForm>
<EditForm Model="joining" OnValidSubmit="Join" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-join>
<b class="text-sm">@Localizer["Join by invitation"]</b>
<DInput TValue="string" @bind-Value="joining.InvitationCode" Immediate="true" autocomplete="off" maxlength="64"
placeholder="@Localizer["Invitation code"]" data-group-code>
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
<DInput TValue="string" type="password" @bind-Value="joining.InvitationPassword" Immediate="true" autocomplete="current-password" maxlength="128"
placeholder="@Localizer["Invitation password, if it has one"]" />
<DButton Type="submit" Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(joining.InvitationCode))" data-group-join-submit>
<i class="ion-md-log-in"></i><span>@Localizer["Join"]</span>
</DButton>
</EditForm>
</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 (groups.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona is in no group yet."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var group in groups)
{
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-group-id="@group.Id">
<div class="flex flex-wrap items-center gap-2">
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") text-lg"></i>
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate">@(group.Name ?? group.UserName)</b>
<span class="text-xs opacity-70 truncate">@("@" + group.Handle)</span>
</span>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(group.IsCommunity ? Localizer["Community"] : Localizer["Circle"])</DBadge>
<span class="text-sm tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person"></i> @group.MembersCount</span>
@if (group.IsOwner)
{
<DButton Size="DSize.Sm" Active="@(open?.Id == group.Id)" OnClick="@(() => Toggle(group))" data-group-manage>
<i class="ion-md-settings"></i><span class="hidden sm:inline">@Localizer["Manage"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Leave(group))" data-group-leave>
<i class="ion-md-exit"></i><span>@Localizer["Leave"]</span>
</DButton>
}
</div>
@if (open?.Id == group.Id)
{
@if (group.InvitationCode is { Length: > 0 } invitation)
{
<div class="flex flex-col gap-1 text-sm">
<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 />
</div>
}
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-2">
<DInput TValue="string" @bind-Value="edit.Name" Immediate="true" autocomplete="off" maxlength="64" aria-label="@Localizer["Name"]" />
<DTextarea @bind-Value="edit.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" aria-label="@Localizer["Description"]" />
@if (group.IsCommunity)
{
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="edit.PostingPolicy" aria-label="@Localizer["Who may post"]">
<Prefix><i class="ion-md-create"></i></Prefix>
<ChildContent>
@foreach (var policy in PostingPolicies)
{
<option value="@policy">@PolicyName(policy)</option>
}
</ChildContent>
</DSelect>
<DToggle Size="DSize.Sm" @bind-Value="editDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
}
<DToggle Size="DSize.Sm" @bind-Value="editApproves" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
<DInput TValue="string" type="password" @bind-Value="edit.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
placeholder="@Localizer["A new invitation password"]" />
@if (group.IsPasswordRequired)
{
<DToggle Size="DSize.Sm" @bind-Value="edit.RemoveInvitationPassword" LabelText="@Localizer["Remove the invitation password"]" />
}
<DToggle Size="DSize.Sm" @bind-Value="edit.RegenerateInvitationCode" LabelText="@Localizer["A new invitation code (the old one stops working)"]" />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-save>
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
</DButton>
</EditForm>
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count(m => !m.IsPending)</span></h2>
<div class="flex flex-col gap-2" data-group-members>
@foreach (var member in members.OrderBy(m => m.IsPending ? 0 : 1).ThenBy(m => m.Since))
{
<div class="flex flex-wrap items-center gap-3 min-w-0" data-member="@member.ActorUri" data-pending="@(member.IsPending ? "true" : "false")">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(member.PictureUrl ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate text-sm">@(member.Name ?? member.Handle)</b>
<span class="text-xs opacity-70 truncate">@("@" + member.Handle)</span>
</span>
@if (member.IsPending)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["asks to join"]</DBadge>
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, true))" data-member-approve>
<i class="ion-md-checkmark"></i><span>@Localizer["Let in"]</span>
</DButton>
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, false))" data-member-reject>
<i class="ion-md-close"></i><span>@Localizer["Decline"]</span>
</DButton>
}
else if (member.Role == "owner")
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["owner"]</DBadge>
}
else
{
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Remove(member))" title="@Localizer["Take out of the group"]" data-member-remove>
<i class="ion-md-remove-circle"></i><span class="hidden sm:inline">@Localizer["Take out"]</span>
</DButton>
}
</div>
}
</div>
}
</article>
}
</div>
</div>
</section>
@code {
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
[Inject] GroupService GroupService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ViewGroup> groups = [];
List<ViewGroupMember> members = [];
InsertGroupForm draft = new() { PostingPolicy = "followers" };
JoinGroupForm joining = new();
UpdateGroupForm edit = new();
bool editDiscoverable;
bool editApproves;
ViewGroup open;
string loadedPersonaId;
bool isSaving;
string PolicyName(string policy) => policy switch
{
"anyone" => Localizer["Anyone may post"],
"moderators" => Localizer["Only its moderators post"],
_ => Localizer["Its followers post"]
};
// The persona changed (NavMenu's switcher): its groups are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
open = null;
members = [];
// (the forms are the persona's: validated as they are filled, so they name it from the start)
draft.AvatarId = CascadingState.PersonaId;
joining.AvatarId = CascadingState.PersonaId;
groups = await GroupService.List(CascadingState.PersonaId);
IsLoading = false;
}
async Task Create()
{
if (!draft.IsCommunity)
draft.IsDiscoverable = false;
isSaving = true;
var result = await GroupService.Insert(draft);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var group = (ViewGroup)result.Data;
draft = new() { AvatarId = CascadingState.PersonaId, PostingPolicy = "followers" };
Toast.AddResponseSuccess(Localizer["{0} made.", group.Name ?? group.UserName]);
await LoadData();
if (groups.FirstOrDefault(g => g.Id == group.Id) is { } made)
await Toggle(made);
}
async Task Join()
{
isSaving = true;
var result = await GroupService.Join(joining);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var group = (ViewGroup)result.Data;
joining = new() { AvatarId = CascadingState.PersonaId };
Toast.AddResponseSuccess(Localizer["You joined {0}.", group.Name ?? group.UserName]);
await LoadData();
}
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;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
groups.Remove(group);
Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]);
}
async Task Toggle(ViewGroup group)
{
if (open?.Id == group.Id)
{
open = null;
members = [];
return;
}
open = group;
edit = new UpdateGroupForm { AvatarId = CascadingState.PersonaId, GroupId = group.Id, Name = group.Name, Description = group.Description, PostingPolicy = group.PostingPolicy };
editDiscoverable = group.IsDiscoverable;
editApproves = group.ManuallyApprovesMembers;
members = await GroupService.Members(CascadingState.PersonaId, group.Id);
}
async Task Save()
{
edit.IsDiscoverable = open.IsCommunity ? editDiscoverable : null;
edit.ManuallyApprovesMembers = editApproves;
if (string.IsNullOrEmpty(edit.InvitationPassword))
edit.InvitationPassword = null;
isSaving = true;
var result = await GroupService.Update(edit);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (ViewGroup)result.Data;
var index = groups.FindIndex(g => g.Id == updated.Id);
if (index >= 0)
groups[index] = updated;
open = updated;
edit.InvitationPassword = null;
edit.RemoveInvitationPassword = false;
edit.RegenerateInvitationCode = false;
Toast.AddResponseSuccess(Localizer["Saved."]);
}
async Task Answer(ViewGroupMember member, bool approve)
{
isSaving = true;
var result = approve
? await GroupService.Approve(CascadingState.PersonaId, open.Id, member.ActorUri)
: await GroupService.Reject(CascadingState.PersonaId, open.Id, member.ActorUri);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
members = await GroupService.Members(CascadingState.PersonaId, open.Id);
}
async Task Remove(ViewGroupMember member)
{
isSaving = true;
var result = await GroupService.Remove(CascadingState.PersonaId, open.Id, member.ActorUri);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
members.Remove(member);
Toast.AddResponseSuccess(Localizer["{0} is out of the group.", member.Name ?? member.Handle]);
}
}
+237 -23
View File
@@ -7,27 +7,46 @@
@* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed, @* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js); newer ones are polled every default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@ 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. *@
<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"> <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"> @* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" /> <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> </aside>
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full"> <section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing" @if (!isComposing)
{
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
<DButton Shape="DButtonShape.Circle" Size="DSize.Lg" Color="DColor.Primary" OnClick="ToggleCompose"
class="md:hidden fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-30" aria-label="@Localizer["New post"]"
title="@Localizer["New post"]" data-compose-open>
<i class="ion-md-create text-xl" aria-hidden="true"></i>
</DButton>
}
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
OnChanged="ChangeFeed" /> OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl"> <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 class="flex flex-col gap-4"> <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) <PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</PhoneSheet>
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
{ {
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs"> <a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" /> <i class="ion-md-notifications-off"></i>
</div> <span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
</a>
} }
@if (pending.Count > 0) @if (pending.Count > 0)
@@ -67,15 +86,16 @@
} }
else if (posts.Count == 0) else if (posts.Count == 0)
{ {
<p class="w-full text-center text-lg"> <p class="w-full text-center text-lg" data-feed-empty>
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"] <i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p> </p>
} }
@foreach (var post in posts) @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
{ (tests/e2e/Perf/FeedPerfTests) *@
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" /> <Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
} <Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
</Virtualize>
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60"> <div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@if (isLoadingMore) @if (isLoadingMore)
@@ -103,24 +123,44 @@
[Inject] IJSRuntime JS { get; set; } [Inject] IJSRuntime JS { get; set; }
[Inject] TimelineService TimelineService { get; set; } [Inject] TimelineService TimelineService { get; set; }
[Inject] GroupService GroupService { get; set; } [Inject] GroupService GroupService { get; set; }
[Inject] ListService ListService { get; set; }
[Inject] StreamingService Streaming { get; set; }
[Inject] InstanceService InstanceService { get; set; } [Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; } [Inject] RequestStats RequestStats { get; set; }
[Inject] FeedMemory Memory { get; set; }
FeedFilter Filter { get; set; } = new(); FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = []; List<FeedPost> posts = [];
List<FeedPost> pending = []; List<FeedPost> pending = [];
List<ViewGroup> groups = []; List<ViewGroup> groups = [];
List<MastodonList> lists = [];
int unreadNotifications;
// the requests the persona's notification policy filtered, shown on the Notifications tab
int filteredRequests;
int unreadConversations;
string maxId; string maxId;
string minId; string minId;
string loadError; string loadError;
bool isLoadingMore; bool isLoadingMore;
bool isComposing; 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; (double Latitude, double Longitude)? position;
string positionError; string positionError;
string loadedPersonaId; string loadedPersonaId;
bool waitingForPersona;
FeedQuery lastQuery; FeedQuery lastQuery;
DateTime? lastPollAt; DateTime? lastPollAt;
TimeSpan? lastLoadTook; TimeSpan? lastLoadTook;
// the scroll a recalled feed goes back to, once rendered
double? recalledScroll;
GlobePanel globe; GlobePanel globe;
IJSObjectReference feedModule; IJSObjectReference feedModule;
@@ -131,6 +171,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
Streaming.Received += OnStream;
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() }; Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 }) if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
Filter.TimelineType = TimelineType.Home; Filter.TimelineType = TimelineType.Home;
@@ -143,20 +184,57 @@
return; 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) if (CascadingState.PersonaId is null)
{ {
IsLoading = false; waitingForPersona = !CascadingState.IsSessionLoaded;
IsLoading = waitingForPersona;
return; return;
} }
if (await Recall())
return;
await Reload(); await Reload();
} }
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one. /// <summary>Shows the feed again as the reader left it, when it is the same feed of the same persona and recent.</summary>
async Task<bool> Recall()
{
if (!Memory.Holds(CascadingState.PersonaId))
return false;
groups = Memory.Groups;
lists = Memory.Lists;
if (Query != Memory.Query)
return false;
loadCancellation = new();
posts = Memory.Posts;
pending = Memory.Pending;
minId = Memory.MinId;
maxId = Memory.MaxId;
recalledScroll = Memory.ScrollTop;
loadedPersonaId = CascadingState.PersonaId;
lastQuery = Query;
IsLoading = false;
Memory.Forget();
await Streaming.Follow(StreamOf(Filter), loadCancellation.Token);
await ReadNotifications(loadCancellation.Token);
return true;
}
// 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() 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; return;
}
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
return;
waitingForPersona = false;
await Reload(); await Reload();
} }
@@ -168,13 +246,25 @@
reference = DotNetObjectReference.Create(this); reference = DotNetObjectReference.Create(this);
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js"); feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
if (recalledScroll is { } top)
await feedModule.InvokeVoidAsync("scrollToOffset", ScrollId, top);
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId); await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
pollTimer = new(PollInterval); pollTimer = new(PollInterval);
_ = PollNewer(pageCancellation.Token); _ = PollNewer(pageCancellation.Token);
} }
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
FilterContext? FilterIn => Filter.TimelineType switch
{
TimelineType.Home or TimelineType.Lists => FilterContext.Home,
TimelineType.Local or TimelineType.Federation or TimelineType.Tag or TimelineType.Nearby or TimelineType.Groups => FilterContext.Public,
TimelineType.Notifications => FilterContext.Notifications,
_ => null
};
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true, FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
Filter.Tag, position?.Latitude, position?.Longitude); Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary> /// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
async Task Reload() async Task Reload()
@@ -193,11 +283,16 @@
if (CascadingState.PersonaId != loadedPersonaId) if (CascadingState.PersonaId != loadedPersonaId)
{ {
// a stream is the persona's: another one opens its own
await Streaming.Stop();
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken); groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
lists = await ListService.All(cancellationToken);
loadedPersonaId = CascadingState.PersonaId; loadedPersonaId = CascadingState.PersonaId;
} }
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId)) if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
Filter.GroupId = groups.FirstOrDefault()?.Id; Filter.GroupId = groups.FirstOrDefault()?.Id;
if (Filter.TimelineType is TimelineType.Lists && lists.All(l => l.Id != Filter.ListId))
Filter.ListId = lists.FirstOrDefault()?.Id;
lastQuery = Query; lastQuery = Query;
if (Filter.TimelineType is TimelineType.Nearby && position is null) if (Filter.TimelineType is TimelineType.Nearby && position is null)
{ {
@@ -223,6 +318,8 @@
maxId = page.MaxId; maxId = page.MaxId;
minId = page.MinId; minId = page.MinId;
lastPollAt = DateTime.UtcNow; lastPollAt = DateTime.UtcNow;
await Streaming.Follow(StreamOf(Filter), cancellationToken);
await ReadNotifications(cancellationToken);
StateHasChanged(); StateHasChanged();
await Locate(posts, cancellationToken); await Locate(posts, cancellationToken);
if (feedModule is not null) if (feedModule is not null)
@@ -275,7 +372,14 @@
{ {
while (await pollTimer.WaitForNextTickAsync(cancellationToken)) while (await pollTimer.WaitForNextTickAsync(cancellationToken))
{ {
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct // no stream tells of direct messages: their tab's badge is counted again on every tick
if (!IsLoading && CascadingState.IsOnline && Filter.TimelineType is not TimelineType.Direct
&& await TimelineService.UnreadConversations(cancellationToken) is var unread && unread != unreadConversations)
{
unreadConversations = unread;
await InvokeAsync(StateHasChanged);
}
if (IsLoading || CascadingState.IsOffline || minId is null || Streaming.IsOpen || Filter.TimelineType is TimelineType.Nearby
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true || Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken)) || !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
continue; continue;
@@ -287,9 +391,15 @@
continue; continue;
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet(); var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList(); var page = ((FeedPage)result.Data).Posts;
// a conversation with a newer message is the same item, moved up with its new message
var newer = Filter.TimelineType is TimelineType.Direct
? page.Where(p => posts.Concat(pending).All(shown => shown.Key != p.Key || shown.CursorId != p.CursorId)).ToList()
: page.Where(p => known.Add(p.Key)).ToList();
if (newer.Count == 0) if (newer.Count == 0)
continue; continue;
posts.RemoveAll(p => newer.Any(n => n.Key == p.Key));
pending.RemoveAll(p => newer.Any(n => n.Key == p.Key));
pending.InsertRange(0, newer); pending.InsertRange(0, newer);
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -307,11 +417,93 @@
} }
} }
/// <summary>On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge
/// counts what is unread.</summary>
async Task ReadNotifications(CancellationToken cancellationToken)
{
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
if (Filter.TimelineType is not TimelineType.Notifications)
{
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
return;
}
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
if (newest is not null)
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0;
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
}
/// <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
{
TimelineType.Home => new("user"),
TimelineType.Local => new("public:local"),
TimelineType.Federation => new("public:remote"),
TimelineType.Notifications => new("user:notification"),
TimelineType.Tag when filter.Tag is { Length: > 0 } tag => new("hashtag", Tag: tag),
TimelineType.Lists when filter.ListId is { Length: > 0 } list => new("list", List: list),
_ => null
};
/// <summary>What the stream tells: a new post or notification waits behind the "new" button; a deletion or an edit is
/// applied where the post is.</summary>
async Task OnStream(StreamMessage message)
{
switch (message.Event)
{
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
var post = FeedPost.Of(status);
if (posts.Concat(pending).Any(p => p.Key == post.Key))
return;
pending.Insert(0, post);
break;
case "notification" when message.Notification is { } notification && Filter.TimelineType is TimelineType.Notifications:
// a notification of a group already shown joins it, and the group waits on top as new
var key = notification.GroupKey ?? notification.Id;
var group = pending.FirstOrDefault(p => p.Key == key) ?? posts.FirstOrDefault(p => p.Key == key);
if (group is null)
{
pending.Insert(0, FeedPost.Of(notification));
break;
}
group.GroupCount++;
group.Notification = notification;
group.GroupAccounts = [.. group.GroupAccounts.Prepend(notification.Account).Where(a => a is not null).DistinctBy(a => a.Id).Take(8)];
if (posts.Remove(group))
pending.Insert(0, group);
break;
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
unreadNotifications++;
break;
case "delete" when message.DeletedId is { } id:
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
pending.RemoveAll(p => p.Key == id || p.Status?.Id == id);
break;
case "status.update" when message.Status is { } edited:
foreach (var shown in posts.Concat(pending).Where(p => p.Status?.Id == edited.Id))
shown.Status = edited;
break;
default:
return;
}
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() async Task ShowPending()
{ {
var shown = pending; var shown = pending;
pending = []; pending = [];
posts.InsertRange(0, shown); posts.InsertRange(0, shown);
await ReadNotifications(loadCancellation?.Token ?? default);
minId = shown.FirstOrDefault()?.CursorId ?? minId; minId = shown.FirstOrDefault()?.CursorId ?? minId;
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId); await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
await Locate(shown, loadCancellation?.Token ?? default); await Locate(shown, loadCancellation?.Token ?? default);
@@ -385,6 +577,7 @@
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null)); yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null); yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null); yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
yield return NerdRow.Of(Localizer["stream"], Streaming.IsOpen ? Localizer["open"] : Localizer["closed, polling"]);
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval)); yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null); yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
if (RequestStats.Latest(1).FirstOrDefault() is { } last) if (RequestStats.Latest(1).FirstOrDefault() is { } last)
@@ -397,6 +590,9 @@
public override async ValueTask DisposeAsync() public override async ValueTask DisposeAsync()
{ {
Streaming.Received -= OnStream;
await Remember();
await Streaming.Stop();
await pageCancellation.CancelAsync(); await pageCancellation.CancelAsync();
pageCancellation.Dispose(); pageCancellation.Dispose();
loadCancellation?.Cancel(); loadCancellation?.Cancel();
@@ -416,6 +612,24 @@
await base.DisposeAsync(); await base.DisposeAsync();
} }
/// <summary>Keeps the feed for the way back (FeedMemory), when it shows something worth keeping.</summary>
async Task Remember()
{
if (IsLoading || loadError is { Length: > 0 } || posts.Count == 0 || CascadingState.PersonaId is null
|| Filter.TimelineType is TimelineType.Nearby)
return;
var scrollTop = 0d;
try
{
if (feedModule is not null)
scrollTop = await feedModule.InvokeAsync<double>("scrollOffset", ScrollId);
}
catch (JSDisconnectedException)
{
}
Memory.Save(Query, posts, pending, groups, lists, minId, maxId, scrollTop);
}
sealed class GeoAnswer sealed class GeoAnswer
{ {
public double? Latitude { get; set; } public double? Latitude { get; set; }
+334
View File
@@ -0,0 +1,334 @@
@page "/lists"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Lists"]</Title>
@* The current persona's lists (/api/v1/lists): made, renamed, set to show which replies and whether their members stay
out of the home feed, and deleted; their members added from the accounts the persona follows (PrivaPub accepts no
others, and drops a member when the follow ends) and removed. A list is read as a tab of the feed page. Lists 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">
<div class="flex items-center gap-2">
<i class="ion-md-list text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Lists"]</h1>
</div>
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
placeholder="@Localizer["A new list's title"]" data-list-new>
<Prefix><i class="ion-md-add"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
<span>@Localizer["Make a list"]</span>
</DButton>
</EditForm>
@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 (lists.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no list yet."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var list in lists)
{
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-list-id="@list.Id">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 truncate">@list.Title</b>
@if (list.Exclusive)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" title="@Localizer["Its members stay out of the home feed."]">
<i class="ion-md-eye-off"></i> @Localizer["Exclusive"]
</DBadge>
}
<DButton Size="DSize.Sm" OnClick="@(() => Read(list))" title="@Localizer["Read the list"]">
<i class="ion-md-paper"></i><span class="hidden sm:inline">@Localizer["Read the list"]</span>
</DButton>
<DButton Size="DSize.Sm" Active="@(open?.Id == list.Id)" OnClick="@(() => Toggle(list))" title="@Localizer["Edit"]">
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
</DButton>
</div>
@if (open?.Id == list.Id)
{
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
aria-label="@Localizer["Title"]" data-list-title />
<DSelect TValue="ListRepliesPolicy" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.RepliesPolicy">
<Prefix><i class="ion-md-undo"></i></Prefix>
<ChildContent>
@foreach (var policy in Policies)
{
<option value="@policy">@PolicyName(policy)</option>
}
</ChildContent>
</DSelect>
</div>
<DToggle Size="DSize.Sm" @bind-Value="edit.Exclusive" LabelText="@Localizer["Keep its members out of the home feed"]" />
<div class="flex flex-wrap gap-2">
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title))">
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
</DButton>
@if (isConfirmingDelete)
{
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
</DButton>
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
}
else
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
</DButton>
}
</div>
</EditForm>
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count</span></h2>
<div class="flex flex-col gap-2" data-list-members>
@if (members.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["Nobody is in this list yet."]</p>
}
@foreach (var account in members)
{
<div class="flex items-center gap-3 min-w-0" data-member-id="@account.Id">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<a class="flex flex-col min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
<b class="truncate text-sm">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</a>
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, false))"
title="@Localizer["Remove from the list"]">
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
</DButton>
</div>
}
</div>
<EditForm Model="find" OnValidSubmit="Find" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="find.Query" Immediate="true" autocomplete="off" type="search"
placeholder="@Localizer["Find accounts you follow"]" data-list-find>
<Prefix><i class="ion-md-search"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isFinding || string.IsNullOrWhiteSpace(find.Query))">
@if (isFinding)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Search"]</span>
</DButton>
</EditForm>
@if (found is not null)
{
<div class="flex flex-col gap-2" data-list-found>
@if (Candidates.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["Only accounts you follow can join a list, and none of them matches."]</p>
}
@foreach (var account in Candidates)
{
<div class="flex items-center gap-3 min-w-0" data-candidate-id="@account.Id">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate text-sm">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, true))">
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
</DButton>
</div>
}
</div>
}
}
</article>
}
</div>
</div>
</section>
@code {
static readonly ListRepliesPolicy[] Policies = [ListRepliesPolicy.List, ListRepliesPolicy.Followed, ListRepliesPolicy.None];
[Inject] ListService ListService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
List<MastodonList> lists = [];
MastodonList draft = new();
MastodonList edit = new();
MastodonList open;
List<Account> members = [];
List<Account> found;
SearchForm find = new();
string loadedPersonaId;
bool isSaving;
bool isFinding;
bool isConfirmingDelete;
List<Account> Candidates => found.Where(a => members.All(m => m.Id != a.Id)).ToList();
string PolicyName(ListRepliesPolicy policy) => policy switch
{
ListRepliesPolicy.Followed => Localizer["Replies to anyone you follow"],
ListRepliesPolicy.None => Localizer["No replies"],
_ => Localizer["Replies to the list's members"]
};
// The persona changed (NavMenu's switcher): its lists are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
open = null;
members = [];
found = null;
lists = await ListService.All();
IsLoading = false;
}
async Task Create()
{
isSaving = true;
var result = await ListService.Save(new MastodonList { Title = draft.Title });
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var list = (MastodonList)result.Data;
lists.Add(list);
lists = [.. lists.OrderBy(l => l.Title, StringComparer.CurrentCultureIgnoreCase)];
draft = new();
Toast.AddResponseSuccess(Localizer["List {0} made.", list.Title]);
await Toggle(list);
}
async Task Toggle(MastodonList list)
{
isConfirmingDelete = false;
found = null;
find = new();
if (open?.Id == list.Id)
{
open = null;
members = [];
return;
}
open = list;
edit = new MastodonList { Id = list.Id, Title = list.Title, RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive };
members = await ListService.Members(list.Id);
}
async Task Save()
{
isSaving = true;
var result = await ListService.Save(edit);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var saved = (MastodonList)result.Data;
var index = lists.FindIndex(l => l.Id == saved.Id);
if (index >= 0)
lists[index] = saved;
open = saved;
Toast.AddResponseSuccess(Localizer["List {0} saved.", saved.Title]);
}
async Task Delete()
{
isSaving = true;
var result = await ListService.Delete(open.Id);
isSaving = false;
isConfirmingDelete = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
lists.RemoveAll(l => l.Id == open.Id);
Toast.AddResponseSuccess(Localizer["List {0} deleted.", open.Title]);
open = null;
members = [];
}
async Task Find()
{
isFinding = true;
found = await ListService.FindFollowed(find.Query);
isFinding = false;
}
async Task SetMember(Account account, bool member)
{
isSaving = true;
var result = await ListService.SetMember(open.Id, account.Id, member);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
if (member)
members.Add(account);
else
members.RemoveAll(m => m.Id == account.Id);
Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, open.Title] : Localizer["{0} removed from {1}.", account.Name, open.Title]);
}
async Task Read(MastodonList list)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.TimelineType = TimelineType.Lists;
filter.ListId = list.Id;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
sealed class SearchForm
{
public string Query { get; set; }
}
}
+378
View File
@@ -0,0 +1,378 @@
@page "/account/{accountId}"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@(account?.Name ?? Localizer["Profile"])</Title>
@* 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. *@
<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">
<div class="flex items-center gap-2">
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
<i class="ion-md-arrow-back"></i>
</DButton>
<h1 class="font-bold truncate">@Localizer["Profile"]</h1>
</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 (account is null)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This account doesn't exist."]
</p>
}
else
{
@if (account.Moved is { } moved)
{
<p class="flex flex-wrap items-center gap-2 p-3 rounded-xl neo-inset neo-2xs text-sm" data-moved>
<i class="ion-md-swap"></i>
<span>@Localizer["{0} has moved to", account.Name]</span>
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
</p>
}
@* 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"
src="@(account.Avatar ?? "imgs/icon-192.png")" />
<div class="flex flex-col min-w-0 flex-1 pb-1">
<b class="text-lg truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span>
</div>
@if (!IsOwn)
{
<div class="flex flex-wrap gap-2 pb-1">
@if (relationship?.FollowedBy == true)
{
<DBadge Size="DSize.Sm" class="self-center">@Localizer["Follows you"]</DBadge>
}
@if (relationship?.Blocking != true)
{
<DButton Size="DSize.Sm" Color="@(relationship?.Following == true || relationship?.Requested == true ? DColor.Neutral : DColor.Primary)"
Disabled="@(IsDefaultDisabled || isActing || relationship is null)" OnClick="ToggleFollow">
@FollowLabel
</DButton>
}
@if (relationship?.Following == true)
{
<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>
</Trigger>
<ChildContent>
@if (allLists.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
}
@foreach (var list in allLists)
{
<label class="label text-sm" data-list-id="@list.Id">
<input type="checkbox" class="checkbox checkbox-sm" checked="@inLists.Contains(list.Id)" disabled="@(IsDefaultDisabled || isActing)"
@onchange="@((ChangeEventArgs e) => SetListMember(list, e.Value is true))" />
<span class="truncate">@list.Title</span>
</label>
}
<a class="btn btn-xs" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
</ChildContent>
</DDropdown>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
<span>@(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"])</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Blocking == true ? AccountAction.Unblock : AccountAction.Block))">
<i class="ion-md-remove-circle text-error"></i>
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i class="ion-md-flag text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
</div>
@if (isReporting)
{
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
}
}
</div>
<div class="flex flex-wrap gap-2 px-4 pt-2">
@if (account.Locked)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-lock"></i> @Localizer["Locked account"]</DBadge>
}
@if (account.Bot)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-cog"></i> @Localizer["Bot"]</DBadge>
}
@if (account.Group)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge>
}
</div>
@if (!string.IsNullOrWhiteSpace(account.Note))
{
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))</div>
}
@if (account.Fields.Count > 0)
{
<dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm">
@foreach (var field in account.Fields)
{
<dt class="font-semibold truncate">@CustomEmojis.Text(field.Name, account.Emojis)</dt>
<dd class="post-content break-words min-w-0">@((MarkupString)CustomEmojis.Html(field.Value, account.Emojis))</dd>
}
</dl>
}
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
</div>
</article>
<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>
<DTab><Header>@Localizer["Media"]</Header></DTab>
</DTabs>
<div class="flex flex-col gap-4">
@if (tab is ProfileTab.Posts && pinned.Count > 0)
{
<div class="flex flex-col gap-4" data-pinned>
<span class="badge badge-ghost gap-1 self-start"><i aria-hidden="true" class="ion-md-pin"></i>@Localizer["Pinned"]</span>
@foreach (var post in pinned)
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
}
</div>
}
@foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key)))
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
}
@if (posts.Count == 0 && !isLoadingMore)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
}
@if (maxId is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore">
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
}
</div>
</section>
@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; }
[Inject] IJSRuntime JS { get; set; }
[Parameter] public string AccountId { get; set; }
Account account;
Relationship relationship;
List<FeedPost> posts = [];
List<FeedPost> pinned = [];
HashSet<string> pinnedKeys = [];
ProfileTab tab = ProfileTab.Posts;
string maxId;
string loadedId;
string loadedPersonaId;
bool isActing;
bool isLoadingMore;
bool isListMenuOpen;
bool isReporting;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
bool IsOwn => account is not null && account.Id == CascadingState.PersonaId;
string FullAcct => account.Acct.Contains('@') ? account.Acct : $"{account.Acct}@{account.Host}";
string HeaderStyle => string.IsNullOrEmpty(account?.Header) ? null : $"background-image:url('{account.Header}')";
string FollowLabel => relationship switch
{
{ Following: true } => Localizer["Unfollow"],
{ Requested: true } => Localizer["Cancel the request"],
_ => Localizer["Follow"]
};
protected override async Task OnParametersSetAsync()
{
if (AccountId == loadedId && CascadingState.PersonaId == loadedPersonaId)
return;
loadedId = AccountId;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
account = await AccountService.Get(AccountId);
if (account is null)
{
IsLoading = false;
return;
}
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
await LoadLists();
pinned = await AccountService.Pinned(account.Id);
pinnedKeys = [.. pinned.Select(p => p.Key)];
posts = [];
maxId = null;
IsLoading = false;
await LoadPage();
}
/// <summary>The persona's lists and which of them hold the account, for an account the persona follows.</summary>
async Task LoadLists()
{
if (relationship?.Following != true)
{
allLists = [];
inLists = [];
return;
}
allLists = await ListService.All();
inLists = [.. (await ListService.Of(account.Id)).Select(l => l.Id)];
}
async Task SetListMember(MastodonList list, bool member)
{
isActing = true;
var result = await ListService.SetMember(list.Id, account.Id, member);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
if (member)
inLists.Add(list.Id);
else
inLists.Remove(list.Id);
Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, list.Title] : Localizer["{0} removed from {1}.", account.Name, list.Title]);
}
async Task LoadPage()
{
isLoadingMore = true;
var result = await AccountService.Statuses(account.Id, tab, maxId);
isLoadingMore = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var page = (FeedPage)result.Data;
var known = posts.Select(p => p.Key).ToHashSet();
posts.AddRange(page.Posts.Where(p => !known.Contains(p.Key)));
maxId = page.MaxId;
await InstanceService.Resolve(page.Posts.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(page.Posts);
}
async Task LoadMore() => await LoadPage();
async Task SelectTab(int index)
{
tab = (ProfileTab)index;
posts = [];
maxId = null;
await LoadPage();
}
async Task ToggleFollow() =>
await Do(relationship is { Following: true } or { Requested: true } ? AccountAction.Unfollow : AccountAction.Follow);
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;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
if (action is AccountAction.Follow or AccountAction.Unfollow)
await LoadLists();
Toast.AddResponseSuccess(action switch
{
AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name],
AccountAction.Follow => Localizer["Following {0}.", account.Name],
AccountAction.Unfollow => Localizer["{0} unfollowed.", account.Name],
AccountAction.Block => Localizer["{0} blocked.", account.Name],
AccountAction.Unblock => Localizer["{0} unblocked.", account.Name],
AccountAction.Mute => Localizer["{0} muted.", account.Name],
_ => Localizer["{0} unmuted.", account.Name]
});
}
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
pinned.RemoveAll(p => p.Key == post.Key);
}
async Task OpenTag(string tagName)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tagName;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
async Task Back() => await JS.InvokeVoidAsync("history.back");
}
+147
View File
@@ -0,0 +1,147 @@
@page "/scheduled"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Scheduled posts"]</Title>
@* The current persona's posts waiting for their time (/api/v1/scheduled_statuses): moved to another time, or dropped so
they are never published. The composer schedules them. *@
<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">
<div class="flex items-center gap-2">
<i class="ion-md-time text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Scheduled posts"]</h1>
</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 (scheduled.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is waiting to be published."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var item in scheduled)
{
<article class="flex flex-col gap-2 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-scheduled-id="@item.Id">
<div class="flex flex-wrap items-center gap-2 text-sm">
<i class="ion-md-time"></i>
<b>@item.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<span class="opacity-70">@VisibilityName(item.Params.Visibility)</span>
@if (item.MediaAttachments.Count > 0)
{
<span class="opacity-70"><i class="ion-md-images"></i> @item.MediaAttachments.Count</span>
}
</div>
@if (item.Params.SpoilerText is { Length: > 0 } warning)
{
<p class="text-sm"><i class="ion-md-warning text-warning"></i> @warning</p>
}
<p class="text-sm whitespace-pre-wrap break-words">@item.Params.Text</p>
<div class="flex flex-wrap items-center gap-2">
<input type="datetime-local" class="input input-sm w-auto" autocomplete="off" aria-label="@Localizer["Publish it on"]"
value="@(moves.GetValueOrDefault(item.Id) ?? item.ScheduledAt.ToLocalTime().ToString("yyyy-MM-ddTHH:mm"))"
@onchange="@((ChangeEventArgs e) => moves[item.Id] = e.Value?.ToString())" />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving || !moves.ContainsKey(item.Id))" OnClick="@(() => Move(item))">
<i class="ion-md-checkmark"></i><span>@Localizer["Move"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Drop(item))">
<i class="ion-md-trash text-error"></i><span>@Localizer["Never publish it"]</span>
</DButton>
</div>
</article>
}
</div>
</div>
</section>
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ScheduledStatus> scheduled = [];
readonly Dictionary<string, string> moves = [];
string loadedPersonaId;
bool isSaving;
string VisibilityName(string visibility) => visibility switch
{
"unlisted" => Localizer["Unlisted"],
"private" => Localizer["Followers only"],
"direct" => Localizer["Direct"],
_ => Localizer["Public"]
};
// The persona changed (NavMenu's switcher): its scheduled posts are another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
moves.Clear();
scheduled = await StatusService.Scheduled();
IsLoading = false;
}
async Task Move(ScheduledStatus item)
{
if (!DateTime.TryParse(moves[item.Id], System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var local))
return;
isSaving = true;
var result = await StatusService.Reschedule(item.Id, local.ToUniversalTime());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var moved = (ScheduledStatus)result.Data;
moves.Remove(item.Id);
item.ScheduledAt = moved.ScheduledAt;
scheduled = [.. scheduled.OrderBy(s => s.ScheduledAt)];
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", moved.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
}
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;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
scheduled.Remove(item);
Toast.AddResponseSuccess(Localizer["It will not be published."]);
}
}
+132
View File
@@ -0,0 +1,132 @@
@page "/search"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Search"]</Title>
@* Accounts, posts and hashtags (/api/v2/search). A handle (name@host) or a post's address is resolved by PrivaPub,
which fetches it from its server if it has not met it yet; the browser never contacts another server. *@
<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">
<EditForm Model="form" OnValidSubmit="Run" class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Query" autocomplete="off" type="search"
placeholder="@Localizer["Search accounts, posts and tags, or paste an address"]">
<Prefix><i class="ion-md-search"></i></Prefix>
</DInput>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSearching)">
@if (isSearching)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Search"]</span>
</DButton>
</EditForm>
@if (searched)
{
@if (results.Accounts.Count == 0 && results.Statuses.Count == 0 && results.Hashtags.Count == 0)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing found."]</p>
}
@if (results.Accounts.Count > 0)
{
<h2 class="font-bold">@Localizer["Accounts"]</h2>
<div class="flex flex-col gap-2">
@foreach (var account in results.Accounts)
{
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
</div>
}
@if (results.Hashtags.Count > 0)
{
<h2 class="font-bold">@Localizer["Hashtags"]</h2>
<div class="flex flex-wrap gap-2">
@foreach (var tag in results.Hashtags)
{
<DButton Size="DSize.Sm" OnClick="@(() => OpenTag(tag.Name))">#@tag.Name</DButton>
}
</div>
}
@if (results.Statuses.Count > 0)
{
<h2 class="font-bold">@Localizer["Posts and replies"]</h2>
<div class="flex flex-col gap-4">
@foreach (var post in statuses)
{
<Content @key="post.Key" Post="post" OnTag="OpenTag" />
}
</div>
}
}
</div>
</section>
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
[SupplyParameterFromQuery(Name = "q")] public string Q { get; set; }
sealed class SearchForm
{
public string Query { get; set; }
}
SearchForm form = new();
SearchResults results = new();
List<FeedPost> statuses = [];
bool searched;
bool isSearching;
protected override async Task OnInitializedAsync()
{
IsLoading = false;
if (string.IsNullOrWhiteSpace(Q))
return;
form.Query = Q;
await Run();
}
async Task Run()
{
if (string.IsNullOrWhiteSpace(form.Query))
return;
isSearching = true;
// a handle or an address is something to resolve; plain words only search what PrivaPub already holds
var resolve = form.Query.Contains('@') || form.Query.StartsWith("http", StringComparison.OrdinalIgnoreCase);
results = await AccountService.Search(form.Query, resolve);
statuses = [.. results.Statuses.Select(FeedPost.Of)];
searched = true;
isSearching = false;
await InstanceService.Resolve(statuses.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(statuses);
// a handle opens its account's profile, whatever else matched
var handle = form.Query.Trim().TrimStart('@');
if (form.Query.Contains('@') && results.Accounts.FirstOrDefault(a => string.Equals(a.Acct, handle, StringComparison.OrdinalIgnoreCase)
|| string.Equals($"{a.Acct}@{InstanceService.SelfHost}", handle, StringComparison.OrdinalIgnoreCase)) is { } exact)
Navigation.NavigateTo($"account/{Uri.EscapeDataString(exact.Id)}");
else if (results.Accounts.Count == 1 && results.Statuses.Count == 0 && form.Query.Contains('@'))
Navigation.NavigateTo($"account/{Uri.EscapeDataString(results.Accounts[0].Id)}");
}
async Task OpenTag(string tagName)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tagName;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
}
+104 -9
View File
@@ -21,6 +21,9 @@
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" /> ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray" <DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" /> ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
data-remind-alt-text />
<label class="flex flex-col gap-2 max-w-md"> <label class="flex flex-col gap-2 max-w-md">
<span class="text-sm">@Localizer["Language"]</span> <span class="text-sm">@Localizer["Language"]</span>
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@ @* Language names stay in their own language. Changing it reloads the app with the new culture. *@
@@ -46,6 +49,60 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-funnel text-xl"></i> <span>@Localizer["Filters"]</span>
</p>
</Header>
<ChildContent>
<div class="flex flex-wrap items-center gap-3 w-full p-3 md:p-4">
<p class="flex-1 min-w-48 text-sm">@Localizer["Words, phrases and posts this persona would rather not see, and where."]</p>
<a class="btn btn-sm" href="filters"><i class="ion-md-funnel"></i><span>@Localizer["Manage filters"]</span></a>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-account>
<i class="ion-md-key text-xl"></i> <span>@Localizer["Login"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<AccountEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-people text-xl"></i> <span>@Localizer["Personas"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<PersonasEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
@@ -54,9 +111,14 @@
</Header> </Header>
<ChildContent> <ChildContent>
<div class="block w-full p-3 md:p-4"> <div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg"> @if (CascadingState.IsSignedIn)
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"] {
</p> <ProfileEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div> </div>
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
@@ -69,9 +131,37 @@
</Header> </Header>
<ChildContent> <ChildContent>
<div class="block w-full p-3 md:p-4"> <div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg"> @if (CascadingState.IsSignedIn)
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"] {
</p> <div class="flex flex-col gap-6">
<PersonaArchivePanel />
<DataTransfer />
</div>
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-notifications text-xl"></i> <span>@Localizer["Notifications"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<NotificationPolicyEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div> </div>
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
@@ -84,9 +174,14 @@
</Header> </Header>
<ChildContent> <ChildContent>
<div class="block w-full p-3 md:p-4"> <div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg"> @if (CascadingState.IsSignedIn)
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"] {
</p> <MutesAndBlocks />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div> </div>
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
+14
View File
@@ -40,7 +40,19 @@ builder.Services.AddOptions()
.AddScoped<RequestStats>() .AddScoped<RequestStats>()
.AddScoped<AuthService>() .AddScoped<AuthService>()
.AddScoped<TimelineService>() .AddScoped<TimelineService>()
.AddScoped<FeedMemory>()
.AddScoped<StatusService>() .AddScoped<StatusService>()
.AddScoped<AccountService>()
.AddScoped<PortabilityService>()
.AddScoped<AdminService>()
.AddScoped<ArchiveService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
.AddScoped<DiscoveryService>()
.AddScoped<StreamingService>()
.AddScoped<PublicNoticeService>()
.AddScoped<ConfirmService>()
.AddScoped<OverlayService>()
.AddScoped<MediaService>() .AddScoped<MediaService>()
.AddScoped<GroupService>() .AddScoped<GroupService>()
.AddScoped<InstanceService>() .AddScoped<InstanceService>()
@@ -60,6 +72,8 @@ builder.Services.AddHttpClient("default", client =>
{ {
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/"); client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 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 => builder.Services.AddSingleton(serviceProvider =>
File diff suppressed because it is too large. Load diff
+1461 -21
View File
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>What the current persona does to and learns of other accounts.</summary>
public enum AccountAction
{
Follow,
Unfollow,
Block,
Unblock,
Mute,
Unmute
}
/// <summary>Which of an account's posts its profile lists.</summary>
public enum ProfileTab
{
Posts,
PostsAndReplies,
Media
}
/// <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
/// 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)
{
const int PageSize = 20;
public async Task<Account> Get(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// <summary>An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet.</summary>
public async Task<Account> Lookup(string acct, CancellationToken cancellationToken = default)
{
acct = acct?.Trim().TrimStart('@');
if (string.IsNullOrEmpty(acct))
return null;
using var response = await httpService.Get(APIs.Mastodon.GET_AccountLookup, [$"acct={Uri.EscapeDataString(acct)}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
var found = await Search(acct, resolve: true, cancellationToken);
return found.Accounts.FirstOrDefault(a => string.Equals(a.Acct, acct, StringComparison.OrdinalIgnoreCase))
?? found.Accounts.FirstOrDefault();
}
public async Task<Relationship> Relationship(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [$"id[]={Uri.EscapeDataString(accountId)}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return (await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []).FirstOrDefault();
await LogFailure(response, nameof(Relationship), cancellationToken);
return null;
}
/// <summary>The relationships of many accounts at once, 40 a request.</summary>
public async Task<List<Relationship>> Relationships(IReadOnlyList<string> accountIds, CancellationToken cancellationToken = default)
{
var relationships = new List<Relationship>();
foreach (var batch in accountIds.Chunk(40))
{
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [.. batch.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Relationships), cancellationToken);
return relationships;
}
relationships.AddRange(await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []);
}
return relationships;
}
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
new[] { new KeyValuePair<string, string>("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action.ToString().ToLowerInvariant()),
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 account's posts, newest first; Data is a FeedPage.</summary>
public async Task<WebResult> Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={PageSize}" };
parameters.AddRange(tab switch
{
ProfileTab.Posts => ["exclude_replies=true"],
ProfileTab.Media => ["only_media=true"],
_ => Array.Empty<string>()
});
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), [.. parameters],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
var (nextMaxId, _) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. statuses.Select(FeedPost.Of)],
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id
}
};
}
/// <summary>The posts the account pins, in its order: what it features, for an account elsewhere.</summary>
public async Task<List<FeedPost>> Pinned(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), ["pinned=true"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return [.. (await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? []).Select(FeedPost.Of)];
await LogFailure(response, nameof(Pinned), cancellationToken);
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>Accepts (authorize) or refuses (reject) a follow request.</summary>
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_FollowRequest(accountId, accept ? "authorize" : "reject"),
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
/// <summary>Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`.</summary>
public async Task<SearchResults> Search(string query, bool resolve, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new SearchResults();
using var response = await httpService.Get(APIs.Mastodon.GET_Search,
[$"q={Uri.EscapeDataString(query.Trim())}", $"resolve={(resolve ? "true" : "false")}", "limit=20"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<SearchResults>(cancellationToken) ?? new SearchResults();
await LogFailure(response, nameof(Search), cancellationToken);
return new SearchResults();
}
/// <summary>Accounts or hashtags this server already knows that match what is being typed, for the composer: nothing
/// is fetched from elsewhere.</summary>
public async Task<SearchResults> Suggest(string typed, bool hashtags, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Search,
[$"q={Uri.EscapeDataString(typed)}", $"type={(hashtags ? "hashtags" : "accounts")}", "resolve=false", "limit=5"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<SearchResults>(cancellationToken) ?? new SearchResults();
await LogFailure(response, nameof(Suggest), cancellationToken);
return new SearchResults();
}
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func<HttpContent>)form.Content,
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>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 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);
/// <summary>A page of the accounts the persona blocked; Data is an AccountPage.</summary>
public Task<WebResult> Blocks(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
async Task<WebResult> Accounts(string route, string maxId, CancellationToken cancellationToken)
{
var parameters = new List<string> { "limit=40" };
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
using var response = await httpService.Get(route, [.. parameters], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var accounts = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
var (nextMaxId, _) = response.PageCursors();
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
}
/// <summary>The servers the persona blocked: nothing from them reaches it, and its followers there are removed.</summary>
public async Task<List<string>> DomainBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_DomainBlocks, ["limit=200"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<string>>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
public async Task<WebResult> BlockDomain(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair<string, string>("domain", domain.Trim()) },
cancellationToken: cancellationToken);
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)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
{
AccountId = accountId,
StatusIds = statusIds,
Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(),
Forward = forward
}, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
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(AccountService)}.{caller}", cancellationToken);
}
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels;
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.
/// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The newest 100 reports, open or resolved.</summary>
public async Task<List<ViewReport>> Reports(bool resolved, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorReports, [$"resolved={(resolved ? "true" : "false")}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewReport>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Reports), cancellationToken);
return [];
}
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The servers blocked for everyone here, by name.</summary>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminDomainBlocks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewDomainBlock>>(cancellationToken) ?? [];
await LogFailure(response, nameof(ServerBlocks), cancellationToken);
return [];
}
/// <summary>Blocks a server for everyone, or changes how; Data is the block.</summary>
public async Task<WebResult> BlockServer(DomainBlockForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminDomainBlock, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewDomainBlock>(cancellationToken) };
}
public async Task<WebResult> UnblockServer(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminDomainUnblock(domain), cancellationToken: cancellationToken);
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);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewStatisticsOverview>(cancellationToken);
await LogFailure(response, nameof(Overview), cancellationToken);
return null;
}
/// <summary>One page of the servers we exchanged with; sort is traffic (default), failures, latency or host.</summary>
public async Task<ViewHostsPage> Hosts(int days, string sort, int page, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsHosts,
[$"days={days}", $"sort={Uri.EscapeDataString(sort ?? "")}", $"page={page}", "limit=50"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewHostsPage>(cancellationToken);
await LogFailure(response, nameof(Hosts), cancellationToken);
return null;
}
public async Task<ViewCrawler> Crawler(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsCrawler, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewCrawler>(cancellationToken);
await LogFailure(response, nameof(Crawler), 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.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AdminService)}.{caller}", cancellationToken);
}
}
}
+15
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Components;
using decePubClient.Models; using decePubClient.Models;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -41,6 +42,20 @@ public class AppStatusService(IJSRuntime jsRuntime)
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia"); public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
// whether the navigation being decided came from the Back (or Forward) button
public bool TookBack() => JsSyncRuntime.Invoke<bool>("tookBack");
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
// read once: a card asks it for each gifv it shows
bool? reducedMotion;
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
// a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff"); public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) => public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
+84
View File
@@ -0,0 +1,84 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
namespace decePubClient.Services
{
/// <summary>
/// A persona's whole archive on PrivaPub (/clientapi/persona/{id}/archive): made in a job and downloaded through a
/// link, or another archive (its own, or Mastodon's) uploaded in pieces by js/transfer.js and imported into it, the
/// parts chosen, until it is done or asked to stop. As the root. Values null on failure; nothing throws.
/// </summary>
public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<ViewPersonaArchive> State(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken);
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ArchiveService)}.{nameof(State)}", cancellationToken);
return null;
}
/// <summary>Asks for the archive to be made; Data is the state.</summary>
public async Task<WebResult> Export(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
}
/// <summary>A link to download the archive made; Data is the ViewArchiveTicket.</summary>
public async Task<WebResult> Ticket(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveTicket(personaId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveTicket>(cancellationToken) };
}
/// <summary>An upload for an archive's pieces; Data is the ViewArchiveUpload.</summary>
public async Task<WebResult> StartUpload(CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_PersonaArchiveUpload, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewArchiveUpload>(cancellationToken) };
}
public async Task<WebResult> CancelUpload(string uploadId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_PersonaArchiveUpload(uploadId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The uploaded archive imported into the persona; Data is the state.</summary>
public async Task<WebResult> Import(string personaId, ImportPersonaArchiveForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImport(personaId), form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPersonaArchive>(cancellationToken) };
}
public async Task<WebResult> Stop(string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImportStop(personaId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
}
}
+31
View File
@@ -52,6 +52,16 @@ namespace decePubClient.Services
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? []; return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
} }
/// <summary>Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere.</summary>
public async Task<WebResult> CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary> /// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default) public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{ {
@@ -104,6 +114,27 @@ namespace decePubClient.Services
await authStateProvider.SetSession(jwtUser, cancellationToken); await authStateProvider.SetSession(jwtUser, cancellationToken);
} }
/// <summary>Changes the root's password, the current one given.</summary>
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Deletes the root, its password given: every persona of it and everything public they had, here and, as
/// PrivaPub tells them, on the servers that hold it. The caller signs out afterwards.</summary>
public async Task<WebResult> DeleteAccount(RemoveSelfForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteAccount, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary> /// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
public async Task Logout(CancellationToken cancellationToken = default) public async Task Logout(CancellationToken cancellationToken = default)
{ {
+40
View File
@@ -0,0 +1,40 @@
namespace decePubClient.Services
{
/// <summary>
/// Asks before what can't be undone (deleting a post, blocking, leaving a group, signing out): Ask shows the question
/// in ConfirmHost (MainLayout) and returns whether the reader confirmed it. One question at a time; a second one asked
/// while the first is open answers the first no.
/// </summary>
public class ConfirmService
{
TaskCompletionSource<bool> pending;
public event Action Changed;
public bool IsAsking => pending is not null;
public string Heading { get; private set; }
public string Text { get; private set; }
public string ConfirmText { get; private set; }
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
public bool Danger { get; private set; }
public async Task<bool> Ask(string heading, string text, string confirmText, bool danger, CancellationToken cancellationToken = default)
{
pending?.TrySetResult(false);
var asking = new TaskCompletionSource<bool>();
pending = asking;
(Heading, Text, ConfirmText, Danger) = (heading, text, confirmText, danger);
Changed?.Invoke();
await using var cancel = cancellationToken.Register(() => asking.TrySetResult(false));
var confirmed = await asking.Task;
if (pending == asking)
{
pending = null;
Changed?.Invoke();
}
return confirmed;
}
public void Answer(bool confirmed) => pending?.TrySetResult(confirmed);
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Which order the directory lists accounts in.</summary>
public enum DirectoryOrder
{
/// <summary>By their latest public post.</summary>
Active,
/// <summary>By the day they say they joined.</summary>
New
}
/// <summary>
/// What there is to find on PrivaPub: hashtags (their week, following them), what is trending there (hashtags, posts
/// and links, from public posts it already holds) and the directory of accounts that let themselves be discovered.
/// Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<Tag> Tag(string name, CancellationToken cancellationToken = default)
{
using var response = await httpService.GetAnon(APIs.Mastodon.GET_M_Tag(name), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Tag>(cancellationToken);
await LogFailure(response, nameof(Tag), cancellationToken);
return null;
}
/// <summary>Follows or stops following a hashtag; Data is the Tag.</summary>
public async Task<WebResult> FollowTag(string name, bool follow, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_TagAction(name, follow ? "follow" : "unfollow"), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Tag>(cancellationToken) };
}
public Task<List<Tag>> FollowedTags(CancellationToken cancellationToken = default) =>
List<Tag>(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken);
public Task<List<Tag>> TrendingTags(CancellationToken cancellationToken = default) =>
List<Tag>(APIs.Mastodon.GET_TrendingTags, ["limit=20"], nameof(TrendingTags), cancellationToken);
public Task<List<Status>> TrendingStatuses(CancellationToken cancellationToken = default) =>
List<Status>(APIs.Mastodon.GET_TrendingStatuses, ["limit=40"], nameof(TrendingStatuses), cancellationToken);
public Task<List<TrendLink>> TrendingLinks(CancellationToken cancellationToken = default) =>
List<TrendLink>(APIs.Mastodon.GET_TrendingLinks, ["limit=20"], nameof(TrendingLinks), cancellationToken);
public const int DirectoryPage = 40;
public Task<List<Account>> Directory(DirectoryOrder order, bool localOnly, int offset, CancellationToken cancellationToken = default) =>
List<Account>(APIs.Mastodon.GET_Directory,
[$"order={(order is DirectoryOrder.New ? "new" : "active")}", $"local={(localOnly ? "true" : "false")}", $"limit={DirectoryPage}", $"offset={offset}"],
nameof(Directory), cancellationToken);
async Task<List<T>> List<T>(string route, string[] parameters, string caller, CancellationToken cancellationToken)
{
using var response = await httpService.GetAnon(route, parameters, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<T>>(cancellationToken) ?? [];
await LogFailure(response, caller, cancellationToken);
return [];
}
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(DiscoveryService)}.{caller}", cancellationToken);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels.Group;
namespace decePubClient.Services
{
/// <summary>
/// The feed as the reader left it: its query, posts, waiting posts, cursors and scroll. Coming back from a thread or a
/// profile shows it again at once instead of reading it anew; newer posts come behind the "new" button as usual. Kept
/// ten minutes, and only for the same feed of the same persona.
/// </summary>
public class FeedMemory
{
public static readonly TimeSpan Keep = TimeSpan.FromMinutes(10);
public FeedQuery Query { get; private set; }
public List<FeedPost> Posts { get; private set; } = [];
public List<FeedPost> Pending { get; private set; } = [];
public List<ViewGroup> Groups { get; private set; } = [];
public List<MastodonList> Lists { get; private set; } = [];
public string MinId { get; private set; }
public string MaxId { get; private set; }
public double ScrollTop { get; private set; }
public DateTime SavedAt { get; private set; }
public void Save(FeedQuery query, List<FeedPost> posts, List<FeedPost> pending, List<ViewGroup> groups, List<MastodonList> lists,
string minId, string maxId, double scrollTop)
{
Query = query;
Posts = posts;
Pending = pending;
Groups = groups;
Lists = lists;
MinId = minId;
MaxId = maxId;
ScrollTop = scrollTop;
SavedAt = DateTime.UtcNow;
}
/// <summary>Whether what is kept is recent and belongs to this persona.</summary>
public bool Holds(string personaId) => Query is not null && Query.PersonaId == personaId && DateTime.UtcNow - SavedAt < Keep;
public void Forget()
{
Query = null;
Posts = [];
Pending = [];
}
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The current persona's filters (PrivaPub's /api/v2/filters): made, changed with their keywords, given posts, and
/// deleted. What they match comes on each status (Status.Filtered); the card applies it. Lists come back empty on
/// failure; nothing throws.
/// </summary>
public class FilterService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<List<Filter>> All(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Filters, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Filter>>(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(FilterService)}.{nameof(All)}", cancellationToken);
return [];
}
/// <summary>Makes the filter when filterId is null, or changes it; Data is the filter as PrivaPub keeps it.</summary>
public async Task<WebResult> Save(string filterId, FilterForm form, CancellationToken cancellationToken = default)
{
using var response = filterId is null
? await httpService.Post(APIs.Mastodon.POST_Filter, form, cancellationToken: cancellationToken)
: await httpService.Put(APIs.Mastodon.PUT_M_Filter(filterId), form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Filter>(cancellationToken) };
}
public async Task<WebResult> Delete(string filterId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Filter(filterId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Adds a post to the filter; Data is the FilterStatus.</summary>
public async Task<WebResult> AddStatus(string filterId, string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_FilterStatus(filterId), new[] { new KeyValuePair<string, string>("status_id", statusId) },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<FilterStatus>(cancellationToken) };
}
public async Task<WebResult> RemoveStatus(string filterStatusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_FilterStatus(filterStatusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
}
}
+64 -1
View File
@@ -5,11 +5,13 @@ using collAnon.Client.Services;
using decePubClient.Extensions; using decePubClient.Extensions;
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group; using PrivaPub.ClientModels.Group;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>The communities and circles a persona belongs to (/clientapi/group/list).</summary> /// <summary>The persona's communities and circles (/clientapi/group): made, joined by invitation, edited and left;
/// for their owners and moderators, who is in them and who asks to join.</summary>
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{ {
/// <summary>The persona's groups, communities first; empty on failure.</summary> /// <summary>The persona's groups, communities first; empty on failure.</summary>
@@ -31,5 +33,66 @@ namespace decePubClient.Services
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? []; var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)]; return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
} }
/// <summary>Makes a community or a circle owned by the persona; Data is the ViewGroup.</summary>
public Task<WebResult> Insert(InsertGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_InsertGroup, form, cancellationToken);
/// <summary>Changes a group the persona runs; Data is the ViewGroup.</summary>
public Task<WebResult> Update(UpdateGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_UpdateGroup, form, cancellationToken);
/// <summary>Joins a group by its invitation (and its password, when it has one); Data is the ViewGroup.</summary>
public Task<WebResult> Join(JoinGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_JoinGroup, form, cancellationToken);
public Task<WebResult> Leave(string personaId, string groupId, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_LeaveGroup, personaId, groupId, null, cancellationToken);
/// <summary>Lets in someone who asked to join a group the persona runs.</summary>
public Task<WebResult> Approve(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_ApproveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Declines a request to join; the asker's server is told.</summary>
public Task<WebResult> Reject(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RejectGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Takes a member out of a group the persona runs.</summary>
public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary>
public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_GroupMembers,
[$"avatarId={Uri.EscapeDataString(personaId)}", $"groupId={Uri.EscapeDataString(groupId)}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(GroupService)}.{nameof(Members)}", cancellationToken);
return [];
}
return await response.DefaultReadFromJsonAsync<List<ViewGroupMember>>(cancellationToken) ?? [];
}
async Task<WebResult> GroupResult(string route, object form, CancellationToken cancellationToken)
{
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewGroup>(cancellationToken) };
}
async Task<WebResult> Membership(string route, string personaId, string groupId, string actorUri, CancellationToken cancellationToken)
{
using var response = await httpService.Post(route, new GroupMembershipForm { AvatarId = personaId, GroupId = groupId, MemberActorURI = actorUri },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
} }
} }
+25 -1
View File
@@ -37,6 +37,10 @@ namespace decePubClient.Services
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default); Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false, Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -78,6 +82,12 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken); Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) => public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError, Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
cancellationToken); cancellationToken);
@@ -300,10 +310,24 @@ namespace decePubClient.Services
} }
/// <summary>Sends the request and records it in RequestStats.</summary> /// <summary>Sends the request and records it in RequestStats.</summary>
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken) async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
var started = Stopwatch.GetTimestamp(); var started = Stopwatch.GetTimestamp();
var response = await httpClient.SendAsync(request, cancellationToken); HttpResponseMessage response;
try
{
response = await httpClient.SendAsync(request, timeout.Token);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
}
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds; var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode, requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset"))); elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
+127
View File
@@ -0,0 +1,127 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The current persona's lists (Mastodon's /api/v1/lists): made, renamed and deleted, their members (only accounts the
/// persona follows; PrivaPub drops a member when the follow ends), and the lists an account is in. A list's posts are a
/// feed of their own (TimelineService, TimelineType.Lists). Lists come back empty on failure; nothing throws.
/// </summary>
public class ListService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The most members PrivaPub keeps in one list, and so the most read back.</summary>
public const int MaxMembers = 500;
const int MembersPerPage = 80;
public async Task<List<MastodonList>> All(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Lists, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<MastodonList>>(cancellationToken) ?? [];
await LogFailure(response, nameof(All), cancellationToken);
return [];
}
/// <summary>Makes the list when it has no id yet, or saves its title, replies policy and exclusivity; Data is the list.</summary>
public async Task<WebResult> Save(MastodonList list, CancellationToken cancellationToken = default)
{
var form = new MastodonList { Title = list.Title?.Trim(), RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive };
using var response = list.Id is null
? await httpService.Post(APIs.Mastodon.POST_List, form, cancellationToken: cancellationToken)
: await httpService.Put(APIs.Mastodon.PUT_M_List(list.Id), form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<MastodonList>(cancellationToken) };
}
public async Task<WebResult> Delete(string listId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_List(listId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Every member of the list, page by page, up to MaxMembers.</summary>
public async Task<List<Account>> Members(string listId, CancellationToken cancellationToken = default)
{
var members = new List<Account>();
var maxId = default(string);
while (members.Count < MaxMembers)
{
string[] parameters = maxId is null ? [$"limit={MembersPerPage}"] : [$"limit={MembersPerPage}", $"max_id={Uri.EscapeDataString(maxId)}"];
using var response = await httpService.Get(APIs.Mastodon.M_ListAccounts(listId), parameters, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Members), cancellationToken);
return members;
}
var page = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
members.AddRange(page);
maxId = response.PageCursors().MaxId;
if (page.Count < MembersPerPage || maxId is null)
return members;
}
return members;
}
/// <summary>Adds (or removes) an account the persona follows to (from) the list.</summary>
public async Task<WebResult> SetMember(string listId, string accountId, bool member, CancellationToken cancellationToken = default)
{
using var response = member
? await httpService.Post(APIs.Mastodon.M_ListAccounts(listId), new ListAccountsForm { AccountIds = [accountId] },
cancellationToken: cancellationToken)
: await httpService.Delete(APIs.Mastodon.M_ListAccounts(listId), [$"account_ids[]={Uri.EscapeDataString(accountId)}"],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>The persona's lists the account is in.</summary>
public async Task<List<MastodonList>> Of(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountLists(accountId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<MastodonList>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Of), cancellationToken);
return [];
}
/// <summary>Accounts the persona follows whose name, display name or server starts with the query (PrivaPub's
/// following=true; nothing is resolved).</summary>
public async Task<List<Account>> FindFollowed(string query, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return [];
using var response = await httpService.Get(APIs.Mastodon.GET_AccountSearch,
[$"q={Uri.EscapeDataString(query.Trim())}", "following=true", "resolve=false", "limit=20"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FindFollowed), cancellationToken);
return [];
}
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(ListService)}.{caller}", cancellationToken);
}
}
}
+59 -5
View File
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary> /// <summary>
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
/// </summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer) public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{ {
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary> static readonly MediaConfiguration Fallback = new()
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default) {
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
ImageSizeLimit = 16 * 1024 * 1024,
VideoSizeLimit = 99 * 1024 * 1024
};
MediaConfiguration limits;
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
{
if (limits is not null)
return limits;
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
return limits = instance.Configuration.MediaAttachments;
return Fallback;
}
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() => using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{ {
var file = new ByteArrayContent(media.Blob); var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType); file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } }; var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description) if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description"); content.Add(new StringContent(description), "description");
@@ -29,6 +54,35 @@ namespace decePubClient.Services
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
/// when it can't be.</summary>
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
if (response.StatusCode == HttpStatusCode.PartialContent)
return new WebResult();
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken); var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
+64
View File
@@ -0,0 +1,64 @@
using decePubClient.Components.Daisy;
namespace decePubClient.Services
{
/// <summary>
/// The overlays open now, newest last (IDOverlayHost): MainLayout closes the newest when the Back button is pressed
/// while one is open, and all of them when the app goes elsewhere.
/// </summary>
public class OverlayService : IDOverlayHost
{
readonly List<Entry> open = [];
public bool HasOpen
{
get
{
lock (open)
return open.Count > 0;
}
}
public IDisposable Open(Func<Task> close)
{
var entry = new Entry(this, close);
lock (open)
open.Add(entry);
return entry;
}
public async Task CloseTop(CancellationToken cancellationToken = default)
{
Entry top;
lock (open)
top = open.LastOrDefault();
if (top is null)
return;
top.Dispose();
await top.Close();
}
public async Task CloseAll(CancellationToken cancellationToken = default)
{
Entry[] all;
lock (open)
{
all = [.. open];
open.Clear();
}
foreach (var entry in all.Reverse())
await entry.Close();
}
sealed class Entry(OverlayService owner, Func<Task> close) : IDisposable
{
public Func<Task> Close { get; } = close;
public void Dispose()
{
lock (owner.open)
owner.open.Remove(this);
}
}
}
}
+296
View File
@@ -0,0 +1,296 @@
using collAnon.Client.Services;
using System.Text;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>What a persona carries from one server to another, each in the CSV file Mastodon exports and imports.</summary>
public enum PortableData
{
Follows = 1,
Lists,
Blocks,
Mutes,
ServerBlocks,
Bookmarks
}
/// <summary>How an import went: how many rows were done, and the rows that could not be, with why.</summary>
public sealed class ImportReport
{
public int Total { get; set; }
public int Done { get; set; }
public List<string> Failed { get; set; } = [];
}
/// <summary>
/// The persona's follows, lists, blocks, mutes, blocked servers and bookmarks as Mastodon's CSV exports, written in
/// the browser from the Mastodon API, and the same files imported: each row resolved through PrivaPub (a remote
/// account or post is fetched by PrivaPub, never by the browser) and acted on one at a time. Nothing throws; an import
/// reports the rows it could not do.
/// </summary>
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
InstanceService instances, CoalescingStringLocalizer localizer)
{
/// <summary>The most rows read from an export or an import file.</summary>
public const int MaxRows = 5000;
const string AccountHeader = "Account address";
public static string FileName(PortableData data) => data switch
{
PortableData.Follows => "following_accounts.csv",
PortableData.Lists => "lists.csv",
PortableData.Blocks => "blocked_accounts.csv",
PortableData.Mutes => "muted_accounts.csv",
PortableData.ServerBlocks => "blocked_domains.csv",
_ => "bookmarks.csv"
};
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
sealed class Stop
{
public WebResult Failed { get; set; }
}
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
{
var stop = new Stop();
var rows = data switch
{
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(stop, cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, stop, cancellationToken)
};
if (stop.Failed is { } failed)
return failed;
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
}
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
Csv(AccountHeader, "Show boosts", "Notify on new posts", "Languages"),
.. followed.Select(a => relationships.GetValueOrDefault(a.Id) is { } r
? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), null)
: Csv(Full(a.Acct), "true", "false", null))
];
}
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
{
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
Csv(AccountHeader, "Hide notifications"),
.. muted.Select(a => Csv(Full(a.Acct), Flag(relationships.GetValueOrDefault(a.Id)?.MutingNotifications ?? true)))
];
}
async Task<List<string>> Lists(CancellationToken cancellationToken)
{
var rows = new List<string>();
foreach (var list in await lists.All(cancellationToken))
rows.AddRange((await lists.Members(list.Id, cancellationToken)).Select(member => Csv(list.Title, Full(member.Acct))));
return rows;
}
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
{
var rows = new List<string>();
var maxId = default(string);
do
{
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
if (result.IsInvalid || result.Data is not FeedPage page)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return rows;
}
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
maxId = page.Posts.Count == 0 ? null : page.MaxId;
}
while (maxId is not null && rows.Count < MaxRows);
return rows;
}
// every page of an account list, up to MaxRows
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
{
var all = new List<Account>();
var maxId = default(string);
do
{
var result = await page(maxId, cancellationToken);
if (result.IsInvalid || result.Data is not AccountPage accountPage)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return all;
}
all.AddRange(accountPage.Accounts);
maxId = accountPage.MaxId;
}
while (maxId is not null && all.Count < MaxRows);
return all;
}
/// <summary>Does what each row of an exported file says, one row at a time; progress counts the rows done.</summary>
public async Task<ImportReport> Import(PortableData data, string csv, IProgress<ImportReport> progress = default,
CancellationToken cancellationToken = default)
{
var rows = Parse(csv.TrimStart('\uFEFF')).Where(r => r.Count > 0 && !string.IsNullOrWhiteSpace(r[0])).ToList();
if (rows.Count > 0 && rows[0][0].Equals(AccountHeader, StringComparison.OrdinalIgnoreCase))
rows.RemoveAt(0);
var report = new ImportReport { Total = Math.Min(rows.Count, MaxRows) };
var known = data == PortableData.Lists ? await lists.All(cancellationToken) : [];
foreach (var row in rows.Take(MaxRows))
{
if (cancellationToken.IsCancellationRequested)
break;
var failure = data switch
{
PortableData.Follows => await FollowRow(row, cancellationToken),
PortableData.Lists => await ListRow(row, known, cancellationToken),
PortableData.Blocks => await AccountRow(row[0], AccountAction.Block, cancellationToken),
PortableData.Mutes => await AccountRow(row[0], AccountAction.Mute, cancellationToken),
PortableData.ServerBlocks => Failure(row[0], await accounts.BlockDomain(row[0], cancellationToken)),
_ => await BookmarkRow(row[0].Trim(), cancellationToken)
};
if (failure is null)
report.Done++;
else
report.Failed.Add(failure);
progress?.Report(report);
}
return report;
}
async Task<string> FollowRow(List<string> row, CancellationToken cancellationToken)
{
if (await accounts.Lookup(row[0], cancellationToken) is not { } account)
return Unknown(row[0]);
var showReblogs = row.Count < 2 || !row[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken));
}
async Task<string> AccountRow(string acct, AccountAction action, CancellationToken cancellationToken)
{
if (await accounts.Lookup(acct, cancellationToken) is not { } account)
return Unknown(acct);
return Failure(acct, await accounts.Act(account.Id, action, cancellationToken));
}
// a list row names the list and an account; the list is made when missing, and an account is followed first, since
// a list holds followed accounts only
async Task<string> ListRow(List<string> row, List<MastodonList> known, CancellationToken cancellationToken)
{
if (row.Count < 2 || string.IsNullOrWhiteSpace(row[1]))
return string.Join(',', row);
var title = row[0].Trim();
var list = known.FirstOrDefault(l => string.Equals(l.Title, title, StringComparison.Ordinal));
if (list is null)
{
var made = await lists.Save(new MastodonList { Title = title }, cancellationToken);
if (made.IsInvalid || made.Data is not MastodonList created)
return Failure(title, made) ?? title;
known.Add(list = created);
}
if (await accounts.Lookup(row[1], cancellationToken) is not { } account)
return Unknown(row[1]);
var relationship = await accounts.Relationship(account.Id, cancellationToken);
if (relationship is not { Following: true })
{
var followed = await accounts.Follow(account.Id, true, cancellationToken);
if (followed.IsInvalid)
return Failure(row[1], followed);
}
return Failure($"{title}: {row[1]}", await lists.SetMember(list.Id, account.Id, true, cancellationToken));
}
async Task<string> BookmarkRow(string uri, CancellationToken cancellationToken)
{
var found = await accounts.Search(uri, resolve: true, cancellationToken);
if ((found.Statuses.FirstOrDefault(s => s.Uri == uri || s.Url == uri) ?? found.Statuses.FirstOrDefault()) is not { } status)
return Unknown(uri);
return Failure(uri, await statuses.Act(status.Id, StatusAction.Bookmark, cancellationToken));
}
static string Unknown(string what) => $"{what}: ?";
static string Failure(string what, WebResult result) => result.IsInvalid ? $"{what}: {result.ErrorMessage}" : null;
// a local account's acct has no host; an export names every account in full
string Full(string acct) => acct.Contains('@') ? acct : $"{acct}@{instances.SelfHost}";
static string Flag(bool value) => value ? "true" : "false";
static string Csv(params string[] cells) => string.Join(',', cells.Select(c => c is null ? "" :
c.IndexOfAny([',', '"', '\n', '\r']) < 0 ? c : $"\"{c.Replace("\"", "\"\"")}\""));
// RFC 4180: quoted cells may hold commas, quotes (doubled) and line breaks
static IEnumerable<List<string>> Parse(string csv)
{
var row = new List<string>();
var cell = new StringBuilder();
var quoted = false;
for (var i = 0; i < csv.Length; i++)
{
var c = csv[i];
if (quoted)
{
if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"')
{
cell.Append('"');
i++;
}
else if (c == '"')
quoted = false;
else
cell.Append(c);
continue;
}
switch (c)
{
case '"':
quoted = true;
break;
case ',':
row.Add(cell.ToString());
cell.Clear();
break;
case '\r':
break;
case '\n':
row.Add(cell.ToString());
cell.Clear();
yield return row;
row = [];
break;
default:
cell.Append(c);
break;
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
yield return row;
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using Blazored.LocalStorage;
using decePubClient.Models;
namespace decePubClient.Services
{
/// <summary>
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
/// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm
/// returns true at once for a persona that already accepted it, and false when the reader declines.
/// </summary>
public class PublicNoticeService(ILocalStorageService localStorage)
{
TaskCompletionSource<bool> pending;
public event Action Changed;
public bool IsAsking => pending is not null;
/// <summary>What the persona is being told about.</summary>
public PublicAct Act { get; private set; } = PublicAct.Reaction;
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(personaId))
return false;
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
if (told.Contains(personaId))
return true;
Act = act;
pending = new TaskCompletionSource<bool>();
Changed?.Invoke();
var accepted = await pending.Task;
pending = null;
Changed?.Invoke();
if (!accepted)
return false;
told.Add(personaId);
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
return true;
}
public void Answer(bool accepted) => pending?.TrySetResult(accepted);
}
}
+164 -12
View File
@@ -21,7 +21,9 @@ namespace decePubClient.Services
Reblog, Reblog,
Unreblog, Unreblog,
Bookmark, Bookmark,
Unbookmark Unbookmark,
Pin,
Unpin
} }
/// <summary> /// <summary>
@@ -31,38 +33,147 @@ namespace decePubClient.Services
/// </summary> /// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer) public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{ {
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary> /// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
/// it is picked); a description changed since is sent first.</summary>
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default) public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
{ {
if (form.IsLocated || form.GroupId is not null) if (form.IsLocated || form.GroupId is not null)
return await PublishThroughClientApi(form, personaId, cancellationToken); return await PublishThroughClientApi(form, personaId, cancellationToken);
var mediaIds = new List<string>(); if (form.Media.Any(m => m.State != UploadState.Ready))
foreach (var media in form.Media) return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
{ if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
var uploaded = await mediaService.Upload(media, cancellationToken); return undescribed;
if (uploaded.IsInvalid) var mediaIds = form.Media.Select(m => m.Id).ToList();
return uploaded;
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
}
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
{ {
Status = form.Content ?? string.Empty, Status = form.Content ?? string.Empty,
MediaIds = mediaIds, MediaIds = mediaIds,
InReplyToId = form.InReplyToId, InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning, Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null, SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility, Visibility = form.Visibility,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
QuotedStatusId = form.QuotedStatusId
}, cancellationToken: cancellationToken); }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (form.ScheduledAtUtc is not null)
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken); var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return new WebResult { Data = FeedPost.Of(status) }; return new WebResult { Data = FeedPost.Of(status) };
} }
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
// described again through the edit's media_attributes)
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
{
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
{
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
if (described.IsInvalid)
return described;
media.SentAltText = media.AltText;
}
return new WebResult();
}
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusSource(statusId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<StatusSource>(cancellationToken) : null;
}
/// <summary>A post's versions, the oldest first and the one showing last; [] when they cannot be read.</summary>
public async Task<List<StatusEdit>> History(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusHistory(statusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(History)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
}
/// <summary>Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.</summary>
public async Task<List<Account>> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId),
["limit=80"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
}
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
/// changed; Data is the edited Status.</summary>
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
{
if (form.Media.Any(m => m.State != UploadState.Ready))
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
return undescribed;
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
{
Status = form.Content ?? string.Empty,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
MediaIds = [.. form.Media.Select(m => m.Id)],
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Status>(cancellationToken) };
}
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken);
return [];
}
return [.. (await response.MastodonReadFromJsonAsync<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
}
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
public async Task<WebResult> Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId),
new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
}
/// <summary>Drops a scheduled post: it is never published.</summary>
public async Task<WebResult> Unschedule(string scheduledId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary> /// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken) async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
{ {
@@ -100,6 +211,36 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken); return await StatusResult(response, cancellationToken);
} }
/// <summary>Reacts with an emoji (or takes the reaction back); Data is the updated Status.</summary>
public async Task<WebResult> React(string statusId, string emoji, bool add, CancellationToken cancellationToken = default)
{
var route = APIs.Mastodon.M_Reaction(statusId, emoji);
using var response = add
? await httpService.Put(route, cancellationToken: cancellationToken)
: await httpService.Delete(route, cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>Joins a remote event as the persona (its server is told, and shows who takes part), or leaves it; Data is
/// the updated Status, whose event says how the participation stands.</summary>
public async Task<WebResult> Participate(string statusId, bool join, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(join ? APIs.PrivaPub.POST_M_Join(statusId) : APIs.PrivaPub.POST_M_Leave(statusId),
cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_PollVotes(pollId), new PollVoteForm { Choices = [.. choices] },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Poll>(cancellationToken) };
}
/// <summary>Deletes the persona's own status.</summary> /// <summary>Deletes the persona's own status.</summary>
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default) public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
{ {
@@ -107,6 +248,17 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken); return await StatusResult(response, cancellationToken);
} }
/// <summary>Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.</summary>
public async Task<WebResult> DeleteLocated(string postId, string personaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePost, new DeletePostForm { AvatarId = personaId, PostId = postId },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default) public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken); using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
+191
View File
@@ -0,0 +1,191 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
/// <summary>One stream of PrivaPub's streaming API: its name and, for a hashtag or a list, which one.</summary>
public sealed record StreamSubscription(string Stream, string Tag = default, string List = default);
/// <summary>What a stream told: the stream it came on, the event (update, notification, status.update, delete) and
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
/// <summary>
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
/// page to poll meanwhile. Nothing throws.
/// </summary>
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
ILoggingService logger) : IAsyncDisposable
{
ClientWebSocket socket;
CancellationTokenSource running;
StreamSubscription current;
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
/// <summary>Raised for each message of the stream subscribed now.</summary>
public event Func<StreamMessage, Task> Received;
Uri Endpoint
{
get
{
var root = new UriBuilder(configuration.ApiBaseAddress) { Path = "/api/v1/streaming" };
root.Scheme = root.Scheme == "https" ? "wss" : "ws";
return root.Uri;
}
}
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
{
if (subscription == current && (running is not null || subscription is null))
return;
var previous = current;
current = subscription;
if (socket?.State == WebSocketState.Open)
{
if (previous is not null)
await Say("unsubscribe", previous, cancellationToken);
if (subscription is not null)
await Say("subscribe", subscription, cancellationToken);
return;
}
if (subscription is not null && running is null)
{
running = new();
_ = Run(running.Token);
}
}
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
{
var message = new JsonObject { ["type"] = type, ["stream"] = subscription.Stream };
if (subscription.Tag is not null)
message["tag"] = subscription.Tag;
if (subscription.List is not null)
message["list"] = subscription.List;
try
{
await socket.SendAsync(Encoding.UTF8.GetBytes(message.ToJsonString()), WebSocketMessageType.Text, true, cancellationToken);
}
catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException)
{
}
}
// open, subscribe, read until the socket drops, and open again after a pause
async Task Run(CancellationToken cancellationToken)
{
var pause = TimeSpan.FromSeconds(2);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Connect(cancellationToken);
pause = TimeSpan.FromSeconds(2);
await Read(cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex) when (ex is WebSocketException or JsonException or InvalidOperationException)
{
await logger.ProcessWarning(ex.Message, $"{nameof(StreamingService)}.{nameof(Run)}", cancellationToken);
}
socket?.Dispose();
socket = null;
try
{
await Task.Delay(pause, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
pause = TimeSpan.FromSeconds(Math.Min(60, pause.TotalSeconds * 2));
}
}
async Task Connect(CancellationToken cancellationToken)
{
var token = await authStateProvider.GetPersonaToken(cancellationToken);
if (token is null)
{
// any persona request exchanges the token first
using var _ = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken);
token = await authStateProvider.GetPersonaToken(cancellationToken);
}
if (token is null)
throw new InvalidOperationException("no persona token");
socket = new ClientWebSocket();
socket.Options.AddSubProtocol(token);
await socket.ConnectAsync(Endpoint, cancellationToken);
if (current is not null)
await Say("subscribe", current, cancellationToken);
}
async Task Read(CancellationToken cancellationToken)
{
var buffer = new byte[64 * 1024];
while (socket.State == WebSocketState.Open)
{
var text = new StringBuilder();
WebSocketReceiveResult received;
do
{
received = await socket.ReceiveAsync(buffer, cancellationToken);
if (received.MessageType == WebSocketMessageType.Close)
return;
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
}
while (!received.EndOfMessage);
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|| message["payload"]?.GetValue<string>() is not { } payload)
continue;
var told = name switch
{
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
"notification" => new StreamMessage(name, Notification: JsonSerializer.Deserialize<Notification>(payload, SUtility.MastodonSerializer)),
"delete" => new StreamMessage(name, DeletedId: payload),
_ => null
};
if (told is not null && Received is { } handlers)
await handlers(told);
}
}
/// <summary>Closes the socket; the page polls from then on.</summary>
public async Task Stop()
{
current = null;
if (running is not null)
{
await running.CancelAsync();
running.Dispose();
running = null;
}
try
{
if (socket?.State == WebSocketState.Open)
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
}
catch (WebSocketException)
{
}
socket?.Dispose();
socket = null;
}
public async ValueTask DisposeAsync() => await Stop();
}
}
+124 -12
View File
@@ -12,9 +12,9 @@ using PrivaPub.ClientModels.Post;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary> /// <summary>What a feed shows: its kind, and the group, hashtag, list or position some kinds need.</summary>
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default, public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
double? Latitude = default, double? Longitude = default); double? Latitude = default, double? Longitude = default, string ListId = default);
/// <summary> /// <summary>
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer) /// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
@@ -41,11 +41,13 @@ namespace decePubClient.Services
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken), TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
TimelineType.Tag when query.Tag is { Length: > 0 } tag => TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken), await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
TimelineType.Lists when query.ListId is { Length: > 0 } listId =>
await Statuses(APIs.Mastodon.GET_M_ListTimeline(listId), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity => TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken), await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken), TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken), TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
TimelineType.Direct when maxId is null => await Conversations(cancellationToken), TimelineType.Direct => await Conversations(maxId, minId, cancellationToken),
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken), TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
_ => new WebResult { Data = new FeedPage() } _ => new WebResult { Data = new FeedPage() }
}; };
@@ -76,34 +78,144 @@ namespace decePubClient.Services
}; };
} }
/// <summary>Notifications folded into groups (/api/v2/notifications): likes and boosts of one post, follows of one
/// hour, each an item with its count and a few of its accounts. Paged by notification ids from the Link header.</summary>
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken) async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
{ {
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken); using var response = await httpService.Get(APIs.Mastodon.GET_GroupedNotifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? []; var grouped = await response.MastodonReadFromJsonAsync<GroupedNotifications>(cancellationToken) ?? new GroupedNotifications();
var accounts = grouped.Accounts.DistinctBy(a => a.Id).ToDictionary(a => a.Id);
var statuses = grouped.Statuses.DistinctBy(s => s.Id).ToDictionary(s => s.Id);
var (nextMaxId, prevMinId) = response.PageCursors(); var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult return new WebResult
{ {
Data = new FeedPage Data = new FeedPage
{ {
Posts = [.. notifications.Select(FeedPost.Of)], Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id, MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
} }
}; };
} }
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary> /// <summary>How many groups of notifications are unread (newer than the notifications marker).</summary>
async Task<WebResult> Conversations(CancellationToken cancellationToken) public async Task<int> UnreadNotifications(CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken); using var response = await httpService.Get(APIs.Mastodon.GET_UnreadNotifications, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<UnreadCount>(cancellationToken))?.Count ?? 0 : 0;
}
/// <summary>The persona's notification policy; null when it cannot be read.</summary>
public async Task<NotificationPolicy> NotificationPolicy(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationPolicy, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationPolicy)}", cancellationToken);
return null;
}
return await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken);
}
/// <summary>Saves the persona's notification policy; Data is the policy as PrivaPub keeps it.</summary>
public async Task<WebResult> SetNotificationPolicy(NotificationPolicy policy, CancellationToken cancellationToken = default)
{
using var response = await httpService.Patch(APIs.Mastodon.PATCH_NotificationPolicy, new NotificationPolicy
{
ForNotFollowing = policy.ForNotFollowing,
ForNotFollowers = policy.ForNotFollowers,
ForNewAccounts = policy.ForNewAccounts,
ForPrivateMentions = policy.ForPrivateMentions,
ForLimitedAccounts = policy.ForLimitedAccounts
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken) };
}
/// <summary>The requests gathering the filtered notifications, newest first; [] when they cannot be read.</summary>
public async Task<List<NotificationRequest>> NotificationRequests(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationRequests, ["limit=80"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationRequests)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<NotificationRequest>>(cancellationToken) ?? [];
}
/// <summary>Lets a request's notifications in, and its account from then on, or dismisses them.</summary>
public async Task<WebResult> AnswerNotificationRequest(string requestId, bool accept, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_NotificationRequest(requestId, accept), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Markers, new MarkersForm { Notifications = new MarkerForm { LastReadId = notificationId } },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Direct conversations by their newest message, newest first, paged by that message's id.</summary>
async Task<WebResult> Conversations(string maxId, string minId, CancellationToken cancellationToken)
{
var parameters = new List<string> { $"limit={PageSize}" };
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
if (minId is not null)
parameters.Add($"min_id={Uri.EscapeDataString(minId)}");
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [.. parameters], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []; var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } }; var (nextMaxId, nextMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)],
MaxId = conversations.Count < PageSize ? null : nextMaxId,
MinId = nextMinId
}
};
}
/// <summary>How many of the newest conversations have a message the persona has not read.</summary>
public async Task<int> UnreadConversations(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, ["limit=40"], cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []).Count(c => c.Unread) : 0;
}
/// <summary>Marks a conversation read; Data is the conversation.</summary>
public async Task<WebResult> ReadConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_ReadConversation(conversationId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Conversation>(cancellationToken) };
}
/// <summary>Takes a conversation off the list until a newer message; its messages stay.</summary>
public async Task<WebResult> RemoveConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Conversation(conversationId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
} }
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary> /// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
+42 -8
View File
@@ -1,13 +1,47 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
<div class="relative flex flex-col md:flex-row w-full h-full"> @* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's
<NavMenu /> Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own
navigation closes them all and goes on. *@
<CascadingValue Value="(IDOverlayHost)Overlays" IsFixed="true">
<div class="relative flex flex-col md:flex-row w-full h-full">
<NavMenu />
<div class="block flex-1 relative w-full h-full"> <div class="block flex-1 relative w-full h-full">
<div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4"> <div class="block absolute overflow-y-auto w-full h-full p-3 md:py-4 md:pl-2 md:pr-4">
@Body @Body
</div>
</div> </div>
</div> </div>
</div>
<ToastHost /> <ToastHost />
<PublicNoticeHost />
<ConfirmHost />
</CascadingValue>
@code {
[Inject] OverlayService Overlays { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] AppStatusService Status { get; set; }
IDisposable changing;
protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
async ValueTask OnLocationChanging(LocationChangingContext context)
{
var back = !context.IsNavigationIntercepted && Status.TookBack();
if (!Overlays.HasOpen)
return;
if (back)
{
context.PreventNavigation();
await Overlays.CloseTop(context.CancellationToken);
return;
}
await Overlays.CloseAll(context.CancellationToken);
}
public void Dispose() => changing?.Dispose();
}
+106 -21
View File
@@ -1,12 +1,22 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0"> @* On a phone the menu opens from the hamburger over the page, and closes after a choice (any navigation, a persona
switch), on a tap outside it or on Escape; from md it is the sidebar, which scrolls on its own when the window is
short (a phone held sideways). *@
<nav class="flex justify-between items-center md:items-start pt-3 px-3 md:p-0 md:h-full md:overflow-y-auto md:overscroll-contain"
@onkeydown="OnKeyDown">
@if (isMenuOpen)
{
<div class="md:hidden fixed inset-0 z-40 bg-black/30" aria-hidden="true" @onclick="CloseMenu"></div>
}
<div class="relative md:w-52"> <div class="relative md:w-52">
<DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu"> <DButton Size="DSize.Sm" class="md:hidden" OnClick="ToggleNavMenu" aria-label="@Localizer["Menu"]" title="@Localizer["Menu"]"
<i class="ion-md-menu text-lg"></i> aria-expanded="@(isMenuOpen ? "true" : "false")" aria-controls="nav-panel" data-nav-toggle>
<i class="ion-md-menu text-lg" aria-hidden="true"></i>
</DButton> </DButton>
<div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block"> <div id="nav-panel" data-nav-panel
class="@(isMenuOpen ? "block" : "hidden") md:block absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-[min(18rem,calc(100vw-1.5rem))] md:w-full max-h-[calc(100dvh-5rem)] md:max-h-none overflow-y-auto overscroll-contain rounded-lg neo-raised neo-sm md:shadow-none! z-50">
<div class="flex flex-col gap-3 p-3 md:p-4"> <div class="flex flex-col gap-3 p-3 md:p-4">
@if (CascadingState.IsSignedIn) @if (CascadingState.IsSignedIn)
@@ -41,11 +51,37 @@
<i class="ion-md-home"></i> <i class="ion-md-home"></i>
<span>@Localizer["Home"]</span> <span>@Localizer["Home"]</span>
</NavLink> </NavLink>
@if (CascadingState.IsSignedIn)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="search">
<i class="ion-md-search"></i>
<span>@Localizer["Search"]</span>
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="explore">
<i class="ion-md-compass"></i>
<span>@Localizer["Explore"]</span>
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="lists">
<i class="ion-md-list"></i>
<span>@Localizer["Lists"]</span>
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="groups">
<i class="ion-md-people"></i>
<span>@Localizer["Groups"]</span>
</NavLink>
@if (CascadingState.PersonaId is { } personaId)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
<i class="ion-md-person"></i>
<span>@Localizer["My profile"]</span>
</NavLink>
}
}
<NavLink ActiveClass="btn-active" class="btn justify-start" href="settings"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="settings">
<i class="ion-md-settings"></i> <i class="ion-md-settings"></i>
<span>@Localizer["Settings"]</span> <span>@Localizer["Settings"]</span>
</NavLink> </NavLink>
<AuthorizeView Policy="@Policies.IsAdmin"> <AuthorizeView Policy="@Policies.IsModerator">
<NavLink ActiveClass="btn-active" class="btn justify-start" href="administration"> <NavLink ActiveClass="btn-active" class="btn justify-start" href="administration">
<i class="ion-md-switch text-error"></i> <i class="ion-md-switch text-error"></i>
<span>@Localizer["Administration"]</span> <span>@Localizer["Administration"]</span>
@@ -53,10 +89,10 @@
</AuthorizeView> </AuthorizeView>
@if (CascadingState.IsSignedIn) @if (CascadingState.IsSignedIn)
{ {
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout"> <DButton class="justify-start" OnClick="Logout" data-logout>
<i class="ion-md-log-out text-error"></i> <i class="ion-md-log-out text-error"></i>
<span>@Localizer["Logout"]</span> <span>@Localizer["Logout"]</span>
</NavLink> </DButton>
} }
else else
{ {
@@ -76,7 +112,8 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark"> <DToggle Value="IsDark" ValueChanged="UpdateThemeDarkMode" ValueExpression="() => IsDark" class="touch-target"
aria-label="@Localizer["Dark mode"]" title="@Localizer["Dark mode"]">
<UncheckedIcon> <UncheckedIcon>
<i class="ion-md-sunny"></i> <i class="ion-md-sunny"></i>
</UncheckedIcon> </UncheckedIcon>
@@ -90,21 +127,41 @@
</div> </div>
</div> </div>
<div class="md:hidden"> <a class="md:hidden grid place-items-center h-11 w-11" href="" aria-label="@Localizer["Home"]" title="@Localizer["Home"]">
<img alt="" class="h-[30px]" src="imgs/icon-512.png"> <img alt="" class="h-[30px]" src="imgs/icon-512.png">
</div> </a>
<div class="md:hidden"> <a class="md:hidden grid place-items-center h-11 w-11" href="settings" aria-label="@Localizer["Settings"]" title="@Localizer["Settings"]">
<a class="block rounded-full neo-raised neo-2xs" href="settings"> <img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full neo-raised neo-2xs"
<img alt="@CascadingState.Persona?.Name" class="object-cover w-[30px] h-[30px] rounded-full" src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")">
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")"> </a>
</a>
</div>
</nav> </nav>
@code { @code {
string menuToggle = "hidden"; [Inject] ConfirmService Confirm { get; set; }
[Inject] OverlayService Overlays { get; set; }
bool isMenuOpen;
IDisposable overlay;
// open, the Back button closes it
void SetMenu(bool open)
{
isMenuOpen = open;
if (open && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(() =>
{
SetMenu(false);
StateHasChanged();
}));
else if (!open && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
bool isPersonaMenuOpen; bool isPersonaMenuOpen;
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-") string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
@@ -118,20 +175,48 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
IsLoading = false; IsLoading = false;
if (!CascadingState.Status.IsMobileMedia()) Navigation.LocationChanged += OnLocationChanged;
menuToggle = default; }
void OnLocationChanged(object sender, LocationChangedEventArgs e)
{
if (!isMenuOpen)
return;
SetMenu(false);
InvokeAsync(StateHasChanged);
}
void CloseMenu() => SetMenu(false);
void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
SetMenu(false);
}
public override async ValueTask DisposeAsync()
{
Navigation.LocationChanged -= OnLocationChanged;
overlay?.Dispose();
await base.DisposeAsync();
} }
async Task SwitchPersona(string personaId) async Task SwitchPersona(string personaId)
{ {
SetMenu(false);
var result = await CascadingState.SwitchPersona(personaId); var result = await CascadingState.SwitchPersona(personaId);
if (result.IsInvalid) if (result.IsInvalid)
Toast.AddResponseError(result); Toast.AddResponseError(result);
} }
private void ToggleNavMenu() private void ToggleNavMenu() => SetMenu(!isMenuOpen);
async Task Logout()
{ {
menuToggle = menuToggle is { Length: > 0 } ? default : "hidden"; SetMenu(false);
if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true))
return;
Navigation.NavigateTo("logout");
} }
protected async Task ResetToOriginalColour() protected async Task ResetToOriginalColour()
+3 -1
View File
@@ -1,11 +1,12 @@
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into /* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/ wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */ (daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
@import "tailwindcss" source("../"); @import "tailwindcss" source("../");
@source not "../wwwroot/vendor"; @source not "../wwwroot/vendor";
@source not "../Docs"; @source not "../Docs";
@source not "../tools"; @source not "../tools";
@source not "../tests";
@plugin "../tools/tailwind/daisyui.mjs" { @plugin "../tools/tailwind/daisyui.mjs" {
themes: false; themes: false;
@@ -14,5 +15,6 @@
@import "./theme.css"; @import "./theme.css";
@import "./neo.css"; @import "./neo.css";
@import "./touch.css";
@import "./base.css"; @import "./base.css";
@import "./feed.css"; @import "./feed.css";
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more