Files
decePubClient/CLAUDE.md
T
thepraandClaude Opus 5.5 b731d8c8c0
Build / Build (push) Successful in 1m3s
R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Completion program R3, with PrivaPub's TOTP routes of the same release.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:33:46 +02:00

49 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

decePubClient is a Blazor WebAssembly PWA (.NET 10). It is meant as a Pleroma-FE-like client for PrivaPub, the owner's ActivityPub server, whose repo is thepra/SocialPub. It is deployed as a static site at https://decepub.thepra.dev, and its API base address is https://privapub.thepra.dev (AppConfiguration:ApiBaseAddress in wwwroot/appsettings.json).

It talks to PrivaPub through both of its APIs with one sign-in (see PrivaPub wiring below). The feed page has three columns: navigation, the feed with a tab per kind of feed, and a 3D globe that follows the posts in view. Nerd stats sit in tooltips wherever there is data. Development builds point at a local PrivaPub on http://127.0.0.1:6970 (wwwroot/appsettings.Development.json, never published).

Sibling repo dependency

The project references ../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj, so SocialPub must be checked out next to this repo. CI clones it there. That project supplies:

  • the shared DTOs;
  • Policies (IsAdmin, IsUser, IsModerator);
  • the FieldsNameResource and ErrorsResource localization resources.

Before wiring anything to the API, read ../SocialPub/CLAUDE.md. PrivaPub's route names (/peasants/..., /mouth, /anus, ...) are deliberate. Never "correct" them. The API surface is the Mastodon client API plus PrivaPub's private /clientapi and /api/privapub/v1.

To run against a local PrivaPub: start a mongod, run SocialPub's PrivaPub with the Development command in its CLAUDE.md on 127.0.0.1:6970 (Registrations__Mode=Open lets /clientapi/user/signup create a root), then create personas with /clientapi/avatar/private/insert. Federated posts come from SocialPub's pasture (tools/pasture/run.sh up gts akkoma, then tools/pasture/interop.sh gts akkoma), whose API is 127.0.0.1:6971. Neither DB-IP nor PrivaPub locate private addresses, so for the globe set RemoteInstance.Geo in mongo and Statistics:Geo:Self in PrivaPub's configuration.

Commands

dotnet build decePubClient.csproj                      # what CI runs (-c Release) on every push to master and on PRs
dotnet run                                             # dev server: https://localhost:7046 / http://localhost:5046
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 is no linter.

Verifying

There is no test project (owner decision 2026-10-17: tests/e2e was removed, as was SocialPub's PrivaPub.Tests). Don't add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:

dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
../SocialPub/tools/pasture/run.sh up gts decepub   # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
  • Blazor on .NET 10 ignores the Blazor-Environment header, so the environment is chosen at publish time: wwwroot/appsettings.Pasture.json (API https://privapub.test) is published only then, and the deploy fails if it ever ships.
  • A browser reaches *.test through the pasture's Caddy on 127.0.0.1:6443 (Chromium: --host-resolver-rules="MAP *.test 127.0.0.1:6443"), and must accept the pasture's CA.

CSS: Tailwind 4 + daisyUI 5, vendored, offline

Styles/app.css is compiled into wwwroot/css/app.css by the Tailwind CSS standalone binary with the daisyUI plugin. app.css is gitignored and regenerated on every build.

Nothing in the build or the CI may download from github.com. Tools live in the repo, and the CSS build runs without network access. NuGet restore still uses nuget.org.

  • tools/tailwind/ holds the pinned tools:
    • Tailwind 4.3.3 as tailwindcss-linux-x64.xz;
    • daisyUI 5.7.47 (daisyui.mjs, daisyui-theme.mjs);
    • daisyUI's component reference, daisyui-llms.txt.
  • .gitattributes keeps these files byte-exact.
  • tools/tailwind.sh prepare checks them against the sha256 values pinned in the script, and unpacks the binary into the gitignored .tools/tailwind/. It needs xz.
  • Only linux-x64 is vendored.
  • The script's header explains how to upgrade or add a platform.
  • The TailwindCompile target in the csproj runs it, so dotnet build / publish are enough, in CI as well. Debug builds use --optimize, Release builds --minify. Pass -p:SkipTailwind=true to skip it.
  • IncludeTailwindOutput adds the generated file to Content before static web assets are resolved. Without that, a clean-clone publish would ship app.css without its .gz/.br copies and outside service-worker-assets.js. The deploy workflow checks for css/app.css.gz.
  • bash tools/tailwind.sh watch rebuilds the CSS live next to dotnet watch.
  • Tailwind scans every file .gitignore doesn't exclude (minus tools/ and wwwroot/vendor/), and only generates the classes it finds written out whole. Never build a class name by concatenation or interpolation ("btn-" + size, $"btn-{size}"). Write every variant as a literal, e.g. in a switch arm.

Ionicons 4.5 (wwwroot/vendor/ionicons.min.css, <i class="ion-md-...">) is loaded as its own stylesheet.

Theme and neumorphism

  • Styles/theme.css holds two daisyUI themes, neo-light (default) and neo-dark (prefersdark). Every colour is an hsl() of --neo-hue-light / --neo-hue-dark / --neo-chroma (0 for the grey themes).
  • wwwroot/js/theme.js sets those variables and data-theme on <html> from PageSettings. It runs in <head> from localStorage, so there is no flash before Blazor boots. After that, CascadingState calls it through AppStatusService.ApplyTheme whenever the settings change. With PreferSystemTheming it leaves data-theme off, and the OS decides.
  • Change the theme flags through PageSettings.SetDarkMode / SetGray / SetHue and CascadingState.UpdatePageSettings, which keep the flags consistent:
    • choosing light or dark leaves system theming;
    • picking a hue leaves grey.
  • AppStatusService.IsDarkTheme() tells which variant is showing.
  • Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones. The other sections of Settings belong to the login or the persona (see Settings under PrivaPub wiring).
  • The theme is the login's while PageSettings.SyncWithLogin is on (the default; Settings → General turns it off for this device). CascadingState takes the login's theme (ViewUserSettings; never its language, which would reload the app) at sign-in from the JwtUser (SignedIn, which Login, Setup and Join call instead of LoadSession) and at start-up from /clientapi/user/settings, once savedAt says a client saved one; while none did, it gives the login this device's. UpdatePageSettings saves them to /clientapi/user/update/settings 1.5 s after the last change, one save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown.
  • Styles/neo.css holds the neumorphic layer:
    • neo-raised / neo-inset / neo-flat, with sizes neo-2xs / neo-xs / neo-sm / neo-md (these replace the old neomorph is-nxsmall classes);
    • neo-gradient(-pressed) and neo-hue-track;
    • @utility <daisy-component> overrides that give btn, input, select, card, … the neo look. daisyUI puts its rules in sublayers of utilities, so these overrides beat every daisyUI rule. Each override must exclude the daisyUI variants it shouldn't touch, e.g. :not(:where(.btn-ghost, .btn-link, ...)), and call sites drop a neo shadow with shadow-none!.
    • Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A file input's own button (Choose File, Browse…) is raised like the app's buttons.
  • Never write @utility collapse. It brings back Tailwind's own collapse utility (visibility: collapse), and every collapse disappears.

Phones

Below md the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:

  • Width and pointer are separate.

    • Layout differences between a phone and a desktop use the width variants at the call site (max-md:, md:).
    • How big a thing is to a finger uses the pointer: Styles/touch.css under @media (pointer: coarse), and pointer-coarse: at a call site.
    • A mouse matches none of it, so desktop keeps daisyUI's sizes.
    • A window at most 30rem tall (a phone held sideways) is the short: variant.
  • touch.css is plain CSS in Tailwind's utilities layer, so like neo.css it beats daisyUI's sublayers. It sets daisyUI's own size variables:

    • buttons, tabs, inputs, selects and file inputs are 44px tall;
    • extra small buttons and the badges that act are 36px, with a transparent ::after that reaches 44 (5px around the padding box);
    • checkboxes and radios are 24px, toggles and range thumbs 28;
    • field text is 16px, so iOS doesn't zoom into a focused field.

    Its touch-target utility gives a small link or button a 44px hit area. It can't be used on something that clips its own overflow (a truncated name takes pointer-coarse:py-3.5 instead), nor on a daisyUI tooltip, which draws its arrow with ::after (NerdTip takes minimum sizes).

  • Every target has a 44px hit area on touch, with 8px between targets in a row (pointer-coarse:gap-2).

  • Overlays on a phone rise from the bottom.

    • DModal Placement="ResponsiveBottom" is a sheet below md and a centred dialog from md. Every DModal has a visible close button (ShowClose, named by CloseText).
    • DDropdown SheetOnNarrow turns its content into a bottom sheet.
    • Components/PhoneSheet.razor shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a sheet on a phone.
    • All are capped by dvh, and the app itself is h-dvh.
  • The Back button closes the newest open overlay instead of leaving the page.

    • Overlays register through IDOverlayHost (Components/Daisy, cascaded from MainLayout):
      • DModal and DDropdown through the cascade;
      • the nav menu, PhoneSheet and the full-screen globe through Services/OverlayService.cs.
    • main.js marks a real Back press (a capture-phase popstate), since Blazor can't tell it from the app's own navigation, which closes every overlay and goes on.
    • No history entries are pushed.
  • What can't be undone asks first through Services/ConfirmService.cs (Ask, shown by ConfirmHost in MainLayout): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and signing out.

  • On a touch screen:

    • a tap on a post opens its thread (main.js, data-thread on the card, its data-thread-link), and cards are pinned to the globe with a mouse only;
    • a tap on a nerd-stats trigger opens its rows in a sheet (NerdTip, AppStatusService.CanHover);
    • pictures open in Components/MediaViewer.razor.
  • The feed on a phone:

    • a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd stats' Details are in More, and the author's picture is in the header;
    • a new post starts from a floating button;
    • the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
    • the feed tabs show their names and fade at the edge where more wait (edge-fade-x, data-scroll-x).

daisyUI Blazor components (Components/Daisy/)

Every daisyUI component has a D-prefixed wrapper (DButton, DModal, DDropdown, DInput, ...), grouped by daisyUI's categories into folders whose namespaces are imported in _Imports.razor. /styleguide (not linked) renders all of them in every theme. When you add or change one:

  • Map each enum to its classes in a switch with whole literal class names.
  • Value-holding fields derive from DInputBase<TValue>, Blazor's InputBase, so @bind-Value and EditForm validation work. Everything else derives from DComponentBase (Class, pass-through attributes, Cx(...)).
  • Open/active state lives in C# (@bind-IsOpen, @bind-Active) and is rendered with daisyUI's *-open modifier classes. No JS interop.
    • What daisyUI does with scripts has a script-free equivalent here: DCheckbox.Indeterminate renders aria-checked="mixed", and DCarousel's navigation links to the slides' ids, which Blazor scrolls to.
    • DCalendar is the one exception: it wraps the vendored Cally web component (wwwroot/vendor/cally.js).
  • Images and links in /styleguide stay local (wwwroot/imgs/styleguide/).
  • Don't name a parameter after an HTML attribute (Title, Style, Id, Label, ...). Blazor matches attributes to parameters without regard to case, so the parameter would swallow the attribute.
  • Components don't use Localizer or CascadingState; their user-visible strings are parameters.

Architecture

Startup (Program.cs).

  • DI registration happens here.
  • The named HttpClient "default" uses ApiBaseAddress as its base address. It falls back to the host's base address when that setting is empty.
  • SetDefaultCulture() (Extensions/ExtensionMethods.cs) runs before the app starts. It picks the culture from the saved PublicCacheData, or on a first visit from the browser language.
  • Only English (en-GB) and Italian (it-IT) have resources; any other language gets English.
  • Settings → General changes the language, saves it, and reloads the app.

LayerComponents/CascadingState.razor wraps the router in App.razor. It is the app-wide state object. It handles:

  • PublicCacheData (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
  • the theme, applied through AppStatusService.ApplyTheme (wwwroot/js/theme.js);
  • IsOnline / IsOffline, polled every 10 seconds;
  • the session: IsSignedIn, Personas (the root's), PersonaId and Persona (its Mastodon Account), with LoadSession, SwitchPersona and SignOut. A persona change re-renders every subscriber; pages compare PersonaId and reload what belongs to the persona. The JWT is renewed hourly when needed;
  • ProcessError / ProcessWarning, which log through ILoggingService and show a toast;
  • LogFromJs, which JS calls back into through a DotNetObjectReference.

Components and pages inherit LocalizableComponentBase, the equivalent of b2b.next's WidmannComponentBase. It supplies:

  • CascadingState, Localizer, Navigation and Toast;
  • IsLoading, which starts true, and IsDefaultDisabled;
  • AfterRenderAsyncJobs, run once after the next render;
  • WhenOnline / StartOnlinePolling.

Never redeclare the base's members in a component.

UI feedback.

  • Services/ToastService.cs is the snackbar: the SUtility extensions AddResponseError(WebResult) (silent on 410/cancelled; connectivity failures show as warnings), AddResponseError(string), AddResponseSuccess (closes after 5 s), AddWarning and AddException. Components/ToastHost.razor, placed in MainLayout, shows the messages.
  • Components/ErrorViewer.razor shows in-page errors: the cascading EditContext's validation messages, AdditionalError and AdditionalErrors.
  • ConfirmService asks before what can't be undone (see Phones).

HTTP (Services/IHttpService.cs).

  • Routes are constants in Models/APIs.cs (b2b.next naming: GET_/POST_/DELETE_, _M_ for routes built from parameters), relative to ApiBaseAddress.
  • The route picks the credentials: clientapi/ sends the root's JWT, api/ the current persona's Mastodon token (exchanged when missing, exchanged once more and retried when PrivaPub refuses it), oauth/ nothing.
  • 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 can be an object (JSON), name/value pairs (a form) or a Func<HttpContent> (multipart), built once per attempt.
  • Auth levels: Get / Post / Put / Patch / Delete need credentials and end the session when PrivaPub refuses them; GetAnon adds them when there are some; *TotallyAnon send none (the login and the password recovery must not carry a JWT: PrivaPub redirects them).
  • retryOnError backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
  • Each request has its own timeout (Measure): 100 s, or 10 minutes for a multipart body (an upload); the HttpClient's own is off.
  • Failures never throw. They come back as responses carrying an invalid WebResult, with an ErrorCode from Models/FailureCodes.cs; a cancelled request comes back as HTTP 410.
  • Read failures with ReadWebResult (it understands WebResult, validation ProblemDetails and Mastodon's {"error": ...}) and bodies with DefaultReadFromJsonAsync / MastodonReadFromJsonAsync; a body that doesn't fit comes back as the default and is written to the browser console. PageCursors() reads max_id/min_id from Link.
  • Every request PrivaPub answers is recorded in RequestStats (method, route template, status, duration, size, rate limit headers; never a body or a token), for the nerd stats.
  • PrivaPub's WebResult gets IsInvalid from Extensions/WebResultExtensions.cs, a C# 14 extension property.

PrivaPub wiring.

  • One sign-in (Services/AuthService.cs, Pages/Login.razor): the root signs in on /clientapi/user/login, then each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's PersonaExchange, RFC 8693 on /oauth/token, first-party client id AppConfiguration:ClientId). AuthData keeps the JWT and the persona tokens; logout revokes every persona token, ends this device's session (/clientapi/user/logout), then forgets the session.
    • Sessions: "Sign out everywhere" (AuthService.LogoutEverywhere, /clientapi/user/logout/everywhere) ends every session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every persona token, this device's too: AuthService.ChangePassword and TotpEnable keep the new JwtUser PrivaPub answers with (ContinueSession), drop the stored persona tokens and exchange one for the current persona at once (the others when next used), so the device stays signed in.
    • Two-factor sign-in (TOTP, /clientapi/user/totp/*): with it on, a right password is answered with a 428 (AuthService.NeedsOneTimeCode). Login keeps the name and the password in the form, out of sight, and sends it again with LoginForm.OneTimeCode: the authenticator app's six digits (DOtp, data-login-code) or, after "Use a recovery code instead", a recovery code like abcde-fghjk (a text field, data-login-recovery). A wrong code is a 400, like a wrong password, with PrivaPub's message. It is turned on and off in Settings (TotpEditor, see Settings).
  • The ways in (sign-up is closed on PrivaPub; owner decision 2026-10-16):
    • Setup (Pages/Setup.razor at /setup): while PrivaPub has no administrator (/clientapi/user/setup), the first login is made there, first come, first served, with its first persona. It becomes the administrator, and setup closes for good. Login offers it while it waits.
    • Invitations (Pages/Join.razor at /join?code=): a group's invitation makes a login whose first persona joins the group (/clientapi/user/invitation/signup). Login links there, and a group's page shows the link with its code.
    • Both sign in at once like Login (AuthService.SetUp / Join, sent with no credentials). A login without a persona is sent from Login to Settings to make one.
    • A forgotten password (Pages/PasswordRecovery.razor at /password-recovery, linked from Login): the login's name or its address asks for a link (/clientapi/user/recover/password), and the page shows PrivaPub's answer, the same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified address. The link comes back as /password-recovery?rc=: the page keeps the code, takes it out of the address bar and the history at once (replace), checks it (/recover/valid, the code as a JSON string), and sets the new password with it (/recover/update/password), which ends every session of the login; then the reader signs in. All of it goes with no credentials.
  • Services (Services/): TimelineService (every feed as FeedPages of FeedPosts, paged by max_id/min_id; circles are read as ids from /clientapi and hydrated through /api/v1/statuses?id[]=; located posts come from /clientapi/post/nearby and have no Mastodon actions), StatusService (publish, favourite, boost, bookmark, delete, threads, mute, block, poll votes, emoji reactions through Pleroma's /api/v1/pleroma/statuses/:id/reactions, joining and 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/Thread.razor at /expand/{id}): opening a remote thread makes PrivaPub read its replies from their servers (its FEP-7888 context, else replies), so the page reads the thread again after 4 and 15 seconds and adds what was found, in the thread's order.
  • Interaction policies (GoToSocial's, and FEP-5624's canReply on a PeerTube video whose comments are moderated): a reply or boost whose target's author must approve it shows so on its card (privapub.approval: pending, then nothing once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
  • Settings (Pages/Settings.razor, Components/Settings/): the login itself (AccountEditor: its email address (/clientapi/user/account), set only with the password and a 6-digit code mailed to the new address (/user/email, then /email/verify through DOtp; the old /user/update is refused), shown verified or not and with an address waiting for its code, and removed with the password (/email/remove); a wrong password is PrivaPub's 403, read as such; its password changed; its two-factor sign-in (TotpEditor, a block of AccountEditor that reads the login itself: turned on with the password (/totp/setup), which shows the secret as a QR image, as selectable text and as an otpauth:// link an authenticator app opens, then the app's first code (/totp/enable, a 409 once the hour-long setup expired), after which the ten recovery codes are shown once, with Copy (AppStatusService.CopyText) and "I saved them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the password and a code from the app or a recovery code (/totp/disable)); every session of it ended ("Sign out everywhere", see One sign-in); or the whole of it deleted, every persona with it, through /clientapi/user/update/password and /clientapi/user/delete; the client then signs out), the persona's profile (ProfileEditor: update_credentials as multipart, its words as written from Account.Source, pictures (streamed from the file, and removed with DELETE /api/v1/profile/avatar|header), fields, locked, bot, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, blocks and blocked servers (MutesAndBlocks), and DataTransfer: follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import resolves each row through PrivaPub and acts one row at a time; it can be stopped. 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 PrivaPub.ClientModels.
  • Where a post is (InstanceService.Locate, Models/FeedPost.cs): its own place (Pixelfed's, privapub.place), its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from Helpers/CountryPoints.cs when DB-IP located it only to a country), or, for a CDN-fronted server, its CDN's group (LocationSource.Cdn: no point, a CdnKey and the place Before the CDN). Server descriptions are cached a day in localStorage (ServerInfos); a server's weekly history (/instances/:host/history) and a CDN's page (/cdns/:key) 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.
  • The feed page (Pages/Index.razor): FeedTabs, Content (the card, which does its own actions), Composer, GlobePanel. Leaving it keeps the feed in Services/FeedMemory.cs (its 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 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 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 arc to a hovered post, honours prefers-reduced-motion, and draws only while something moves (idle, it costs no GPU time; wake() before anything that animates).
    • Map. Each pixel computes its own map coordinates from its point on the sphere (sharpen), so the poles don't pinch; the maps wrap horizontally and get 8× anisotropic filtering (keepSharp). The day texture is NASA's, its seam repaired by tools/globe/prepare-day-texture.py (dev-only, needs Pillow).

    • Sun. wwwroot/js/sun.js (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every 30 s), or the moment of the hovered, focused or pinned post (data-post-at on the card; a click pins a card, data-globe-pinned), and tells GlobePanel through SunChanged, which shows the moment and its phase at the panel's top and keeps the Sun's numbers as screen-reader text (aria-describedby). The theme changes only the atmosphere and the points.

    • Instruments (wwwroot/js/instruments.js; owner decision 2026-10-06: the nerd stats drawn where they belong, not a table). The globe has no tooltip. What it draws:

      • the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
      • a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
      • the tropics, the polar circles and the CDN ring;
      • the view's centre and spread, and rings around the posts in view;
      • a small orrery in the top left corner, re-anchored to the camera before each render.

      The numbers are chips in a DOM overlay (.globe-instruments, aria-hidden), placed after each frame globe.gl renders (scene.onAfterRender), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js already made, so an idle globe still draws nothing.

      • three.js's classes come from objects globe.gl made (classesOf), never window.THREE, since that would mix two copies. three.js sets its is* flags on instances, so the lookup checks instances. globe.gl adds its globe on its first update, after new Globe(), so the parts are built before the first render that finds it. Without the classes, only the ring and the legend remain.
      • A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen gets everything.
    • Countries. Land borders as thin paths, and names placed by placeLabels: largest countries first, once they span enough pixels, away from the globe's edge and clear of each other, in the reader's language (Intl.DisplayNames). countries.json is built by tools/globe/prepare-countries.py from world-atlas (Natural Earth). The label renderer owns display, so labels hide through visibility.

    • CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by data-post-id.

  • Nerd stats (Components/Nerd/NerdTip.razor, rows from NerdRow): a post's ids, times and provenance (read on hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request. The globe's are its instruments (see The globe), with no tooltip and no poll.

Localization (Services/CoalescingStringLocalizer.cs).

  • Lookup order: this repo's Resources/AllStrings.resx (+ .it.resx), then FieldsNameResource, then ErrorsResource.
  • A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
  • Resources/ErrorMessages.resx is read by [Required] / [StringLength] through ErrorMessageResourceType, and by the D fields' parse errors (DInputBase, DCalendar) through the static ErrorMessages.InvalidValue / InvalidDate. A new key needs its public static string property in ErrorMessages.Designer.cs too, or validation throws at runtime.

Client storage is localStorage (Blazored.LocalStorage, camelCase JSON): AuthData, PublicCacheData, FeedFilter and the server cache ServerInfos, keyed by nameof(Type). There is no IndexedDB: ILoggingService writes to the browser console only, and main.js deletes the database (data) where the client once kept its log, wherever a browser still holds it.

Auth. TokenAuthStateProvider holds AuthData (localStorage, cached in memory) and builds the auth state from it: the root's name, its policies as claims, and the current persona. Pages that need a session carry [Authorize]; RedirectToLogin sends the reader to /login?returnUrl=….

JS interop.

  • wwwroot/main.js defines window.* helpers. Services/AppStatusService.cs calls them, mostly through the synchronous IJSInProcessRuntime.
  • Each helper reports its errors back through window.logFromJs.
  • The other scripts (js/theme.js, HeadElement's, vendor/cally.js, js/blurhash.js) are loaded by <script> tags in wwwroot/index.html; the feed's modules (js/feed.js, js/globe.js) are imported on demand through IJSObjectReference.

PWA. Published builds use wwwroot/service-worker.published.js, and service-worker.js is the dev version. The published worker names its cache after the assets manifest's version, so each publish drops the previous cache.

Development rules

These rules are adapted from the owner's b2b.next project. They are enforced by review: the build doesn't check them, so follow them in every change.

Area Rule
All var everywhere; uninitialised locals are var x = default(T);
All Primary constructors for DI (class Foo(IBar bar)), not constructor + fields
All Every async method takes a CancellationToken as its last parameter
All Early return on error: guard, handle, return; never if/else for success vs failure
Models / state Lists and arrays = [], never null; no = string.Empty / "" defaults; explicit enum defaults
Components No @{ } blocks in markup: locals become @code members
Components [Inject] in @code, never @inject
Components Disable controls with IsDefaultDisabled (plus local conditions), never bare offline state
Components Every text/number input declares autocomplete
Services Task<WebResult> for mutations, Task<List<T>> ([] on error) for lists, Task<T> with a default for values
Localization Every user-facing string through Localizer, its key in both AllStrings.resx and AllStrings.it.resx

CancellationToken. It goes on every async method in services, CascadingState and helpers, defaulting to default; pass it on to what you call. Exempt: Blazor lifecycle overrides, [JSInvokable] methods, framework overrides (GetAuthenticationStateAsync), and private UI handlers bound from markup.

State initialisation. Initialise page and component state (= new(), = []) and bind it directly. No passthrough getters and no ?. chains over state that can't be null:

List<FeedPost> posts = [];                      // DO
@posts.Count                                    // DO
@(posts?.Count ?? 0)                             // DON'T

= "" is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's Alt).

Pages that load data. Pages fetch in LoadData and keep lifecycle concerns in the lifecycle method:

protected override async Task OnInitializedAsync()
{
	if (CascadingState.IsOffline)
	{
		WhenOnline(LoadData);
		StartOnlinePolling();
		IsLoading = false;
		return;
	}

	await LoadData();
	IsLoading = false;
}
  • Reuse the base IsLoading; don't add per-section loading flags.
  • A component that binds IsDefaultDisabled must set IsLoading = false once it is ready, even if it loads nothing; otherwise its controls stay disabled.
  • Never wrap a service call in try/catch. Services already catch and log, and return an invalid WebResult. Branch on result.IsInvalid instead:
var result = await StatusService.Delete(Status.Id);
if (result.IsInvalid)
{
	Toast.AddResponseError(result);
	return;
}

Toast.AddResponseSuccess(Localizer["Message deleted."]);
await OnDeleted.InvokeAsync(Post);

Client services follow b2b.next's two patterns (see its StoreService):

  • Create var result = new WebResult();, call httpService inside a try/catch, and on a non-success status result = await response.Content.ReadWebResult(response.StatusCode, localizer, ct). Log with ILoggingService.
  • Return result (mutations) or [] (lists), and set result.Data on success.
  • POST bodies are JSON payloads for /clientapi and the Mastodon API (snake_case DTOs), forms for /oauth.

In-page errors. ErrorViewer takes AdditionalError for one message and AdditionalErrors for a list. Never join a list into one string with <br/> or \n.

Autocomplete values:

  • autocomplete="off" for search, filter and free text;
  • new-password when setting a password;
  • one-time-code for OTP (DOtp sets it);
  • username and current-password on the login form, so password managers fill it;
  • inside Components/Daisy, attributes pass through from the caller.

Localization:

  • Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly.
  • Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide on them and one of the two silently shows the other's text. Reuse the existing key.
  • Use Localizer[key, args], never string.Format(Localizer[key], …). Don't write .Value.
  • Enum members shown through Localizer[value.ToString()] are keys too: when you add a member, add its key.
  • Language names in the language picker stay in their own language.

Formatting. Date formats come from SUtility.dateTimeFormat / timeFormat / dateTimeFormatExt. Relative times and sizes come from GetPassedTime / GetFileSize (Extensions/GenericExtensions.cs). No inline format strings for display.

JSON. Register every /clientapi or localStorage type in ClientJsonContext.cs (camelCase: theme.js reads pageSettings straight from localStorage), every Mastodon API type in MastodonJsonContext.cs (snake_case).

Wiring a feature to PrivaPub:

  1. For /clientapi, add the request/response DTOs to PrivaPub.ClientModels in the SocialPub repo: View* for responses, *Request for requests, *Form for forms with validation. Lists = [], no string defaults. For the Mastodon API, add the shape to Models/Mastodon/.
  2. Register them in ClientJsonContext or MastodonJsonContext, and the route in Models/APIs.cs.
  3. Add a client service method that calls IHttpService against the server's route, following the patterns above.
  4. Wire the page with early returns, toasts and/or an ErrorViewer.

Not adopted from b2b.next, on purpose:

  • The tw: Tailwind prefix. b2b.next needs it next to MudBlazor's CSS; here daisyUI is the only component CSS.
  • Server-only rules: no 5XX responses, the controller pattern, IOptionsMonitor, data access and caching.
  • b2b.next-specific features: Excel import/export, password policy, legacy porting rules, the {culture} route placeholder (PrivaPub's routes are absolute /clientapi/...), and the release/API-status worker.
  • Mandatory /// <summary> on every [Parameter]. Document only what isn't obvious from the name.

Deployment

Both workflows check the repo out with a plain git fetch from this Gitea, not actions/checkout, because the runner would download that action from github.com. Never add a uses: step.

.gitea/workflows/deploy.yml runs on a v* tag or a manual dispatch, on a self-hosted runner that is also the web host. The steps:

  1. Publish the site.
  2. Write build.json (commit, ref, time).
  3. Snapshot the live directory to /var/backups/decepub.thepra.dev.
  4. rsync --delete into /var/www/decepub.thepra.dev.
  5. Check that build.json serves the new commit and that a deep client route returns 200 (the SPA fallback). Otherwise, roll back to the snapshot.

The nginx vhost and headers are in deploy/nginx/ (it sends X-Robots-Tag: noindex, nofollow), and deploy/max/setup.sh is the one-time root setup on the server. If you add a file that must never be cached, add it to the expires -1 locations in the vhost.

Conventions

  • Indent with tabs.
  • Nullable is disabled and ImplicitUsings is enabled.
  • Global Razor usings are in _Imports.razor.
  • Styling uses the D components, Tailwind utilities and the neo-* utilities. Use gap-* rather than space-x/y-* on flex containers; the feeds use flex-col-reverse, which space-* gets wrong.
  • SUtility.IfTrueThen(cond, "class") is the helper for conditional classes.