2026-10-04 11:03:14 +02:00
# CLAUDE.md
2026-10-03 12:08:52 +02:00
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` ).
2026-10-04 11:03:14 +02:00
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).
2026-10-03 12:08:52 +02:00
## 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` ,
2026-10-04 11:03:14 +02:00
`/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.
2026-10-03 12:08:52 +02:00
## Commands
```sh
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
```
2026-10-05 01:23:18 +02:00
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature` . There is no linter.
2026-10-17 00:28:34 +02:00
## Verifying
2026-10-05 01:23:18 +02:00
2026-10-17 00:28:34 +02:00
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:
2026-10-05 01:23:18 +02:00
```sh
2026-10-17 00:28:34 +02:00
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)
2026-10-05 01:23:18 +02:00
```
2026-10-17 00:28:34 +02:00
- 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.
2026-10-03 12:08:52 +02:00
2026-10-03 12:43:07 +02:00
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
2026-10-03 12:08:52 +02:00
` 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.
2026-10-03 12:43:07 +02:00
**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.
2026-10-03 12:08:52 +02:00
- 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`.
2026-10-03 12:43:07 +02:00
- ` bash tools/tailwind.sh watch` rebuilds the CSS live next to ` dotnet watch`.
2026-10-17 02:56:57 +02:00
- Tailwind scans every file ` .gitignore` doesn't exclude (minus ` tools/` and ` wwwroot/vendor/`), and only
2026-10-03 12:43:07 +02:00
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (` "btn-" + size`, ` $"btn-{size}"`). Write
2026-10-03 12:08:52 +02:00
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.
2026-10-03 12:43:07 +02:00
- 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.
2026-10-17 03:21:59 +02:00
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.
2026-10-03 12:08:52 +02:00
- ` 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!`.
2026-10-07 19:52:26 +02:00
- 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.
2026-10-03 12:08:52 +02:00
- Never write ` @utility collapse`. It brings back Tailwind's own ` collapse` utility (` visibility: collapse`), and
every collapse disappears.
2026-10-07 00:20:56 +02:00
## 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.
2026-10-17 00:28:34 +02:00
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
2026-10-07 00:20:56 +02:00
- 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:
2026-10-07 19:52:26 +02:00
- buttons, tabs, inputs, selects and file inputs are 44px tall;
2026-10-07 00:20:56 +02:00
- 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).
2026-10-17 00:28:34 +02:00
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (` pointer-coarse:gap-2`).
2026-10-07 00:20:56 +02:00
- **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
2026-10-17 04:02:20 +02:00
` MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a
2026-10-17 05:41:10 +02:00
group, dropping a scheduled post, signing out, and for an administrator deleting an announcement, removing the
server's thumbnail, and changing a login's role, banning or unbanning it, resetting its two-factor sign-in and deleting
it. An act that takes words or a password asks in ` Components/ActDialog.razor` instead, which is its confirmation:
what it does, the fields, Cancel (` data-act-cancel`) and the act (` data-act-submit`); a failure (a wrong password) stays
in the dialog. The staff's (` Staff`: ` data-moderate-confirm="{act}"`, a note ` data-moderate-note`, a warning
` data-moderate-warning`, the administrator's own password ` data-admin-password`) are silencing and suspending an account
and undoing it, removing a post, resolving a report, deleting a persona or a group; the login's own
(` data-own-confirm="{act}"`, its password ` data-own-password`) are deleting one of its personas and a group a persona
owns.
2026-10-07 00:20:56 +02:00
- **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:**
2026-10-17 04:02:20 +02:00
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, a downvote, the thread and
the nerd stats' Details are in More, and the author's picture is in the header;
2026-10-07 00:20:56 +02:00
- 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`).
2026-10-03 12:08:52 +02:00
## 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
2026-10-03 12:43:07 +02:00
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/`).
2026-10-03 12:08:52 +02:00
- 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
2026-10-03 15:26:21 +02:00
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.
2026-10-03 12:08:52 +02:00
2026-10-03 15:26:21 +02:00
**` LayerComponents/CascadingState.razor`** wraps the router in ` App.razor`. It is the app-wide state object. It handles:
2026-10-03 12:08:52 +02:00
- ` PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
- the theme, applied through ` AppStatusService.ApplyTheme` (` wwwroot/js/theme.js`);
2026-10-03 15:26:21 +02:00
- ` IsOnline` / ` IsOffline`, polled every 10 seconds;
2026-10-18 08:58:30 +02:00
- the session: ` IsSignedIn`, ` Personas` (the root's), ` PersonaId` and ` Persona` (its Mastodon ` Account`), ` Preferences`
(its reading preferences, see **Reading preferences**), ` IsModerator` and ` IsAdmin` (the JWT's policies), with
` LoadSession`, ` SwitchPersona`, ` PersonaDeleted` 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;
2026-10-17 05:41:10 +02:00
- a persona the staff suspended (` ViewAvatar.AccountState` ` Suspended` in ` /clientapi/avatar/private/list`, ` CanUse`) is
never acted as: ` LoadSession` skips it, ` SwitchPersona` refuses it, and the switcher and Login's chooser show it
disabled (` data-persona-state`). When PrivaPub refuses the current persona a token while the JWT is good (suspended
meanwhile, or gone), ` HttpService` marks it refused (` TokenAuthStateProvider.Refuse`; no request acts as it again, each
answered ` FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
and ` CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
2026-10-03 15:26:21 +02:00
- ` ProcessError` / ` ProcessWarning`, which log through ` ILoggingService` and show a toast;
2026-10-03 12:08:52 +02:00
- ` LogFromJs`, which JS calls back into through a ` DotNetObjectReference`.
2026-10-03 15:26:21 +02:00
**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`.
2026-10-17 02:56:57 +02:00
Never redeclare the base's members in a component.
2026-10-03 15:26:21 +02:00
**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`.
2026-10-07 00:20:56 +02:00
- ` ConfirmService` asks before what can't be undone (see **Phones**).
2026-10-03 15:26:21 +02:00
2026-10-04 11:03:14 +02:00
**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.
2026-10-05 06:20:18 +02:00
- Auth levels: ` Get` / ` Post` / ` Put` / ` Patch` / ` Delete` need credentials and end the session when PrivaPub refuses
2026-10-17 03:21:59 +02:00
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).
2026-10-03 15:26:21 +02:00
- ` retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
2026-10-07 11:56:59 +02:00
- Each request has its own timeout (` Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
` HttpClient`'s own is off.
2026-10-03 15:26:21 +02:00
- 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.
2026-10-04 11:03:14 +02:00
- 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.
2026-10-03 15:26:21 +02:00
- PrivaPub's ` WebResult` gets ` IsInvalid` from ` Extensions/WebResultExtensions.cs`, a C# 14 extension property.
2026-10-04 11:03:14 +02:00
**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
2026-10-17 03:21:59 +02:00
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
2026-10-17 03:33:46 +02:00
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**).
2026-10-16 23:21:48 +02:00
- **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.
2026-10-17 03:21:59 +02:00
- **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.
2026-10-04 11:03:14 +02:00
- **Services** (` Services/`): ` TimelineService` (every feed as ` FeedPage`s of ` FeedPost`s, paged by ` max_id`/` min_id`;
circles are read as ids from ` /clientapi` and hydrated through ` /api/v1/statuses?id[]=`; located posts come from
` /clientapi/post/nearby` and have no Mastodon actions), ` StatusService` (publish, favourite, boost, bookmark, delete,
2026-10-17 04:02:20 +02:00
threads, mute, block, poll votes, downvotes, who may quote the persona's posts and revoking a quote of one, emoji
reactions through Pleroma's ` /api/v1/pleroma/statuses/:id/reactions`, joining and
2026-10-05 15:19:07 +02:00
leaving remote events through ` /api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
2026-10-07 02:14:53 +02:00
` /clientapi/post/insert`, and a located post's author deletes it through ` /clientapi/post/delete`, its card's one action), ` AccountService` (profiles, relationships, follow,
2026-10-18 08:58:30 +02:00
block, mute, follow requests, the persona's followers and following and removing a follower, an account's featured
hashtags, and what the persona keeps of an account for itself: notify, languages, endorsement and private note (see
**Profiles and search**), ` /api/v2/search`, whose handles and URLs PrivaPub resolves), ` ListService` (the
2026-10-05 02:49:51 +02:00
persona's lists and their members; members are only accounts it follows, found with ` accounts/search?following=true`),
2026-10-05 03:53:51 +02:00
` FilterService` (the persona's v2 filters, their keywords and filtered posts), ` DiscoveryService` (hashtags and
2026-10-18 08:58:30 +02:00
following and featuring them, the persona's featured hashtags and their suggestions, trends, the directory, who to
follow), ` PortabilityService` (Mastodon's CSV exports and imports), ` AdminService`
2026-10-17 04:32:57 +02:00
(` /clientapi/moderator`, the staff's log among it, and ` /clientapi/admin`'s blocks, statistics and backups),
` InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
2026-10-17 05:41:10 +02:00
` InstanceAdminController`), ` ModerationService` (the staff's acts: accounts found, silenced, suspended and given back,
posts removed and reports resolved on ` /clientapi/moderator`; logins read with their personas and groups, their role,
ban, two-factor reset and deletion, and a persona's or a group's deletion with the administrator's password on
` /clientapi/admin`), ` AboutService` (what the server says of itself to anyone: the instance API, its documents
2026-10-17 04:32:57 +02:00
and published domain blocks, never with credentials), ` AnnouncementService` (` /api/v1/announcements`: dismissing and
2026-10-18 08:24:43 +02:00
reacting), ` EmojiService` (the server's custom emoji: ` /api/v1/custom_emojis` for everyone, cached, and
` /clientapi/admin/emoji` for administrators; see **Custom emoji**), ` MediaService` (uploads, and deleting those no post holds), ` GroupService`, ` InstanceService` and
2026-10-05 06:20:18 +02:00
` ProvenanceService`.
2026-10-17 04:02:20 +02:00
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
or downvote, ` PublicNoticeService` asks it once (` PublicNoticeHost` in ` MainLayout`) and remembers the persona in
2026-10-05 15:19:07 +02:00
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`).
2026-10-05 17:55:39 +02:00
- **What a card shows of other platforms:** custom emoji (` Helpers/CustomEmojis.cs`: a post's or account's ` :shortcode:`
2026-10-18 08:24:43 +02:00
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own
come in ` emojis` the same way, see **Custom emoji**), a threadiverse
2026-10-17 04:02:20 +02:00
post's downvotes next to its favourites with the persona's own as a toggle (` data-downvote`, in More on a phone as
` data-downvote-phone`; ` StatusAction.Downvote`/` Undownvote`, PrivaPub's ` /statuses/:id/downvote|undownvote`, answered
with the post, its ` privapub.votes.downvoted` set and its favourite dropped). PrivaPub sends votes on every post, so
the card offers the toggle where they mean something (` Content.CanDownvote`: a threadiverse server's post, a Page, or
one with downvotes; never the persona's own). It also shows blurhash placeholders until a picture loads (` wwwroot/js/blurhash.js`
2026-10-07 11:56:59 +02:00
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`.
2026-10-17 04:32:57 +02:00
- **About** (` Pages/About.razor` at ` /about`, no sign-in, ` data-about`; linked from NavMenu as ` data-nav-about` and from
Login as ` data-about-offer`): the server's name (` data-about-title`), short description and thumbnail, its long
description (` data-about-description`, ` /api/v1/instance/extended_description`), contact (` data-about-contact`), rules
(` data-about-rules`, ` data-about-rule`), privacy policy (` data-about-privacy`), terms of service (` data-about-terms`,
none while PrivaPub answers 404) and the domain blocks the administrator published (` data-about-blocks`,
` data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized
HTML in ` post-content doc-content` (` Styles/feed.css` spaces their headings). Login's heading names the server
(` data-login-title`, ` /api/v2/instance` read without credentials and kept for the session).
2026-10-05 01:23:18 +02:00
- **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.
2026-10-05 23:04:31 +02:00
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.
2026-10-05 18:39:56 +02:00
An account that moved says where to (` Account.Moved`, which PrivaPub fills once the new account names the old one),
2026-10-17 05:41:10 +02:00
linking the new account's profile. One the staff silenced, or whose server is silenced, says Limited
(` Account.Limited`, ` data-account-limited`).
2026-10-06 16:47:19 +02:00
Another's profile and posts can be reported (` Components/ReportDialog.razor`, ` /api/v1/reports`): a category, words,
2026-10-17 04:32:57 +02:00
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A
violation (` ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(` /api/v1/instance/rules`, sent as ` rule_ids`; ` data-report-rules`, a checkbox ` data-report-rule="{id}"` each).
2026-10-06 16:47:19 +02:00
Who liked or boosted a post opens from its card's menu (` Components/InteractedByDialog.razor`).
2026-10-18 08:58:30 +02:00
The hashtags an account features (` GET /api/v1/accounts/:id/featured_tags`: a persona's own, an account elsewhere's as
its server told them) are chips under its fields (` data-profile-featured-tags`, each a ` button.badge`
` data-profile-featured-tag="{name}"` opening that hashtag's feed tab, its count and last use as its title); on the
persona's own profile, with a link to edit them (` data-profile-featured-tags-edit`, ` settings?open=profile`, which opens
Settings' Profile section). Under another account's header is what the persona keeps of it **for itself alone**
(` data-profile-private`; the account is never told, and none of it federates):
- for an account it follows, a bell (` data-profile-notify="on|off"`: follow again with ` notify`, which gives the persona a
` status` notification for each of its new posts), the languages of its posts that reach the home feed
(` data-profile-languages`, a ` DDropdown SheetOnNarrow` whose value is the codes kept, comma-separated, empty for every
language; a ` DCheckbox` ` data-profile-language="{code}"` each, saved together with ` data-profile-languages-save`,
` data-profile-languages-all` for every language; follow again with ` languages[]`, an empty value for none, since an
empty list is every language) and its recommendation (` data-profile-endorse="on|off"`, ` /accounts/:id/endorse|unendorse`:
Mastodon's endorsements, which PrivaPub keeps private, the owner's default: worded Recommend / Keep in my
recommendations, and said to be seen by the persona alone). Both follow calls send ` reblogs` as the relationship has
it, since a follow always sets it (` AccountService.SetNotify`, ` SetLanguages`). Unfollowing drops the endorsement;
- for any account, a private note (` textarea[data-profile-note]`, the relationship's ` note`, ` POST /accounts/:id/note`
with ` comment`, 2000 characters at most, empty takes it away), saved as it loses focus (a ` focusout` on its wrapper) or
with ` data-profile-note-save`; ` data-profile-note-saved` says it was. Settings' biography textarea in ProfileEditor is
` data-profile-note` too, on another page.
2026-10-17 04:02:20 +02:00
On the persona's own profile its counts are buttons (` data-profile-following`, ` data-profile-followers`) opening the
accounts it follows and its followers (` Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
lists them to the persona alone (` hide_collections`), so another profile keeps plain counts. A follower is removed
there (` remove_from_followers`, ` data-follower-remove`, asked first). The follow requests waiting for the persona show
above its posts (` Components/FollowRequestsPanel.razor`, ` data-follow-request`, accepted or refused; hidden while none
waits), and the Notifications tab opens the same panel in a sheet while some wait (` data-follow-requests-open`).
2026-10-06 08:38:49 +02:00
- **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.
2026-10-05 01:23:18 +02:00
A card in a fixed-height flex column with ` overflow-hidden` needs ` flex-none`, or it collapses to nothing.
2026-10-05 18:24:39 +02:00
- **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
2026-10-17 05:41:10 +02:00
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out; the
ones it owns deleted with the login's password (` data-group-delete-own`, ` ActDialog`; ` /clientapi/group/delete`,
` DeleteGroupForm`: its members are told, its name stays taken). Only a group's owner and moderators see who is in it.
2026-10-05 02:49:51 +02:00
- **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.
2026-10-05 03:10:27 +02:00
- **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
2026-10-17 04:02:20 +02:00
its media (blur). Another's post goes into a filter from its card's menu ("Filter this post", ` data-filter-post`,
` Components/FilterPostDialog.razor`: ` POST /api/v2/filters/:id/statuses`, a filter that holds it already says so), and
the card reads the post again so the filter applies at once. On /filters the posts a filter holds are read by id when
it opens (` TimelineService.Read`, ` /api/v1/statuses?id[]=`) and show their author and words, linking their thread
(` data-filter-status`), or say "Post no longer available" (` data-filter-status-missing`).
2026-10-06 01:00:57 +02:00
- **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.
2026-10-05 04:42:42 +02:00
- **The composer** (` Components/Composer.razor`) writes a post, a reply (` InReplyTo`), a quote (` Quoting`, offered on a
2026-10-07 11:56:59 +02:00
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`);
2026-10-17 04:02:20 +02:00
- a description changed after the upload is PUT before the post;
- a file taken out before posting is deleted from PrivaPub (` MediaService.Discard`, ` DELETE /api/v1/media/:id`,
` data-upload-remove`), unless a post holds it already (an edit's own media); a failure is only logged.
2026-10-07 11:56:59 +02:00
- **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.
2026-10-06 16:47:51 +02:00
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
2026-10-18 08:24:43 +02:00
(` /api/v2/search`, never resolving), once typing pauses; a custom emoji (` :` and two letters at least) is offered the
server's own at once, and the emoji button opens the picker (see **Custom emoji**).
2026-10-06 13:03:19 +02:00
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`).
2026-10-17 02:56:57 +02:00
- **Threads** (` Pages/Thread.razor` at ` /expand/{id}`): opening a remote thread makes PrivaPub read its replies
2026-10-05 06:20:18 +02:00
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.
2026-10-06 16:47:51 +02:00
- **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.
2026-10-17 04:02:20 +02:00
- **Quotes of the persona's posts** (FEP-044f): its own public and unlisted posts are given who may quote them from the
card's menu ("Who may quote it", ` data-quote-policy` holding the current one; ` Components/QuotePolicyDialog.razor`,
` data-quote-policy-choice`: anyone, its followers or nobody, ` PUT /api/v1/statuses/:id/interaction_policy` with
` quote_approval_policy`; the policy is read back from ` quote_approval.automatic`, ` StatusService.QuotePolicyOf`). A
post by someone else quoting one of them has "Revoke quote" in its menu (` data-revoke-quote`, asked first; ` POST
/api/v1/statuses/:quoted/quotes/:quoting/revoke`, answered with the quoting post, its quote revoked).
2026-10-17 03:21:59 +02:00
- **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
2026-10-17 03:33:46 +02:00
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
2026-10-17 03:21:59 +02:00
deleted, every persona with it, through ` /clientapi/user/update/password` and ` /clientapi/user/delete`; the client then
2026-10-17 05:41:10 +02:00
signs out), its personas (` PersonasEditor`: each used, shown silenced or suspended (` data-persona-state`; a suspended
one can't be used), and deleted with the login's password (` data-persona-delete-own`, ` ActDialog`;
` /clientapi/avatar/private/delete`, ` DeletePersonaForm`), the groups it owns with it; ` CascadingState.PersonaDeleted`
forgets its token and, when it was the current one, acts as another, or as none, and a new one is made below), the
persona's profile (` ProfileEditor`:
2026-10-07 11:56:59 +02:00
` 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,
2026-10-18 08:58:30 +02:00
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its featured
hashtags below it (` FeaturedTagsEditor`, ` data-featured-tags`: ` /api/v1/featured_tags`, public, so the persona's followers
are told of each change; each ` data-featured-tag="{name}"` with ` data-featured-tag-remove`, removed at once since a
hashtag can be featured again; added by ` input[data-featured-tag-new]` (Enter or ` data-featured-tag-add`, PrivaPub's 422
shown under it) or from those it used lately, ` /featured_tags/suggestions`, as ` data-featured-tag-suggestion="{name}"`;
at most the instance API's ` max_featured_tags`, 10, counted in ` data-featured-tags-count`), its reading preferences
(` ReadingPreferencesEditor`, the Reading section: ` data-preference-expand-media`, a select of default, show_all or
hide_all, ` data-preference-expand-spoilers`, a toggle, ` data-preferences-save`, ` data-preferences-reset`; ` GET`/` POST
/clientapi/avatar/private/preferences` with ` PersonaPreferencesForm`, since Mastodon's API only reads them at
` /api/v1/preferences`; see **Reading preferences**), its mutes,
blocks and blocked servers (` MutesAndBlocks`), and ` DataTransfer`: follows (with their boosts, notify and languages
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
imported as the CSV files Mastodon writes, so a persona moves between servers. An import
2026-10-07 12:49:51 +02:00
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.
2026-10-17 04:32:57 +02:00
- **Administration** (` Pages/Administration.razor`, ` Components/Admin/`): each section is a ` DCollapse` marked
` data-admin-section`, and its panel loads when the page opens. Moderators see Reports (` reports`) and the Moderation
log (` log`); administrators also see Instance (` instance`), Rules (` rules`), Announcements (` announcements`),
Blocked servers (` blocks`), Relays (` relays`), Media limits (` media`), Statistics (` statistics`) and Backups
2026-10-17 05:41:10 +02:00
(` backups`). Moderators also see Accounts (` accounts`), administrators Users & personas (` users`). PrivaPub checks the
role on every call (moderators: ` /clientapi/moderator`, administrators: ` /clientapi/admin`, checked against its database
2026-10-18 08:58:30 +02:00
too). Administrators also see Custom emoji (` emoji`, after Announcements; see **Custom emoji**) and Staff picks
(` staff-picks`, after Custom emoji).
- **Staff picks** (` StaffPicksPanel`, ` data-staff-picks`, ` InstanceAdminService.StaffPicks`, ` AddStaffPick`,
` RemoveStaffPick`; ` GET /clientapi/admin/staff-picks`, ` POST /staff-picks/insert` with ` StaffPickForm`, ` POST
/staff-picks/delete?accountId=`, each answering the picks as ` List<ViewStaffPick>`): the accounts suggested first to
every persona (Explore's Who to follow), in their order, each ` data-staff-pick="{handle}"` (` data-staff-pick-id`,
` data-staff-pick-shown` false for one deleted, suspended or silenced since, which is suggested to nobody) with
` data-staff-pick-remove` (not asked: it can be picked again). Added from ` input[data-staff-pick-search]`
(` /api/v2/search`, never resolving, communities left out) as ` data-staff-pick-option="{account id}"`; 40 at most
(` data-staff-picks-count`). PrivaPub's 404 (no account it can suggest), 409 (picked already) and 422 (past 40) are
toasts. The staff's log names them ` staff_pick.insert` and ` staff_pick.delete`.
2026-10-17 05:41:10 +02:00
- **Users & personas** (` UsersPanel`, ` data-users`, ` /clientapi/admin/roots`; the one place a login is shown with its
personas): newest first, 50 at a time (` data-users-more`), found by a login's or a persona's name (` data-users-search`;
` data-users-reload`). Each login (` data-user="{login name}"`, ` data-user-role` user|moderator|admin,
` data-user-banned` true|false) shows its badges (` data-user-you`, ` data-user-banned-badge`, ` data-user-totp`,
` data-user-email` none|verified|unverified) and, unless it is the administrator's own, its role
(` data-user-role-select`), ` data-user-ban` or ` data-user-unban`, ` data-user-totp-reset` (with two-factor on) and
` data-user-delete`, each asked first (ConfirmService; a role PrivaPub refuses, one's own or the last administrator's,
is a 409 and the select shows the role again). ` data-user-open` shows its personas (` data-user-personas`): each
(` data-persona="{userName}"`, ` data-persona-state` normal|silenced|suspended) with ` data-persona-silence` or
` -unsilence`, ` -suspend` or ` -unsuspend` and ` data-persona-delete` (the administrator's password), and the groups it
owns (` data-group="{userName}"`, ` data-group-delete`, the password). An act on the administrator's own persona moves
the session with it (` CascadingState.PersonaDeleted`, ` ReloadPersonas`).
- **Accounts** (` AccountsPanel`, ` data-accounts`, ` /clientapi/moderator/accounts`): accounts here and elsewhere found by a
name, ` user@host ` or an address (` data-accounts-search`, results in ` data-accounts-results`), and the ones silenced or
suspended, most recently first (` data-accounts-moderated`, 50 at a time, ` data-accounts-more`). Each
(` data-moderated-account="{handle}"`, ` data-account-state` normal|silenced|suspended, ` data-account-id`) shows what the
staff did (` data-account-silenced`, ` -suspended`, its server's block ` data-account-domain-silenced`, ` -suspended`, the
staff's note ` data-account-note`) and ` data-account-silence` or ` -unsilence`, ` -suspend` or ` -unsuspend`, asked in
` ActDialog` with a note and, for a persona here and a silence or suspension, a warning it is told (Mastodon's
` moderation_warning`; an account elsewhere is told nothing).
2026-10-17 04:32:57 +02:00
- **The settings save whole.** ` POST /clientapi/admin/settings` replaces everything ` ServerSettingsForm` carries (a rule
left out is removed), so Instance, Rules and Media limits each save through ` InstanceAdminService.SaveSettings`: it
reads the settings PrivaPub has now, builds the form from them (` FormOf`: the rules not deleted, in order, the media
overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub
has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes
` AboutService` read the instance again.
- **Instance** (` InstancePanel`, ` data-instance`): the server's name (` data-instance-title`), short description
(` data-instance-short-description`), Markdown description (` data-instance-description`), contact address
(` data-instance-email`) and contact persona (` data-instance-contact`, removed with ` data-instance-contact-remove`;
picked from ` data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as
` data-instance-contact-search` finds them through ` /api/v2/search`, a local account's ` acct` having no host), privacy
policy (` data-instance-privacy`), terms (` data-instance-terms`, their date ` data-instance-terms-date`), and whether the
weekly activity is published (` data-instance-publish-activity`, off by default, with why). Registrations are the
configuration's and only shown (` data-instance-registrations`). ` data-instance-save`, ` data-instance-reset`. The
thumbnail (` data-instance-thumbnail`, a file input; ` data-instance-thumbnail-image`, ` data-instance-thumbnail-remove`,
asked first) is saved as soon as it is picked, streamed from the file as multipart ` file`.
- **Rules** (` RulesPanel`, ` data-rules`): one row a rule (` data-rule="{position}"`, ` data-rule-id`, its
` data-rule-text` and ` data-rule-hint`, ` data-rule-up`, ` data-rule-down`, ` data-rule-delete`), ` data-rule-add`,
` data-rule-save`, ` data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed
(` data-rule-deleted="{id}"`); ` data-rule-restore` lists it again with its id.
- **Announcements** (` AnnouncementsPanel`, ` data-announcements`): each (` data-announcement="{id}"`,
` data-announcement-state` shown, ended or draft) with its HTML, times, ` data-announcement-reads` and reactions;
` data-announcement-new` and ` data-announcement-edit` open ` data-announcement-dialog` (` data-announcement-text`,
` -starts`, ` -ends` as datetime-local in the reader's time, ` -all-day`, ` -published`, ` -save`, ` -cancel`);
` data-announcement-delete` asks first.
- **Blocked servers** (` ServerBlocksPanel`, ` data-server-blocks`): a block is published on About and
` /api/v1/instance/domain_blocks` only when asked (` data-server-block-published`; ` data-server-block-obfuscate` stars
part of its name), since that tells the blocked server and everyone; ` data-server-block-edit` fills the form with a
block, since blocking a server again changes its block. ` data-server-block-save`.
- **Relays** (` RelaysPanel`, ` data-relays`): each (` data-relay="{address}"`, ` data-relay-state`, ` data-relay-source`
configuration or settings; ` data-relay-remove` for the settings' ones: a configured one is removed only from
` Federation:Relays`, and PrivaPub answers 409), added by ` data-relay-address` and ` data-relay-add`. PrivaPub answers
202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds.
- **Media limits** (` MediaLimitsPanel`, ` data-media-limits`): ` data-media-limit="quota|image|video|seconds"` rows,
inputs ` data-media-quota`, ` -image`, ` -video` (megabytes) and ` -seconds`, empty for the configured value, beside the
configured and the effective values (` data-media-effective`) and the hard caps as maxima; ` data-media-save`,
` data-media-reset`, ` data-media-use-configured`. What was set stays to the byte while its megabytes are not changed.
- **Moderation log** (` ModerationLogPanel`, ` data-log`, ` /clientapi/moderator/log`): newest first, 50 at a time
(` data-log-more` asks for those before ` Next`), each entry (` data-log-entry="{id}"`, ` data-log-action`) with who
(` data-log-by`: you, a login name to administrators, ` cli`, or none), what, to what (` data-log-target`) and its note
(` data-log-note`). ` data-log-reload`.
- **Reports** (` ReportsPanel`): a violation's rules (` data-report-rules`, ` data-report-rule-id`) as PrivaPub keeps their
2026-10-17 05:41:10 +02:00
texts, a removed rule's too. "Resolve and…" (` data-report-resolve`, a ` DDropdown SheetOnNarrow`) resolves it with
` data-report-resolve-action` none, silence, suspend (with an account) or remove_posts (with posts), asked in
` ActDialog` with a note and a warning (` ResolveReportForm`; the act comes first, so one that fails leaves the report
open). A resolved report shows what was done and its note (` data-report-resolution="{action}"`,
` data-report-resolution-note`).
- **A post from the feed:** a moderator removes another's post from its card's menu (` data-moderate-remove-post`,
` ActDialog`, ` /clientapi/moderator/posts/remove`): one written here is deleted as its author would, and its author
warned; a copy of one from elsewhere is hidden here. The page takes the card out.
2026-10-18 08:58:30 +02:00
- **Reading preferences** (PrivaPub's R8, ` ViewPersonaPreferences`): how the persona reads what others post, read with it
(` AuthService.PersonaPreferences` in ` CascadingState.SwitchPersona`, PrivaPub's defaults when they can't be read) and
kept as ` CascadingState.Preferences`, replaced by ` PreferencesChanged` after Settings saves them (and when Settings reads
them changed from another device). The card applies them: media wait behind their button (` data-media-hidden`, its
blurhash behind it) when marked sensitive (` default`), never (` show_all`) or always (` hide_all`, "Hidden media, show"),
a blur filter's always (` Content.HidesMedia`); a content warning starts open with ` ExpandSpoilers`. ` Preferences` is in
` Content.RenderKey`. ` Models/Types/ExpandMedia.cs` maps the modes (` ExpandMediaModes`).
2026-10-18 08:24:43 +02:00
- **Custom emoji** (PrivaPub's R7: the server's own emoji, ` Services/EmojiService.cs`).
- **For everyone:** ` GET /api/v1/custom_emojis` (no credentials; PrivaPub lists only those neither disabled nor kept out
of its picker) is read once a day and kept in localStorage (` CustomEmojiCache`, registered in ` ClientJsonContext`);
a failed read keeps what was held and asks again five minutes later. An administrator's change in this client reads it
again at once (` EmojiService.Refresh`). An emoji out of the picker is still taken by PrivaPub when its ` :shortcode:` is
written, but the client knows only the listed ones.
- **Rendering:** a local post, profile and announcement carry the emoji they name in ` emojis` like a remote one, so
` CustomEmojis` shows them (the card, Profile, the announcements banner, NavMenu's persona name; NavMenu's other
personas, which ` /clientapi` sends without emoji, take the server's list when the menu opens). A located post
(` /clientapi/post/nearby`, no ` emojis`) takes the server's emoji its words name (` EmojiService.NamedIn`). A reaction
with a custom emoji shows its picture (Pleroma's ` url`; an announcement's ` url`, or the server's list for one the
stream tells of first) and is toggled as ` :shortcode:` (` Content.ReactionOf`), which PrivaPub matches among the
post's own emoji for another server's; a notification of one says "reacted with" and its picture
(` emoji_url`, ` data-notification-emoji`).
- **The picker** (` Components/EmojiPicker.razor`): a ` DDropdown SheetOnNarrow` whose trigger is the caller's
(` data-emoji-picker-open`, set through ` DDropdown.TriggerAttributes`), holding, only while open (` data-emoji-picker`),
a search (` input[data-emoji-picker-search]`, its ` form` naming no form; Enter takes the first found), the caller's
quick choices (` Quick`), the server's emoji by category (none first) and common unicode ones by group
(` Helpers/UnicodeEmojis.cs`, single emoji only, searched by their Mastodon/GitHub shortcode and a few words). Each
choice is ` data-emoji-choice` (the shortcode, or the character) and goes to ` OnPick` as ` :shortcode:` or the
character; it closes the picker first. A GIF shows its still picture under ` prefers-reduced-motion`. The card's React
button (` data-react`) and the announcements banner's (` data-announcement-react`) react with it, after their quick
reactions; the composer's emoji button writes it where the caret is, replacing a selection, else at the end, a
shortcode with a space on each side where a letter would touch it (` AppStatusService.CaretOf` / ` PlaceCaret`,
` main.js`'s ` caretOf` and ` placeCaret`).
- **Suggestions:** in the composer, ` :` and two letters or more at the end of the words offer the matching emoji
(shortcodes starting with it first, eight at most) among the mention and hashtag suggestions, as
` data-emoji-suggestion="{shortcode}"`; one picked is written ` :shortcode: `.
- **Administration** (` Components/Admin/EmojiPanel.razor`, ` data-emoji-panel`, ` data-admin-section="emoji"`;
` /clientapi/admin/emoji/list|insert|update|delete`): every emoji, disabled ones too, grouped by category
(` data-emoji-group="{category}"`, empty for none): each ` data-emoji="{shortcode}"` with ` data-emoji-state`
picker|hidden|disabled, its picture, size and badges, ` data-emoji-edit` and ` data-emoji-delete` (asked first: the
posts and profiles using it show ` :shortcode:` as text). ` data-emoji-new` and ` data-emoji-edit` open
` data-emoji-dialog`: ` input[data-emoji-shortcode]` (read-only when editing: a shortcode is the emoji's id elsewhere and
never changes), ` input[data-emoji-category]` (a datalist of the categories in use), ` data-emoji-picture` (a file input:
PNG, GIF, WebP or JPEG, 256 KB at most, checked here first; ` data-emoji-preview` current|picked), the toggles
` data-emoji-picker` (in the picker) and ` data-emoji-disabled`, ` data-emoji-save`, ` data-emoji-cancel`. The form is
` LocalEmojiForm` (validated by its annotations) sent as multipart with the picture streamed from the file as ` image`,
the whole state on an update, the picture only when a new one is picked. PrivaPub's refusals show in the dialog,
their status on ` data-emoji-error` (409 a shortcode taken in any case, 422 a picture refused, 400 an invalid form).
` data-emoji-reload` reads the list again. Note that ` data-emoji-picker` names both the open picker (a ` div`) and the
dialog's toggle (an ` input`).
2026-10-17 04:02:20 +02:00
- **Statistics** (` Components/Admin/StatisticsPanel.razor`, ` /clientapi/admin/statistics`, typed in ClientModels'
` Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the
ledger's events and the server's own days. Counted per server, never per account.
- A server's name opens ` HostDetailDialog` (` data-statistics-host-open`, ` /hosts/{host}?days=`): what it says of itself
as nerd rows by their field names, its days with the main counters (received, in, out and failures, summed from the
ledger's ` channel:…:…:outcome` keys like the servers table; ` live` for a day not rolled up yet), its last events, and
"Describe again" (` data-host-describe`, ` POST /hosts/{host}/describe`, a queued job PrivaPub answers 202).
- Events (` data-statistics-events`, ` EventsTable`): by server, channel and outcome as the ledger keys them, 100 at a
time, and "Load more" asks for those ` before` the last one's time.
- The server's days (` data-statistics-server`, ` /server?days=`): its client API's requests by status class, the
documents it served, and its ledger written, dropped and failed.
2026-10-07 12:45:30 +02:00
- **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.
2026-10-06 16:47:51 +02:00
- **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
2026-10-18 08:58:30 +02:00
links there while requests wait. A ` status` notification (an account the persona follows with notify posted, PrivaPub's
R8) says "{0} posted" with the post (` data-notification="status"`, and ` data-notification-status` holding the post's
id). A warning from the staff (` moderation_warning`, ` Notification.ModerationWarning`) shows
2026-10-17 05:41:10 +02:00
what they did and what they wrote (` data-notification-warning="{action}"`, ` data-notification-warning-text`). A
notification is dismissed from its line (` data-notification-dismiss`,
2026-10-17 04:02:20 +02:00
` TimelineService.DismissNotification`: ` /api/v2/notifications/:group_key/dismiss`, its whole group, ` ungrouped-{id}` for
one alone), and the page takes that item out (` Content.OnDismissed`).
2026-10-05 03:53:51 +02:00
- **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
2026-10-18 08:58:30 +02:00
it holds), the directory, who to follow, and the hashtags the persona follows. A hashtag's feed tab follows it too, and
features it on the persona's profile (` data-tag-feature="on|off"`, ` /api/v1/tags/:name/feature|unfeature`, the Tag's
` featuring`; public, its followers are told).
- **Who to follow** (` data-suggestions`, ` GET /api/v2/suggestions`, 40): each ` data-suggestion="{acct}"` with
` data-suggestion-source` (staff: picked by the staff, past_interactions: the persona interacted with it, global:
popular here; its ` sources` as the badge's title), ` data-suggestion-follow` (taken off the list once followed or asked)
and ` data-suggestion-dismiss` (` DELETE /api/v1/suggestions/:id`, for good, so asked first). PrivaPub never suggests the
persona itself nor a sibling persona of its login.
2026-10-04 11:03:14 +02:00
- **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`),
2026-10-04 11:12:02 +02:00
its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from
2026-10-04 11:44:51 +02:00
` 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.
2026-10-04 11:03:14 +02:00
- **The feed page** (` Pages/Index.razor`): ` FeedTabs`, ` Content` (the card, which does its own actions), ` Composer`,
2026-10-06 01:21:41 +02:00
` 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
2026-10-05 04:15:54 +02:00
streaming API (` Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
2026-10-18 08:58:30 +02:00
for home, lists, local, federated, hashtags, notifications and direct conversations, and wait behind a "new" button; a
deletion or an edit it tells applies at once. The Direct tab follows the ` direct` stream, whose ` conversation` event
(` StreamMessage.Conversation`) moves that conversation up: behind the "new" button, or at once at the top when the
persona wrote its newest message (the message it just sent, shown alone until then, is replaced by it). Elsewhere the
Direct tab's unread badge is still counted on every poll tick. Other feeds, and any feed while the stream is down, are
polled every 45 s while visible.
2026-10-17 04:32:57 +02:00
- **Announcements** (` Components/AnnouncementsBanner.razor`, above the tabs, ` data-announcement-banner`): the ones
shown now that the persona has not dismissed, one at a time (` data-announcement-previous`, ` -next`,
` -position`), dismissed (` data-announcement-dismiss`, ` POST /api/v1/announcements/:id/dismiss`) and reacted to
(` data-announcement-reaction="{emoji}"` toggles the persona's own, ` data-announcement-react` offers more,
` data-announcement-react-emoji`; ` PUT`/` DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They
come live on the user stream (` announcement`, ` announcement.reaction`, ` announcement.delete`):
` StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it
alone on a polled feed), and raises what it tells as ` Announced`; the user stream's posts and notifications are
the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the
feed's cards.
2026-10-05 20:30:45 +02:00
- **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.
2026-10-04 11:03:14 +02:00
- **The globe** (` wwwroot/js/globe.js`, an ES module imported by ` GlobePanel`) runs on globe.gl, vendored in
2026-10-04 20:23:42 +02:00
` 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,
2026-10-07 02:15:06 +02:00
` 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.
2026-10-04 20:23:42 +02:00
- **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`.
2026-10-04 11:03:14 +02:00
- **Nerd stats** (` Components/Nerd/NerdTip.razor`, rows from ` NerdRow`): a post's ids, times and provenance (read on
2026-10-04 11:44:51 +02:00
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
2026-10-04 20:23:42 +02:00
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
2026-10-07 02:15:06 +02:00
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
2026-10-03 12:08:52 +02:00
**Localization (` Services/CoalescingStringLocalizer.cs`).**
2026-10-03 15:26:21 +02:00
- 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.
2026-10-17 02:56:57 +02:00
- ` 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.
2026-10-18 08:24:43 +02:00
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): ` AuthData`, ` PublicCacheData`, ` FeedFilter`,
the server cache ` ServerInfos` and the server's custom emoji ` CustomEmojiCache`, keyed by ` nameof(Type)`. There is no IndexedDB: ` ILoggingService` writes to the
2026-10-17 02:56:57 +02:00
browser console only, and ` main.js` deletes the database (` data`) where the client once kept its log, wherever a browser
still holds it.
2026-10-04 11:03:14 +02:00
**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=…`.
2026-10-03 12:08:52 +02:00
**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`.
2026-10-17 02:56:57 +02:00
- 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
2026-10-04 11:03:14 +02:00
modules (` js/feed.js`, ` js/globe.js`) are imported on demand through ` IJSObjectReference`.
2026-10-03 12:08:52 +02:00
2026-10-17 02:56:57 +02:00
**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.
2026-10-03 12:08:52 +02:00
2026-10-03 15:26:21 +02:00
## 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:
` ``csharp
2026-10-04 11:03:14 +02:00
List<FeedPost> posts = []; // DO
@posts.Count // DO
@(posts?.Count ?? 0) // DON'T
2026-10-03 15:26:21 +02:00
` ``
` = ""` 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:
` ``csharp
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:
` ``csharp
2026-10-04 11:03:14 +02:00
var result = await StatusService.Delete(Status.Id);
2026-10-03 15:26:21 +02:00
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Message deleted."]);
2026-10-04 11:03:14 +02:00
await OnDeleted.InvokeAsync(Post);
2026-10-03 15:26:21 +02:00
` ``
**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.
2026-10-04 11:03:14 +02:00
- POST bodies are JSON payloads for ` /clientapi` and the Mastodon API (snake_case DTOs), forms for ` /oauth`.
2026-10-03 15:26:21 +02:00
**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);
2026-10-04 11:03:14 +02:00
- ` username` and ` current-password` on the login form, so password managers fill it;
2026-10-03 15:26:21 +02:00
- 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.
2026-10-04 11:03:14 +02:00
- 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.
2026-10-03 15:26:21 +02:00
- 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.
2026-10-04 11:03:14 +02:00
**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).
2026-10-03 15:26:21 +02:00
**Wiring a feature to PrivaPub:**
2026-10-04 11:03:14 +02:00
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`.
2026-10-03 15:26:21 +02:00
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.
2026-10-03 12:08:52 +02:00
## Deployment
2026-10-03 12:43:07 +02:00
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.
2026-10-03 12:08:52 +02:00
` .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.
2026-10-04 11:03:14 +02:00
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.
2026-10-03 12:08:52 +02:00
## 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.