From d33697e3fc8a51154bd811eae30f8283fac30490 Mon Sep 17 00:00:00 2001 From: thepra Date: Sun, 18 Oct 2026 08:58:30 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 91 +++++-- ClientJsonContext.cs | 5 + Components/Admin/ModerationLogPanel.razor | 2 + Components/Admin/StaffPicksPanel.razor | 165 +++++++++++++ Components/Content.razor | 38 ++- Components/Feed/FeedTabs.razor | 27 ++- Components/Settings/FeaturedTagsEditor.razor | 186 +++++++++++++++ .../Settings/ReadingPreferencesEditor.razor | 117 +++++++++ LayerComponents/CascadingState.razor | 25 +- MastodonJsonContext.cs | 4 + Models/APIs.cs | 13 + Models/Mastodon/Account.cs | 12 + Models/Mastodon/Discovery.cs | 35 ++- Models/Mastodon/InstanceV2.cs | 7 + Models/Types/ExpandMedia.cs | 30 +++ Pages/Administration.razor | 16 +- Pages/Explore.razor | 105 ++++++++- Pages/Index.razor | 26 +- Pages/Profile.razor | 222 +++++++++++++++++- Pages/Settings.razor | 45 +++- Resources/AllStrings.it.resx | 210 +++++++++++++++++ Resources/AllStrings.resx | 210 +++++++++++++++++ Services/AccountService.cs | 82 ++++++- Services/AuthService.cs | 26 ++ Services/DiscoveryService.cs | 79 ++++++- Services/InstanceAdminService.cs | 36 +++ Services/PortabilityService.cs | 12 +- Services/StreamingService.cs | 10 +- 28 files changed, 1784 insertions(+), 52 deletions(-) create mode 100644 Components/Admin/StaffPicksPanel.razor create mode 100644 Components/Settings/FeaturedTagsEditor.razor create mode 100644 Components/Settings/ReadingPreferencesEditor.razor create mode 100644 Models/Types/ExpandMedia.cs diff --git a/CLAUDE.md b/CLAUDE.md index e1b21f0..8ad0f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`): 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 diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 48cec2d..9276163 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -54,6 +54,8 @@ namespace decePubClient; [JsonSerializable(typeof(ViewAvatar))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(InsertAvatarForm))] +[JsonSerializable(typeof(ViewPersonaPreferences))] +[JsonSerializable(typeof(PersonaPreferencesForm))] [JsonSerializable(typeof(ViewGroup))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ViewGroupMember))] @@ -99,6 +101,9 @@ namespace decePubClient; [JsonSerializable(typeof(ViewRelay))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(RelayForm))] +[JsonSerializable(typeof(ViewStaffPick))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(StaffPickForm))] [JsonSerializable(typeof(ViewAdminActionsPage))] [JsonSerializable(typeof(UsersIds))] [JsonSerializable(typeof(ModerateAccountForm))] diff --git a/Components/Admin/ModerationLogPanel.razor b/Components/Admin/ModerationLogPanel.razor index d473afc..056564e 100644 --- a/Components/Admin/ModerationLogPanel.razor +++ b/Components/Admin/ModerationLogPanel.razor @@ -166,6 +166,8 @@ "persona.delete" => Localizer["Persona deleted"], "group.delete" => Localizer["Group deleted"], "post.remove" => Localizer["Post removed"], + "staff_pick.insert" => Localizer["Account picked by the staff"], + "staff_pick.delete" => Localizer["Staff pick removed"], _ => action }; } diff --git a/Components/Admin/StaffPicksPanel.razor b/Components/Admin/StaffPicksPanel.razor new file mode 100644 index 0000000..6fe0c22 --- /dev/null +++ b/Components/Admin/StaffPicksPanel.razor @@ -0,0 +1,165 @@ +@inherits LocalizableComponentBase + +@* The staff's picks (/clientapi/admin/staff-picks): the accounts suggested first to every persona, in their order, a + persona here or a person elsewhere, found among the accounts this server knows (/api/v2/search, never resolving). + PrivaPub keeps 40 at most and refuses an account picked already (409) or one it can't suggest (404: a community, a + server's actor, a suspended or deleted account). One deleted, suspended or silenced since stays listed, suggested to + nobody. A pick is removed at once: it can be picked again. Every change is in the staff's log. *@ +
+ @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else + { +

@Localizer["Suggested first to every persona of this server, in this order, under Explore's Who to follow."]

+ + @if (picks.Count == 0) + { +

@Localizer["No account is picked yet."]

+ } + else + { + + } + +
+
+ + + + @picks.Count/@MaxPicks +
+ @if (query is { Length: > 0 }) + { +
+ @foreach (var account in matches) + { + + + @account.Name@("@" + account.Acct) + + } + @if (matches.Count == 0 && !isSearching) + { + @Localizer["No account matches."] + } +
+ } +
+ } +
+ +@code { + [Inject] InstanceAdminService InstanceAdminService { get; set; } + [Inject] AccountService AccountService { get; set; } + + /// PrivaPub's limit (InstanceAdminController.MaxStaffPicks). + const int MaxPicks = 40; + + List picks = []; + string query; + List matches = []; + int searching; + bool isSearching; + bool isActing; + + bool IsFull => picks.Count >= MaxPicks; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => picks = await InstanceAdminService.StaffPicks(); + + async Task Search(string typed) + { + query = typed; + var lookup = ++searching; + if (string.IsNullOrWhiteSpace(typed)) + { + matches = []; + isSearching = false; + return; + } + + isSearching = true; + await Task.Delay(300); + if (lookup != searching) + return; + var found = await AccountService.Suggest(typed.Trim(), false); + if (lookup != searching) + return; + // people only (PrivaPub takes no community), and none picked already + matches = [.. found.Accounts.Where(a => !a.Group && picks.All(p => p.AccountId != a.Id))]; + isSearching = false; + } + + async Task Add(Account account) + { + isActing = true; + var result = await InstanceAdminService.AddStaffPick(account.Id); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + picks = (List)result.Data; + query = null; + matches = []; + Toast.AddResponseSuccess(Localizer["{0} is suggested to every persona.", account.Name]); + } + + async Task Remove(ViewStaffPick pick) + { + isActing = true; + var result = await InstanceAdminService.RemoveStaffPick(pick.AccountId); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + picks = (List)result.Data; + Toast.AddResponseSuccess(Localizer["{0} is no longer suggested by the staff.", pick.Name ?? pick.Handle ?? pick.AccountId]); + } +} diff --git a/Components/Content.razor b/Components/Content.razor index 5c15ec6..cee0dd0 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -12,8 +12,10 @@ author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a warning from the server's staff (moderation_warning) shows what they did and what they wrote. A moderator removes - another's post from its menu, with a note and a warning, asked first. Nerd stats are on the time, the author and its - server's history, the place or the CDN, the media and the counts. *@ + another's post from its menu, with a note and a warning, asked first. The persona's reading preferences + (CascadingState.Preferences) say whether a content warning starts open and which media wait behind a button: those + marked sensitive, all, or none. A `status` notification (an account followed with notify posted) shows the post. Nerd + stats are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@ @if (!IsFilteredOut) {
+

@if (Post.GroupAccounts.Count > 1) { @@ -667,7 +670,25 @@ bool IsBlurred => !showFiltered && Matched.Any(r => r.Filter.FilterAction == FilterAction.Blur); - protected override void OnInitialized() => IsLoading = false; + protected override void OnInitialized() + { + IsLoading = false; + // a content warning starts open when the persona reads them so (reading:expand:spoilers) + isSpoilerOpen = CascadingState?.Preferences.ExpandSpoilers == true; + } + + ///

Whether a picture, video or sound waits behind a button: a blur filter's always; else as the persona's + /// reading preference says, those marked sensitive (default), none or all. + bool HidesMedia(Status status) => IsBlurred || (CascadingState?.ExpandMedia ?? ExpandMedia.Default) switch + { + ExpandMedia.ShowAll => false, + ExpandMedia.HideAll => true, + _ => status.Sensitive + }; + + string HiddenMediaText(Status status) => IsBlurred ? Localizer["Filtered media, show"] + : status.Sensitive ? Localizer["Sensitive media, show"] + : Localizer["Hidden media, show"]; // A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of // its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without @@ -677,7 +698,7 @@ int handlingEvents; object RenderKey() => (Post, Post?.Status, Post?.Notification, Post?.Conversation, Post?.Location, Post?.GroupCount, Post?.GroupAccounts, - (FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline)); + (FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline, CascadingState?.Preferences)); protected override bool ShouldRender() { @@ -720,12 +741,12 @@ @foreach (var media in status.MediaAttachments) { - @if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id)) + @if (HidesMedia(status) && !revealedMedia.Contains(media.Id)) { @* its blurhash shows behind it: the colours, nothing of what it shows *@ } else @@ -1352,6 +1373,7 @@ "update" or "quoted_update" => "ion-md-create", "quote" => "ion-md-quote", "moderation_warning" => "ion-md-warning text-warning", + "status" => "ion-md-notifications text-primary", _ => "ion-md-notifications" }; diff --git a/Components/Feed/FeedTabs.razor b/Components/Feed/FeedTabs.razor index d33755f..2c169c5 100644 --- a/Components/Feed/FeedTabs.razor +++ b/Components/Feed/FeedTabs.razor @@ -2,7 +2,8 @@ @* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being - read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was + read, with its week, a button to follow it (its public posts then come home) and one to feature it on the persona's + profile (public: its followers are told). The stats icon shows how the current feed was read; the buttons on the right open the composer and, on narrow screens, the globe. *@
@@ -99,6 +100,15 @@ OnClick="ToggleTagFollow" data-tag-follow> @(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name]) + @if (tag.Featuring is { } featuring) + { + + + @(featuring ? Localizer["Featured on profile"] : Localizer["Feature on profile"]) + + } }
} @@ -176,6 +186,21 @@ Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]); } + async Task ToggleTagFeature() + { + isFollowing = true; + var result = await DiscoveryService.FeatureTag(tag.Name, tag.Featuring != true); + isFollowing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + tag = (Tag)result.Data; + Toast.AddResponseSuccess(tag.Featuring == true ? Localizer["#{0} is featured on this persona's profile.", tag.Name] : Localizer["#{0} is no longer featured.", tag.Name]); + } + string TabName(TimelineType type) => type switch { TimelineType.Federation => Localizer["Federated"], diff --git a/Components/Settings/FeaturedTagsEditor.razor b/Components/Settings/FeaturedTagsEditor.razor new file mode 100644 index 0000000..d826f2b --- /dev/null +++ b/Components/Settings/FeaturedTagsEditor.razor @@ -0,0 +1,186 @@ +@inherits LocalizableComponentBase + +@* The hashtags the current persona features on its profile (/api/v1/featured_tags). They are public, as on Mastodon: + anyone reads them on the profile, and the persona's followers are told of each one featured or no longer (PrivaPub + sends Add and Remove of a Hashtag). Added by name, or from the hashtags the persona used lately and does not feature + (/featured_tags/suggestions); removed at once, since a hashtag can always be featured again. PrivaPub keeps as many as + its instance API says (max_featured_tags, 10) and refuses more. *@ +
+
+

@Localizer["Featured hashtags"]

+

@Localizer["They show on this persona's profile, for anyone to read, and its followers are told of each change."]

+
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (CascadingState.PersonaId is null) + { +

@Localizer["No persona"]

+ } + else + { + @if (tags.Count == 0) + { +

@Localizer["None featured yet."]

+ } + else + { +
    + @foreach (var tag in tags) + { +
  • + #@tag.Name + @tag.Posts + + + +
  • + } +
+ } + +
+ + + + + @Localizer["Feature"] + + @if (max > 0) + { + @tags.Count/@max + } +
+ + + @if (Suggested.Count > 0 && !IsFull) + { +
+ @Localizer["Used lately:"] + @foreach (var suggestion in Suggested) + { + + } +
+ } + } +
+ +@code { + [Inject] DiscoveryService DiscoveryService { get; set; } + [Inject] AboutService AboutService { get; set; } + + List tags = []; + List suggestions = []; + string newName; + string addError; + // the most PrivaPub keeps (its instance API's max_featured_tags); 0 while unknown, when only PrivaPub refuses + int max; + bool isActing; + string loadedPersonaId; + + bool IsFull => max > 0 && tags.Count >= max; + + // the hashtags used lately that are not featured (a hashtag is the same whatever its case) + List Suggested => [.. suggestions.Where(s => tags.All(t => !string.Equals(t.Name, s.Name, StringComparison.OrdinalIgnoreCase)))]; + + string UsedTitle(FeaturedTag tag) => tag.LastStatusDay is { } last + ? Localizer["{0} posts with it, the last on {1}", tag.Posts, last.ToString(SUtility.dateTimeFormat)] + : Localizer["{0} posts with it", tag.Posts]; + + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + addError = null; + if (CascadingState.PersonaId is null) + { + tags = []; + suggestions = []; + return; + } + + max = (await AboutService.Instance())?.Configuration.Accounts.MaxFeaturedTags ?? 0; + tags = await DiscoveryService.FeaturedTags(); + suggestions = await DiscoveryService.FeaturedTagSuggestions(); + } + + async Task AddOnEnter(KeyboardEventArgs e) + { + if (e.Key != "Enter" || IsDefaultDisabled || isActing || IsFull || string.IsNullOrWhiteSpace(newName)) + return; + await AddTyped(); + } + + async Task AddTyped() + { + if (await Add(newName)) + newName = null; + } + + async Task Add(string name) + { + addError = null; + isActing = true; + var result = await DiscoveryService.AddFeaturedTag(name); + isActing = false; + if (result.IsInvalid) + { + addError = result.ErrorMessage; + return false; + } + + var featured = (FeaturedTag)result.Data; + if (featured is not null && tags.All(t => t.Id != featured.Id)) + tags.Add(featured); + Toast.AddResponseSuccess(Localizer["#{0} is featured on this persona's profile.", featured?.Name ?? name.Trim().TrimStart('#')]); + return true; + } + + async Task Remove(FeaturedTag tag) + { + addError = null; + isActing = true; + var result = await DiscoveryService.RemoveFeaturedTag(tag.Id); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + tags.Remove(tag); + // it may be one of the hashtags used lately again + suggestions = await DiscoveryService.FeaturedTagSuggestions(); + Toast.AddResponseSuccess(Localizer["#{0} is no longer featured.", tag.Name]); + } +} diff --git a/Components/Settings/ReadingPreferencesEditor.razor b/Components/Settings/ReadingPreferencesEditor.razor new file mode 100644 index 0000000..6447579 --- /dev/null +++ b/Components/Settings/ReadingPreferencesEditor.razor @@ -0,0 +1,117 @@ +@inherits LocalizableComponentBase + +@* How the current persona reads what others post: Mastodon's reading preferences (reading:expand:media and + reading:expand:spoilers), which Mastodon's API only reads (/api/v1/preferences). PrivaPub keeps them for each persona + and sets them through /clientapi/avatar/private/preferences, so other apps signed in as the persona read them too. The + cards here follow them as soon as they are saved (CascadingState.Preferences): which media wait behind a button, and + whether a content warning starts open. *@ +
+ @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (CascadingState.PersonaId is null) + { +

@Localizer["No persona"]

+ } + else + { + + +

@Localizer["Kept for this persona on the server: other apps signed in as it read them too."]

+
+ @Localizer["Reset"] + + @if (isSaving) + { + + } + @Localizer["Save"] + +
+ } +
+ +@code { + [Inject] AuthService AuthService { get; set; } + + // what PrivaPub keeps, and the choices being made + ViewPersonaPreferences saved = new(); + ExpandMedia expandMedia = ExpandMedia.Default; + bool expandSpoilers; + bool isSaving; + string loadedPersonaId; + + bool IsChanged => expandMedia != ExpandMediaModes.Of(saved.ExpandMedia) || expandSpoilers != saved.ExpandSpoilers; + + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + saved = CascadingState.Preferences; + Reset(); + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + // read again: another device may have changed them since the persona was chosen here + async Task LoadData() + { + if (CascadingState.PersonaId is null) + return; + + var read = await AuthService.PersonaPreferences(CascadingState.PersonaId); + saved = read ?? CascadingState.Preferences; + if (read is not null && (read.ExpandMedia != CascadingState.Preferences.ExpandMedia || read.ExpandSpoilers != CascadingState.Preferences.ExpandSpoilers)) + CascadingState.PreferencesChanged(read); + Reset(); + } + + void Reset() + { + expandMedia = ExpandMediaModes.Of(saved.ExpandMedia); + expandSpoilers = saved.ExpandSpoilers; + } + + async Task Save() + { + isSaving = true; + var result = await AuthService.SavePersonaPreferences(new PersonaPreferencesForm + { + AvatarId = CascadingState.PersonaId, + ExpandMedia = ExpandMediaModes.Name(expandMedia), + ExpandSpoilers = expandSpoilers + }); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + saved = (ViewPersonaPreferences)result.Data ?? saved; + CascadingState.PreferencesChanged(saved); + Reset(); + Toast.AddResponseSuccess(Localizer["Reading preferences saved."]); + } +} diff --git a/LayerComponents/CascadingState.razor b/LayerComponents/CascadingState.razor index bb51e25..8f75084 100644 --- a/LayerComponents/CascadingState.razor +++ b/LayerComponents/CascadingState.razor @@ -6,7 +6,8 @@ compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's: taken at sign-in and at start-up, saved there a moment after it changes here. A persona the staff suspended is never acted as: it can't be chosen, and when PrivaPub refuses the current one a token (TokenAuthStateProvider.PersonaRefused) - the session moves to another persona of the login, or to none, and says why once. *@ + the session moves to another persona of the login, or to none, and says why once. The current persona's reading + preferences (how its cards show media and content warnings) are read with it and kept here. *@ @ChildContent @@ -43,6 +44,12 @@ public string PersonaId { get; private set; } /// That persona's Mastodon account, null until it is read. public Account Persona { get; private set; } + /// How that persona reads what others post (media shown or hidden, content warnings open or folded), read when + /// it is acted as; PrivaPub's defaults until then, or when they can't be read. A change replaces the object, which the + /// cards' render key holds. + public ViewPersonaPreferences Preferences { get; private set; } = new(); + /// Preferences.ExpandMedia as the cards use it. + public ExpandMedia ExpandMedia => ExpandMediaModes.Of(Preferences.ExpandMedia); /// The root handles reports and moderates accounts and posts (its JWT's policies); PrivaPub checks it anyway. public bool IsModerator { get; private set; } /// The root administers the server (its JWT's policies); PrivaPub checks it anyway. @@ -226,6 +233,7 @@ await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken); PersonaId = null; Persona = null; + Preferences = new(); IsSessionLoaded = true; StateHasChanged(); return; @@ -261,10 +269,22 @@ Persona = await AuthService.CurrentAccount(cancellationToken); if (Persona?.Host is { } host) InstanceService.SelfHost = host; + Preferences = await AuthService.PersonaPreferences(personaId, cancellationToken) ?? new() { AvatarId = personaId }; StateHasChanged(); return result; } + /// The current persona's reading preferences as PrivaPub now keeps them, after Settings saved them; the cards + /// show them from their next render. + public void PreferencesChanged(ViewPersonaPreferences preferences) + { + if (preferences is null || preferences.AvatarId != PersonaId) + return; + + Preferences = preferences; + StateHasChanged(); + } + /// Reads the root's personas again, after one was made. public async Task ReloadPersonas(CancellationToken cancellationToken = default) { @@ -293,6 +313,7 @@ await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken); PersonaId = null; Persona = null; + Preferences = new(); StateHasChanged(); return; } @@ -338,6 +359,7 @@ await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken); PersonaId = null; Persona = null; + Preferences = new(); Toast.AddWarning(suspended ? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", name] : Localizer["{0} can no longer be used, and no other persona of this login can act now.", name]); @@ -405,6 +427,7 @@ Personas = []; PersonaId = null; Persona = null; + Preferences = new(); } async Task KeepSessionFresh(CancellationToken cancellationToken) diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index ce0d098..7448efd 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -56,6 +56,10 @@ namespace decePubClient; [JsonSerializable(typeof(ScheduleForm))] [JsonSerializable(typeof(Tag))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FeaturedTag))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Suggestion))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(GroupedNotifications))] [JsonSerializable(typeof(MarkersForm))] diff --git a/Models/APIs.cs b/Models/APIs.cs index b636f20..9794539 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -36,6 +36,8 @@ public static class APIs public const string GET_Personas = "clientapi/avatar/private/list"; public const string POST_InsertPersona = "clientapi/avatar/private/insert"; public const string POST_DeletePersona = "clientapi/avatar/private/delete"; + public const string GET_PersonaPreferences = "clientapi/avatar/private/preferences"; + public const string POST_PersonaPreferences = "clientapi/avatar/private/preferences"; public const string GET_Groups = "clientapi/group/list"; public const string GET_GroupMembers = "clientapi/group/members"; public const string POST_InsertGroup = "clientapi/group/insert"; @@ -78,6 +80,9 @@ public static class APIs public const string GET_AdminRelays = "clientapi/admin/relays"; public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert"; public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}"; + public const string GET_AdminStaffPicks = "clientapi/admin/staff-picks"; + public const string POST_AdminStaffPickInsert = "clientapi/admin/staff-picks/insert"; + public static string POST_M_AdminStaffPickDelete(string accountId) => $"clientapi/admin/staff-picks/delete?accountId={Uri.EscapeDataString(accountId)}"; public const string GET_AdminEmojis = "clientapi/admin/emoji/list"; public const string POST_AdminEmojiInsert = "clientapi/admin/emoji/insert"; public const string POST_AdminEmojiUpdate = "clientapi/admin/emoji/update"; @@ -166,6 +171,8 @@ public static class APIs public static string GET_M_AccountFollowing(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/following"; public static string GET_M_AccountFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/followers"; public static string POST_M_RemoveFromFollowers(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/remove_from_followers"; + public static string GET_M_AccountFeaturedTags(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/featured_tags"; + public static string POST_M_AccountNote(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/note"; public static string GET_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public static string GET_M_Context(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/context"; public static string DELETE_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; @@ -199,6 +206,12 @@ public static class APIs public static string POST_M_RevokeQuote(string quotedId, string quotingId) => $"api/v1/statuses/{Uri.EscapeDataString(quotedId)}/quotes/{Uri.EscapeDataString(quotingId)}/revoke"; public const string GET_FollowedTags = "api/v1/followed_tags"; + public const string GET_FeaturedTags = "api/v1/featured_tags"; + public const string POST_FeaturedTag = "api/v1/featured_tags"; + public static string DELETE_M_FeaturedTag(string featuredTagId) => $"api/v1/featured_tags/{Uri.EscapeDataString(featuredTagId)}"; + public const string GET_FeaturedTagSuggestions = "api/v1/featured_tags/suggestions"; + public const string GET_Suggestions = "api/v2/suggestions"; + public static string DELETE_M_Suggestion(string accountId) => $"api/v1/suggestions/{Uri.EscapeDataString(accountId)}"; public const string GET_GroupedNotifications = "api/v2/notifications"; public const string GET_UnreadNotifications = "api/v2/notifications/unread_count"; public static string POST_M_DismissNotificationGroup(string groupKey) => $"api/v2/notifications/{Uri.EscapeDataString(groupKey)}/dismiss"; diff --git a/Models/Mastodon/Account.cs b/Models/Mastodon/Account.cs index 8344f9a..ba59129 100644 --- a/Models/Mastodon/Account.cs +++ b/Models/Mastodon/Account.cs @@ -94,10 +94,22 @@ public class Relationship public bool Muting { get; set; } public bool MutingNotifications { get; set; } public bool ShowingReblogs { get; set; } + /// The persona is told of each of the account's new posts (a `status` notification): the follow's notify. public bool Notifying { get; set; } + /// The languages of its posts that reach the persona's home feed (primary subtags, as PrivaPub keeps them). + /// PrivaPub sends null for every language, which the client reads as an empty list does. + public List Languages { get; set; } = []; public bool Requested { get; set; } public bool RequestedBy { get; set; } public bool DomainBlocking { get; set; } + /// In the persona's recommendations (Mastodon's endorsements): private here, listed to the persona alone and + /// never federated; only while it follows the account. + public bool Endorsed { get; set; } + /// The persona's own private note on the account (never its login's, never federated); empty for none. + public string Note { get; set; } + + /// The languages chosen, none meaning every language. + public IReadOnlyList WantedLanguages => Languages ?? []; } /// /api/v2/search: what matched, a remote handle or URL resolved by PrivaPub. diff --git a/Models/Mastodon/Discovery.cs b/Models/Mastodon/Discovery.cs index aa07efc..6518e54 100644 --- a/Models/Mastodon/Discovery.cs +++ b/Models/Mastodon/Discovery.cs @@ -1,13 +1,15 @@ namespace decePubClient.Models.Mastodon; /// A hashtag as PrivaPub describes it: its last week of use in public posts it holds, and whether the persona -/// follows it (null when nobody is signed in). +/// follows it and features it on its profile (both null when nobody is signed in). public class Tag { public string Name { get; set; } public string Url { get; set; } public List History { get; set; } = []; public bool? Following { get; set; } + /// Featured on the persona's profile (Mastodon 4.4's `featuring`). + public bool? Featuring { get; set; } public int WeekUses => History.Sum(h => h.UsesCount); public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0; @@ -31,3 +33,34 @@ public class TrendLink : PreviewCard public int WeekAccounts => History.Sum(h => h.AccountsCount); } + +/// A hashtag an account features on its profile (Mastodon's FeaturedTag): public, so it federates. Mastodon sends +/// the count as a string and the last use as a day (yyyy-MM-dd), counted from the account's public and unlisted posts +/// PrivaPub holds. +public class FeaturedTag +{ + public string Id { get; set; } + /// The hashtag as the account wrote it (its case kept), without the #. + public string Name { get; set; } + public string Url { get; set; } + public string StatusesCount { get; set; } + public string LastStatusAt { get; set; } + + public int Posts => int.TryParse(StatusesCount, out var n) ? n : 0; + + /// The day it was last used, or null when never. + public DateTime? LastStatusDay => + DateTime.TryParseExact(LastStatusAt, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var day) + ? day + : null; +} + +/// /api/v2/suggestions: an account to follow and why. Source is staff (picked by the server's staff), +/// past_interactions (the persona favourited, boosted or answered it lately) or global (popular here); Sources names the +/// reasons (featured, most_followed, most_interactions, past_interactions). +public class Suggestion +{ + public string Source { get; set; } + public List Sources { get; set; } = []; + public Account Account { get; set; } +} diff --git a/Models/Mastodon/InstanceV2.cs b/Models/Mastodon/InstanceV2.cs index 25f3959..2cc46e3 100644 --- a/Models/Mastodon/InstanceV2.cs +++ b/Models/Mastodon/InstanceV2.cs @@ -37,6 +37,13 @@ public class InstanceRule public class InstanceConfiguration { public MediaConfiguration MediaAttachments { get; set; } = new(); + public AccountsConfiguration Accounts { get; set; } = new(); +} + +/// What an account may hold: how many hashtags it features on its profile (PrivaPub's 10). +public class AccountsConfiguration +{ + public int MaxFeaturedTags { get; set; } } /// The types an upload may have and how big it may be, as the server enforces them. diff --git a/Models/Types/ExpandMedia.cs b/Models/Types/ExpandMedia.cs new file mode 100644 index 0000000..af7009c --- /dev/null +++ b/Models/Types/ExpandMedia.cs @@ -0,0 +1,30 @@ +namespace decePubClient.Models.Types +{ + /// How the persona's cards show media (Mastodon's reading:expand:media, set through /clientapi): Default hides + /// what its author marked sensitive, ShowAll shows everything, HideAll hides every picture, video and sound behind a + /// button. + public enum ExpandMedia + { + Default = 1, + ShowAll, + HideAll + } + + /// ExpandMedia as PrivaPub names it ("default", "show_all", "hide_all"). + public static class ExpandMediaModes + { + public static ExpandMedia Of(string mode) => mode switch + { + "show_all" => ExpandMedia.ShowAll, + "hide_all" => ExpandMedia.HideAll, + _ => ExpandMedia.Default + }; + + public static string Name(ExpandMedia mode) => mode switch + { + ExpandMedia.ShowAll => "show_all", + ExpandMedia.HideAll => "hide_all", + _ => "default" + }; + } +} diff --git a/Pages/Administration.razor b/Pages/Administration.razor index 37159e8..0404fe0 100644 --- a/Pages/Administration.razor +++ b/Pages/Administration.razor @@ -6,7 +6,8 @@ @* Moderators handle reports, moderate accounts here and elsewhere (Accounts) and read the staff's log. Administrators also change the logins and their personas (Users & personas), say what the server is (Instance), set its rules, write - its announcements, add its custom emoji, block servers for everyone, choose its relays and media limits, read the + its announcements, add its custom emoji, pick the accounts suggested to every persona (Staff picks), block servers for + everyone, choose its relays and media limits, read the federation statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
@@ -118,6 +119,19 @@ + +
+

+ @Localizer["Staff picks"] +

+
+ +
+ +
+
+
+

diff --git a/Pages/Explore.razor b/Pages/Explore.razor index 6ba0557..935dfad 100644 --- a/Pages/Explore.razor +++ b/Pages/Explore.razor @@ -4,8 +4,10 @@ @Localizer["Explore"] @* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already - holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the - hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@ + holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, who to follow + (PrivaPub's suggestions, each with why: picked by the staff, past interactions, popular here; followed or dismissed + from here, a dismissal for good, so asked first), and the hashtags the persona follows. A hashtag opens as a tab of + the feed; a link goes out to its site. *@

@@ -21,6 +23,7 @@
@Localizer["Popular posts"]
@Localizer["Shared links"]
@Localizer["People"]
+
@Localizer["Who to follow"]
@Localizer["Followed hashtags"]
@@ -75,6 +78,41 @@ }
} + else if (tab is ExploreTab.Suggestions) + { + @if (suggestions.Count == 0) + { +

@Localizer["No one to suggest yet."]

+ } +
+ @foreach (var suggestion in suggestions) + { + + } +
+ } else if (tab is ExploreTab.Links) { @if (links.Count == 0) @@ -157,10 +195,13 @@ Posts, Links, People, + Suggestions, Followed } [Inject] DiscoveryService DiscoveryService { get; set; } + [Inject] AccountService AccountService { get; set; } + [Inject] ConfirmService Confirm { get; set; } [Inject] InstanceService InstanceService { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; } @@ -169,6 +210,7 @@ List posts = []; List links = []; List people = []; + List suggestions = []; DirectoryOrder order = DirectoryOrder.Active; bool localOnly; bool morePeople; @@ -219,6 +261,9 @@ case ExploreTab.Links: links = await DiscoveryService.TrendingLinks(); break; + case ExploreTab.Suggestions: + suggestions = [.. (await DiscoveryService.Suggestions()).Where(s => s.Account is not null)]; + break; default: people = await DiscoveryService.Directory(order, localOnly, 0); morePeople = people.Count == DiscoveryService.DirectoryPage; @@ -272,6 +317,62 @@ Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]); } + // why PrivaPub suggests an account: its v2 source + string SourceName(string source) => source switch + { + "staff" => Localizer["Picked by the staff"], + "past_interactions" => Localizer["You interacted with them"], + "global" => Localizer["Popular here"], + _ => source + }; + + // every reason PrivaPub has for it (its sources) + string WhyText(Suggestion suggestion) => string.Join(", ", suggestion.Sources.Select(source => source switch + { + "featured" => Localizer["picked by this server's staff"], + "most_followed" => Localizer["followed by many here"], + "most_interactions" => Localizer["much favourited and boosted here lately"], + "past_interactions" => Localizer["you favourited, boosted or answered them lately"], + _ => source + })); + + async Task FollowSuggested(Suggestion suggestion) + { + isActing = true; + var result = await AccountService.Act(suggestion.Account.Id, AccountAction.Follow); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + suggestions.Remove(suggestion); + Toast.AddResponseSuccess(((Relationship)result.Data)?.Requested == true + ? Localizer["Asked {0} to accept you.", suggestion.Account.Name] + : Localizer["Following {0}.", suggestion.Account.Name]); + } + + // for good: PrivaPub never suggests it to this persona again, so it is asked first + async Task Dismiss(Suggestion suggestion) + { + if (!await Confirm.Ask(Localizer["Never suggest {0} again?", suggestion.Account.Name], + Localizer["There is no undoing it, but {0} can still be followed from its profile.", suggestion.Account.Name], Localizer["Dismiss"], false)) + return; + + isActing = true; + var result = await DiscoveryService.DismissSuggestion(suggestion.Account.Id); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + suggestions.Remove(suggestion); + Toast.AddResponseSuccess(Localizer["{0} is no longer suggested.", suggestion.Account.Name]); + } + async Task OpenTag(string tagName) { var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); diff --git a/Pages/Index.razor b/Pages/Index.razor index ec3cf2f..da5f681 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -8,9 +8,10 @@ @* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed, and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through - PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page - is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit - the stream tells is applied at once. The administrators' announcements show above the tabs (AnnouncementsBanner). *@ + PrivaPub's streaming API (StreamingService) for the feeds it streams (the direct conversations too, on the direct + stream), and are polled every 45 seconds while the page is visible otherwise, or while the stream is down; either way + they wait behind the "new" button. A deletion or an edit the stream tells is applied at once. The administrators' + announcements show above the tabs (AnnouncementsBanner). *@
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@ @@ -401,7 +402,7 @@ { while (await pollTimer.WaitForNextTickAsync(cancellationToken)) { - // no stream tells of direct messages: their tab's badge is counted again on every tick + // the direct stream is followed on its own tab only: elsewhere its badge is counted again on every tick if (!IsLoading && CascadingState.IsOnline && Filter.TimelineType is not TimelineType.Direct && await TimelineService.UnreadConversations(cancellationToken) is var unread && unread != unreadConversations) { @@ -475,15 +476,30 @@ TimelineType.Notifications => new("user:notification"), TimelineType.Tag when filter.Tag is { Length: > 0 } tag => new("hashtag", Tag: tag), TimelineType.Lists when filter.ListId is { Length: > 0 } list => new("list", List: list), + TimelineType.Direct => new("direct"), _ => null }; /// What the stream tells: a new post or notification waits behind the "new" button; a deletion or an edit is - /// applied where the post is. + /// applied where the post is. A conversation with a new message is the same item moved up with it: behind the "new" + /// button, or at once at the top when the persona wrote that message itself. async Task OnStream(StreamMessage message) { switch (message.Event) { + case "conversation" when message.Conversation is { LastStatus: { } last } conversation && Filter.TimelineType is TimelineType.Direct: + var item = FeedPost.Of(conversation); + // (the message the persona just sent shows by itself until its conversation comes) + posts.RemoveAll(p => p.Key == item.Key || p.Conversation is null && p.Status?.Id == last.Id); + pending.RemoveAll(p => p.Key == item.Key); + if (item.IsOwnedBy(CascadingState.PersonaId)) + { + posts.Insert(0, item); + await InvokeAsync(() => Locate([item], loadCancellation?.Token ?? default)); + break; + } + pending.Insert(0, item); + break; case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications: var post = FeedPost.Of(status); if (posts.Concat(pending).Any(p => p.Key == post.Key)) diff --git a/Pages/Profile.razor b/Pages/Profile.razor index 815981a..157e53d 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -8,7 +8,12 @@ page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows, never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows (AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow - requests waiting for it show above its posts. *@ + requests waiting for it show above its posts. The hashtags an account features are chips opening their feed (the + persona's own link to Settings, where they are edited). Under another account's header is what the persona keeps of + it for itself alone, which the account is never told: for one it follows, whether it is told of its new posts (a + `status` notification), which languages of its posts reach the home feed, and whether it is in the persona's + recommendations (endorsements, private here); for any account, a private note, saved when it loses focus or with + Save. *@
@@ -151,6 +156,26 @@ } + @if (featuredTags.Count > 0 || IsOwn) + { +
+ @foreach (var featured in featuredTags) + { + + } + @if (IsOwn) + { + + + @(featuredTags.Count == 0 ? Localizer["Feature hashtags on this profile"] : Localizer["Edit featured hashtags"]) + + } +
+ } +
@account.StatusesCount @Localizer["posts"] @if (IsOwn) @@ -170,6 +195,80 @@
+ @if (!IsOwn && relationship is { } related) + { +
+

+ + @Localizer["Only this persona sees what is here: {0} is never told.", account.Name] +

+ @if (related.Following) + { +
+ + + @(related.Notifying ? Localizer["Notified of new posts"] : Localizer["Notify me of new posts"]) + + + + @LanguagesLabel + + +

@Localizer["Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language."]

+
+ @foreach (var (languageCode, languageName) in LanguageChoices) + { + + } +
+
+ @Localizer["Every language"] + + @Localizer["Save"] + +
+
+
+ + + @(related.Endorsed ? Localizer["Recommended"] : Localizer["Recommend"]) + +
+

@Localizer["Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else."]

+ } + @* the note is saved as it loses focus (focusout bubbles from the textarea), or with Save *@ +
+ +
+ @if (noteSaved && !NoteChanged) + { + + @Localizer["Saved."] + + } + @(noteDraft?.Length ?? 0)/@AccountService.NoteLength + + @if (isSavingNote) + { + + } + @Localizer["Save"] + +
+
+
+ } + @if (IsOwn) { @if (showAccounts is { } followers) @@ -246,6 +345,32 @@ bool? showAccounts; List allLists = []; HashSet inLists = []; + List featuredTags = []; + bool isLanguagesOpen; + // the languages ticked while the languages menu is open, saved together + HashSet wantedLanguages = []; + // DCheckbox's ValueExpression for the languages, which are bound one by one (never read) + bool languageFlag = false; + // the private note as it is being written; relationship.Note is what PrivaPub keeps + string noteDraft; + bool isSavingNote; + bool noteSaved; + + bool NoteChanged => (noteDraft?.Trim() ?? string.Empty) != (relationship?.Note ?? string.Empty); + + string LanguagesLabel => relationship.WantedLanguages.Count == 0 + ? Localizer["Every language"] + : string.Join(", ", relationship.WantedLanguages.Select(LanguageName)); + + // the languages a post can be marked with, and any other the follow keeps + IEnumerable<(string Code, string Name)> LanguageChoices => + Languages.All.Concat(relationship.WantedLanguages.Where(code => Languages.All.All(l => l.Code != code)).Select(code => (code, code))); + + static string LanguageName(string code) => Languages.All.Where(l => l.Code == code).Select(l => l.Name).FirstOrDefault() ?? code; + + string FeaturedTitle(FeaturedTag featured) => featured.LastStatusDay is { } last + ? Localizer["{0} posts with it, the last on {1}", featured.Posts, last.ToString(SUtility.dateTimeFormat)] + : Localizer["{0} posts with it", featured.Posts]; bool IsOwn => account is not null && account.Id == CascadingState.PersonaId; @@ -288,6 +413,9 @@ return; } relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id); + noteDraft = relationship?.Note; + noteSaved = false; + featuredTags = await AccountService.FeaturedTags(account.Id); await LoadLists(); pinned = await AccountService.Pinned(account.Id); pinnedKeys = [.. pinned.Select(p => p.Key)]; @@ -391,6 +519,98 @@ }); } + async Task ToggleNotify() + { + isActing = true; + var result = await AccountService.SetNotify(account.Id, relationship.ShowingReblogs, !relationship.Notifying); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relationship = (Relationship)result.Data ?? relationship; + Toast.AddResponseSuccess(relationship.Notifying ? Localizer["You'll be told when {0} posts.", account.Name] : Localizer["No longer told when {0} posts.", account.Name]); + } + + // the menu opens on the languages the follow keeps + void LanguagesOpened(bool open) + { + isLanguagesOpen = open; + if (open) + wantedLanguages = [.. relationship.WantedLanguages]; + } + + void TickLanguage(string code, bool on) + { + if (on) + wantedLanguages.Add(code); + else + wantedLanguages.Remove(code); + } + + Task SaveLanguages() => SetLanguages(wantedLanguages); + + Task EveryLanguage() => SetLanguages([]); + + async Task SetLanguages(IReadOnlyCollection languages) + { + isActing = true; + var result = await AccountService.SetLanguages(account.Id, relationship.ShowingReblogs, languages); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relationship = (Relationship)result.Data ?? relationship; + isLanguagesOpen = false; + Toast.AddResponseSuccess(relationship.WantedLanguages.Count == 0 + ? Localizer["Every post of {0} reaches your home feed, whatever its language.", account.Name] + : Localizer["Only the posts of {0} in {1} reach your home feed.", account.Name, LanguagesLabel]); + } + + async Task ToggleEndorse() + { + isActing = true; + var result = await AccountService.Endorse(account.Id, !relationship.Endorsed); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relationship = (Relationship)result.Data ?? relationship; + Toast.AddResponseSuccess(relationship.Endorsed ? Localizer["{0} is in your recommendations, which only you see.", account.Name] + : Localizer["{0} is no longer in your recommendations.", account.Name]); + } + + // as the note loses focus too: only a note changed is sent, one at a time + async Task SaveNote() + { + if (isSavingNote || relationship is null || !NoteChanged) + return; + + var sent = noteDraft; + isSavingNote = true; + var result = await AccountService.SetNote(account.Id, sent); + isSavingNote = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relationship = (Relationship)result.Data ?? relationship; + // what was written meanwhile stays as it is + if (noteDraft == sent) + noteDraft = relationship.Note; + noteSaved = true; + } + void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1); void Remove(FeedPost post) diff --git a/Pages/Settings.razor b/Pages/Settings.razor index 02db043..b3dda42 100644 --- a/Pages/Settings.razor +++ b/Pages/Settings.razor @@ -107,9 +107,10 @@ - + @* opened by a link to it (settings?open=profile, from the persona's own profile) *@ +
-

+

@Localizer["Profile"]

@@ -117,7 +118,30 @@
@if (CascadingState.IsSignedIn) { - +
+ + +
+ } + else + { + @Localizer["Sign in to PrivaPub"] + } +
+ +
+ + +
+

+ @Localizer["Reading"] +

+
+ +
+ @if (CascadingState.IsSignedIn) + { + } else { @@ -196,6 +220,21 @@ @code { + /// A section to open as the page opens: profile (the persona's profile and featured hashtags). + [SupplyParameterFromQuery(Name = "open")] public string Open { get; set; } + + bool isProfileOpen; + string openedFor; + + protected override void OnParametersSet() + { + if (Open == openedFor) + return; + openedFor = Open; + if (Open == "profile") + isProfileOpen = true; + } + bool IsDark => CascadingState.Status.IsDarkTheme(); bool IsGray => CascadingState.PublicCacheData.PageSettings.IsGray(); diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index dc5e37e..6a2f0f0 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -3599,4 +3599,214 @@ {0} ha reagito con + + #{0} è in evidenza sul profilo di questa persona. + + + #{0} non è più in evidenza. + + + Un hashtag da mettere in evidenza + + + Account scelto dallo staff + + + Eliminato, sospeso o silenziato dopo essere stato scelto: non è suggerito a nessuno. + + + Modifica gli hashtag in evidenza + + + Tutte le lingue + + + Ogni post di {0} arriva nella tua home, in qualunque lingua. + + + Metti in evidenza + + + Metti in evidenza #{0} + + + Metti in evidenza degli hashtag su questo profilo + + + Metti in evidenza sul profilo + + + Hashtag in evidenza + + + Gli hashtag in evidenza sono pubblici: compaiono sul profilo della persona, e i suoi follower lo sanno. + + + In evidenza sul profilo + + + Media nascosti, mostra + + + Nascondili tutti + + + Nascondi quelli segnati come sensibili + + + Tieni {0} tra i miei consigliati + + + Salvate per questa persona sul server: anche le altre app collegate come lei le leggono. + + + Lingue dei suoi post nella tua home + + + Non suggerire più {0} + + + Non suggerire più {0}? + + + Nessun account è ancora stato scelto. + + + Nessun account corrisponde. + + + Non ti verrà più detto quando {0} pubblica. + + + Ancora nessuno da suggerire. + + + Ancora nessuno in evidenza. + + + Ora non suggerito + + + Avvisi dei nuovi post attivi + + + Avvisami dei nuovi post + + + Solo per questa persona + + + Nella tua home arrivano solo i suoi post nelle lingue spuntate, e quelli di lingua sconosciuta. Nessuna spuntata: tutte le lingue. + + + Nella tua home arrivano solo i post di {0} in {1}. + + + Solo questa persona vede ciò che è qui: {0} non lo sa mai. + + + Apri gli avvisi sul contenuto + + + Scelto dallo staff + + + Immagini, video e audio + + + Popolare qui + + + Nota privata + + + Lettura + + + Preferenze di lettura salvate. + + + Consiglia + + + Consigliato + + + Cerca account qui e altrove + + + Mostrali tutti + + + Qualcosa da ricordare su {0} + + + Scelta dello staff rimossa + + + Scelte dello staff + + + Togli #{0} dall'evidenza + + + Suggeriti per primi a ogni persona di questo server, in quest'ordine, in Esplora sotto Chi seguire. + + + Togli {0} dai miei consigliati + + + Non si può annullare, ma {0} si può sempre seguire dal suo profilo. + + + Compaiono sul profilo di questa persona, chiunque li legge, e i suoi follower sanno di ogni cambiamento. + + + Usati di recente: + + + Chi seguire + + + Hai interagito con questo account + + + Ti verrà detto quando {0} pubblica. + + + I tuoi consigliati restano privati: li vede solo questa persona, mai sul suo profilo né altrove. + + + seguito da molti qui + + + aggiunto ai preferiti e condiviso molto qui di recente + + + scelto dallo staff di questo server + + + di recente l'hai aggiunto ai preferiti, condiviso o gli hai risposto + + + {0} è tra i tuoi consigliati, che vedi solo tu. + + + {0} non è più tra i tuoi consigliati. + + + {0} non è più suggerito dallo staff. + + + {0} non è più suggerito. + + + {0} è suggerito a ogni persona. + + + {0} post con questo hashtag + + + {0} post con questo hashtag, l'ultimo il {1} + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 274fb85..0979da3 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -3599,4 +3599,214 @@ {0} reacted with + + #{0} is featured on this persona's profile. + + + #{0} is no longer featured. + + + A hashtag to feature + + + Account picked by the staff + + + Deleted, suspended or silenced since it was picked: suggested to nobody. + + + Edit featured hashtags + + + Every language + + + Every post of {0} reaches your home feed, whatever its language. + + + Feature + + + Feature #{0} + + + Feature hashtags on this profile + + + Feature on profile + + + Featured hashtags + + + Featured hashtags are public: they show on the persona's profile, and its followers are told. + + + Featured on profile + + + Hidden media, show + + + Hide all of them + + + Hide those marked sensitive + + + Keep {0} in my recommendations + + + Kept for this persona on the server: other apps signed in as it read them too. + + + Languages of its posts in your home feed + + + Never suggest {0} again + + + Never suggest {0} again? + + + No account is picked yet. + + + No account matches. + + + No longer told when {0} posts. + + + No one to suggest yet. + + + None featured yet. + + + Not suggested now + + + Notified of new posts + + + Notify me of new posts + + + Only for this persona + + + Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language. + + + Only the posts of {0} in {1} reach your home feed. + + + Only this persona sees what is here: {0} is never told. + + + Open content warnings + + + Picked by the staff + + + Pictures, videos and sounds + + + Popular here + + + Private note + + + Reading + + + Reading preferences saved. + + + Recommend + + + Recommended + + + Search accounts here and elsewhere + + + Show all of them + + + Something to remember about {0} + + + Staff pick removed + + + Staff picks + + + Stop featuring #{0} + + + Suggested first to every persona of this server, in this order, under Explore's Who to follow. + + + Take {0} out of my recommendations + + + There is no undoing it, but {0} can still be followed from its profile. + + + They show on this persona's profile, for anyone to read, and its followers are told of each change. + + + Used lately: + + + Who to follow + + + You interacted with them + + + You'll be told when {0} posts. + + + Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else. + + + followed by many here + + + much favourited and boosted here lately + + + picked by this server's staff + + + you favourited, boosted or answered them lately + + + {0} is in your recommendations, which only you see. + + + {0} is no longer in your recommendations. + + + {0} is no longer suggested by the staff. + + + {0} is no longer suggested. + + + {0} is suggested to every persona. + + + {0} posts with it + + + {0} posts with it, the last on {1} + diff --git a/Services/AccountService.cs b/Services/AccountService.cs index 13ad563..163255f 100644 --- a/Services/AccountService.cs +++ b/Services/AccountService.cs @@ -29,9 +29,11 @@ namespace decePubClient.Services } /// - /// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following, - /// blocking and muting it, the persona's follow requests, followers and following, and search across the fediverse (a remote handle or URL is - /// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws. + /// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts and the hashtags it + /// features, following, blocking and muting it, what the persona keeps of it for itself alone (being told of its posts, + /// the languages of them it reads, its recommendation, its private note), the persona's follow requests, followers and + /// following, and search across the fediverse (a remote handle or URL is resolved by PrivaPub, never by the browser). + /// Lists come back empty and values null on failure; nothing throws. /// public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { @@ -93,16 +95,84 @@ namespace decePubClient.Services } /// Follows the account, its boosts shown in the home feed or not; Data is the new Relationship. - public async Task Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default) + public Task Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default) => + FollowWith(accountId, [new("reblogs", Flag(showReblogs))], cancellationToken); + + /// Follows the account with everything a follow carries: its boosts shown or not, the persona told of its new + /// posts or not, and the languages of them that reach the home feed (none, every language), as a Mastodon follows + /// export names them. Data is the new Relationship. + public Task Follow(string accountId, bool showReblogs, bool notify, IReadOnlyCollection languages, + CancellationToken cancellationToken = default) => + FollowWith(accountId, [new("reblogs", Flag(showReblogs)), new("notify", Flag(notify)), .. LanguageFields(languages)], cancellationToken); + + /// Tells the persona of each new post of an account it follows (a `status` notification), or stops. The + /// follow's boosts stay as they are: a follow always sets them, so the caller gives the relationship's own. Data is the + /// new Relationship. + public Task SetNotify(string accountId, bool showReblogs, bool notify, CancellationToken cancellationToken = default) => + FollowWith(accountId, [new("reblogs", Flag(showReblogs)), new("notify", Flag(notify))], cancellationToken); + + /// Lets only the posts of an account the persona follows written in these languages reach its home feed + /// (a post whose language is unknown still does); none is every language. The follow's boosts stay as they are, as in + /// SetNotify. Data is the new Relationship. + public Task SetLanguages(string accountId, bool showReblogs, IReadOnlyCollection languages, CancellationToken cancellationToken = default) => + FollowWith(accountId, [new("reblogs", Flag(showReblogs)), .. LanguageFields(languages)], cancellationToken); + + // an empty value says the list is given, and empty: PrivaPub keeps no language, every language + static IEnumerable> LanguageFields(IReadOnlyCollection languages) => languages.Count == 0 + ? [new("languages[]", string.Empty)] + : languages.Select(language => new KeyValuePair("languages[]", language)); + + async Task FollowWith(string accountId, List> fields, CancellationToken cancellationToken) { - using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"), - new[] { new KeyValuePair("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken); + using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"), fields, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } + static string Flag(bool value) => value ? "true" : "false"; + + /// Keeps an account the persona follows in its recommendations (Mastodon's endorsements), or takes it out. They + /// are the persona's alone: PrivaPub lists them to nobody else and never federates them; it refuses (422) an account the + /// persona doesn't follow. Data is the new Relationship. + public async Task Endorse(string accountId, bool endorse, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, endorse ? "endorse" : "unendorse"), + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// The persona's private note on an account: NoteLength characters at most (PrivaPub refuses more with a 422), + /// empty takes it away. Shown to the persona alone, never federated. Data is the new Relationship. + public async Task SetNote(string accountId, string comment, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountNote(accountId), + new[] { new KeyValuePair("comment", comment?.Trim() ?? string.Empty) }, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// The longest private note PrivaPub keeps (Mastodon's AccountNote limit). + public const int NoteLength = 2000; + + /// The hashtags the account features on its profile: a persona's own, or what an account elsewhere's server + /// told of its own; none for a community. + public async Task> FeaturedTags(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountFeaturedTags(accountId), cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(FeaturedTags), cancellationToken); + return []; + } + /// Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship. public async Task Act(string accountId, AccountAction action, CancellationToken cancellationToken = default) { diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 4e8999f..a94dfa5 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -121,6 +121,32 @@ namespace decePubClient.Services return new WebResult(); } + /// How one of the root's personas reads what others post (its reading preferences: media and content + /// warnings), or null when they can't be read. + public async Task PersonaPreferences(string personaId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_PersonaPreferences, [$"avatarId={Uri.EscapeDataString(personaId)}"], + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogFailure(response, nameof(PersonaPreferences), cancellationToken); + return null; + } + + return await response.DefaultReadFromJsonAsync(cancellationToken); + } + + /// Saves a persona's reading preferences, which Mastodon's API only reads (/api/v1/preferences); Data is the + /// ViewPersonaPreferences PrivaPub keeps. A mode it doesn't know is its 422. + public async Task SavePersonaPreferences(PersonaPreferencesForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_PersonaPreferences, form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + /// Acts as the persona from now on, exchanging a token for it unless it has one. public async Task UsePersona(string personaId, CancellationToken cancellationToken = default) { diff --git a/Services/DiscoveryService.cs b/Services/DiscoveryService.cs index 6efa436..376d119 100644 --- a/Services/DiscoveryService.cs +++ b/Services/DiscoveryService.cs @@ -18,9 +18,11 @@ namespace decePubClient.Services } /// - /// What there is to find on PrivaPub: hashtags (their week, following them), what is trending there (hashtags, posts - /// and links, from public posts it already holds) and the directory of accounts that let themselves be discovered. - /// Lists come back empty and values null on failure; nothing throws. + /// What there is to find on PrivaPub: hashtags (their week, following them, featuring them on the persona's profile), + /// what is trending there (hashtags, posts and links, from public posts it already holds), the directory of accounts that + /// let themselves be discovered, and who to follow (PrivaPub's suggestions: the staff's picks, past interactions, popular + /// accounts here; never the persona itself nor a sibling persona of its login). Lists come back empty and values null on + /// failure; nothing throws. /// public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { @@ -44,6 +46,77 @@ namespace decePubClient.Services return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } + /// Features a hashtag on the persona's profile, or stops (Mastodon 4.4's tags/:name/feature); its followers are + /// told, as featured tags are public. PrivaPub refuses (422) one past its limit. Data is the Tag, its Featuring set. + public async Task FeatureTag(string name, bool feature, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_TagAction(name, feature ? "feature" : "unfeature"), cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// The hashtags the persona features on its profile, with how often it used them. + public async Task> FeaturedTags(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_FeaturedTags, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(FeaturedTags), cancellationToken); + return []; + } + + /// Features a hashtag by its name (a # before it is fine); one featured already is answered as it is. PrivaPub + /// refuses (422) a name that is no hashtag and one past its limit. Data is the FeaturedTag. + public async Task AddFeaturedTag(string name, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_FeaturedTag, + new[] { new KeyValuePair("name", name?.Trim().TrimStart('#')) }, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// Stops featuring a hashtag, by the featured tag's id; its followers are told. + public async Task RemoveFeaturedTag(string featuredTagId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_FeaturedTag(featuredTagId), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + /// Hashtags the persona used lately and does not feature, the most used first. + public async Task> FeaturedTagSuggestions(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_FeaturedTagSuggestions, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(FeaturedTagSuggestions), cancellationToken); + return []; + } + + /// Accounts to follow, best first, each with why (/api/v2/suggestions). + public async Task> Suggestions(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_Suggestions, ["limit=40"], cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Suggestions), cancellationToken); + return []; + } + + /// Never suggests the account to this persona again; there is no undoing it, but the account can still be + /// followed from its profile. + public async Task DismissSuggestion(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Suggestion(accountId), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + public Task> FollowedTags(CancellationToken cancellationToken = default) => List(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken); diff --git a/Services/InstanceAdminService.cs b/Services/InstanceAdminService.cs index cc273d0..497846f 100644 --- a/Services/InstanceAdminService.cs +++ b/Services/InstanceAdminService.cs @@ -150,6 +150,42 @@ namespace decePubClient.Services return await RelayList(response, cancellationToken); } + /// The accounts the staff suggest first to every persona, in their order; one no longer shown (deleted, + /// suspended or silenced since) has IsShown false. + public async Task> StaffPicks(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_AdminStaffPicks, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(StaffPicks), cancellationToken); + return []; + } + + /// Suggests an account to every persona, by its Mastodon id: a persona here or a person elsewhere. PrivaPub + /// answers 404 for no such account, 409 for one picked already, 422 past its 40. Data is the picks now. + public async Task AddStaffPick(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_AdminStaffPickInsert, new StaffPickForm { AccountId = accountId }, + cancellationToken: cancellationToken); + return await StaffPickList(response, cancellationToken); + } + + /// No longer suggests the account (it can be picked again); Data is the picks now. + public async Task RemoveStaffPick(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminStaffPickDelete(accountId), cancellationToken: cancellationToken); + return await StaffPickList(response, cancellationToken); + } + + async Task StaffPickList(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync>(cancellationToken) ?? [] }; + } + async Task Saved(HttpResponseMessage response, CancellationToken cancellationToken) { if (!response.IsSuccessStatusCode) diff --git a/Services/PortabilityService.cs b/Services/PortabilityService.cs index 6bb535a..49d0d45 100644 --- a/Services/PortabilityService.cs +++ b/Services/PortabilityService.cs @@ -85,7 +85,7 @@ namespace decePubClient.Services [ Csv(AccountHeader, "Show boosts", "Notify on new posts", "Languages"), .. followed.Select(a => relationships.GetValueOrDefault(a.Id) is { } r - ? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), null) + ? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), r.WantedLanguages.Count == 0 ? null : string.Join(", ", r.WantedLanguages)) : Csv(Full(a.Acct), "true", "false", null)) ]; } @@ -184,7 +184,15 @@ namespace decePubClient.Services if (await accounts.Lookup(row[0], cancellationToken) is not { } account) return Unknown(row[0]); var showReblogs = row.Count < 2 || !row[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase); - return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken)); + if (row.Count < 3) + return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken)); + // Mastodon's own columns, each changing the follow only when the file has it: told of its new posts, and the + // languages of them that come home (empty: every one) + var notify = row[2].Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + if (row.Count < 4) + return Failure(row[0], await accounts.SetNotify(account.Id, showReblogs, notify, cancellationToken)); + var languages = row[3].Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return Failure(row[0], await accounts.Follow(account.Id, showReblogs, notify, languages, cancellationToken)); } async Task AccountRow(string acct, AccountAction action, CancellationToken cancellationToken) diff --git a/Services/StreamingService.cs b/Services/StreamingService.cs index 7e08f2e..945df5b 100644 --- a/Services/StreamingService.cs +++ b/Services/StreamingService.cs @@ -12,9 +12,11 @@ namespace decePubClient.Services /// One stream of PrivaPub's streaming API: its name and, for a hashtag or a list, which one. public sealed record StreamSubscription(string Stream, string Tag = default, string List = default); - /// What a stream told: the stream it came on, the event (update, notification, status.update, delete) and - /// what it is about (a Status or a Notification for those, the deleted id for delete). - public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default); + /// What a stream told: the event (update, notification, status.update, delete, and on the direct stream + /// conversation) and what it is about (a Status, a Notification or a Conversation for those, the deleted id for + /// delete). + public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default, + Conversation Conversation = default); /// What the user stream told of the administrators' announcements: one new or changed (announcement), one /// gone (announcement.delete, its id), or a reaction's count now (announcement.reaction). @@ -215,6 +217,8 @@ namespace decePubClient.Services { "update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), "notification" => new StreamMessage(name, Notification: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), + // a direct conversation with a new message, as /api/v1/conversations lists it (the direct stream) + "conversation" => new StreamMessage(name, Conversation: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), "delete" => new StreamMessage(name, DeletedId: payload), _ => null };