R8 the Mastodon API made real in decePub: featured tags, notify, languages, recommendations, notes, suggestions, the direct stream

A profile shows the hashtags an account features as chips opening their feed; under another account's header is what the
persona keeps of it for itself alone: for one it follows, a bell (follow with notify), the languages of its posts that
reach the home feed (follow with languages[]) and Recommend (endorsements, private here), and for any account a private
note saved on blur or with Save. Settings edits the persona's featured hashtags (added by name or from those used lately,
10 at most) and its reading preferences (/clientapi/avatar/private/preferences), which CascadingState keeps per persona
and the card applies to sensitive media and content warnings. A hashtag's tab features it on the profile. Explore has Who
to follow (/api/v2/suggestions, dismissed for good after asking); Administration has Staff picks. The Direct tab follows
the direct stream's conversation events; status notifications show the post. The follows CSV carries notify and languages.

Verified: Release build, 0 errors (the 3 warnings were there before). Not run against PrivaPub or in the pasture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:58:30 +02:00
1 parent 566b0e33ea
commit d33697e3fc
28 files changed
+1784 -52

No files matched your search

+76 -15
View File
@@ -214,10 +214,10 @@ all of them in every theme. When you add or change one:
- `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`), `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;
- 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;
- 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
@@ -307,11 +307,13 @@ Never redeclare the base's members in a component.
reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, the persona's followers and following and removing a follower, `/api/v2/search`, whose
handles and URLs PrivaPub resolves), `ListService` (the
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
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`
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`
(`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
`InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
`InstanceAdminController`), `ModerationService` (the staff's acts: accounts found, silenced, suspended and given back,
@@ -360,6 +362,25 @@ Never redeclare the base's members in a component.
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
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.
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
@@ -444,9 +465,19 @@ Never redeclare the base's members in a component.
persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
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
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
@@ -458,7 +489,17 @@ Never redeclare the base's members in a component.
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`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
too). Administrators also see Custom emoji (`emoji`, after Announcements; see **Custom emoji**).
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`.
- **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,
@@ -529,6 +570,13 @@ Never redeclare the base's members in a component.
- **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.
- **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`).
- **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`);
@@ -594,7 +642,9 @@ Never redeclare the base's members in a component.
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait. A warning from the staff (`moderation_warning`, `Notification.ModerationWarning`) shows
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
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`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
@@ -602,7 +652,14 @@ Never redeclare the base's members in a component.
- **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.
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.
- **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`),
@@ -617,8 +674,12 @@ Never redeclare the base's members in a component.
ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
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.
- **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