An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests checks it with a WriteFreely blog; the render matrix still passes (70). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
498 lines
36 KiB
Markdown
498 lines
36 KiB
Markdown
# 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).
|
||
|
||
`Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API.
|
||
|
||
## Sibling repo dependency
|
||
|
||
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
|
||
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
|
||
|
||
```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
|
||
```
|
||
|
||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||
|
||
## Testing: end to end against the pasture
|
||
|
||
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
|
||
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
|
||
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
|
||
|
||
```sh
|
||
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
|
||
../SocialPub/tools/pasture/town.sh seed village
|
||
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
|
||
```
|
||
|
||
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
|
||
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
|
||
time and published only then; the deploy fails if it ever ships.
|
||
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
|
||
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
|
||
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
|
||
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
|
||
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
|
||
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
|
||
town's report (`out/client.jsonl`).
|
||
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
||
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
||
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
||
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
||
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
||
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
|
||
|
||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||
|
||
`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/`, `wwwroot/vendor/`, `Docs/`), 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 persona (see **Settings** under PrivaPub wiring).
|
||
- `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!`.
|
||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||
every collapse disappears.
|
||
|
||
## 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`.
|
||
|
||
`PagesBase` is an empty subclass of it that nothing uses. 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`.
|
||
|
||
**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` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||
PrivaPub redirects it).
|
||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||
- 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, then forgets the session and IndexedDB.
|
||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
|
||
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
|
||
`/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||
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`
|
||
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
|
||
link, participants and status.
|
||
- **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.
|
||
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`: its words and
|
||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
||
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
||
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
|
||
and adds what was found, in the thread's order.
|
||
- **Interaction policies** (GoToSocial's): a reply or boost whose target's author must approve it shows so on its card
|
||
(`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by
|
||
PrivaPub (422) and shown as an error.
|
||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
||
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
||
the client then signs out), the persona's profile (`ProfileEditor`:
|
||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
|
||
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||
resolves each row through PrivaPub and acts one row at a time; it can be stopped.
|
||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||
- **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`. 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, the phase and the season
|
||
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||
- **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 camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||
the place's elevation and day length, the next equinox or solstice).
|
||
|
||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||
`ErrorsResource`.
|
||
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
|
||
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
|
||
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
|
||
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||
|
||
**Client storage** comes in two layers:
|
||
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
|
||
server cache `ServerInfos`, keyed by `nameof(Type)`.
|
||
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
|
||
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
|
||
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
|
||
the mock feed's `Message` store out).
|
||
|
||
**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 (IndexedDB, HeadElement, rxjs) 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.
|
||
|
||
## 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
|
||
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:
|
||
|
||
```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
|
||
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.
|