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

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

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

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

No files matched your search

+76 -15
View File
@@ -214,10 +214,10 @@ all of them in every theme. When you add or change one:
- `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
- the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`);
- `IsOnline` / `IsOffline`, polled every 10 seconds;
- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), `IsModerator`
and `IsAdmin` (the JWT's policies), with `LoadSession`, `SwitchPersona`, `PersonaDeleted` and `SignOut`. A persona
change re-renders every subscriber; pages compare `PersonaId` and reload what belongs to the persona. The JWT is renewed
hourly when needed;
- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), `Preferences`
(its reading preferences, see **Reading preferences**), `IsModerator` and `IsAdmin` (the JWT's policies), with
`LoadSession`, `SwitchPersona`, `PersonaDeleted` and `SignOut`. A persona change re-renders every subscriber; pages
compare `PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed;
- a persona the staff suspended (`ViewAvatar.AccountState` `Suspended` in `/clientapi/avatar/private/list`, `CanUse`) is
never acted as: `LoadSession` skips it, `SwitchPersona` refuses it, and the switcher and Login's chooser show it
disabled (`data-persona-state`). When PrivaPub refuses the current persona a token while the JWT is good (suspended
@@ -307,11 +307,13 @@ Never redeclare the base's members in a component.
reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, the persona's followers and following and removing a follower, `/api/v2/search`, whose
handles and URLs PrivaPub resolves), `ListService` (the
block, mute, follow requests, the persona's followers and following and removing a follower, an account's featured
hashtags, and what the persona keeps of an account for itself: notify, languages, endorsement and private note (see
**Profiles and search**), `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
following and featuring them, the persona's featured hashtags and their suggestions, trends, the directory, who to
follow), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups),
`InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's
`InstanceAdminController`), `ModerationService` (the staff's acts: accounts found, silenced, suspended and given back,
@@ -360,6 +362,25 @@ Never redeclare the base's members in a component.
violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks
(`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each).
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
The hashtags an account features (`GET /api/v1/accounts/:id/featured_tags`: a persona's own, an account elsewhere's as
its server told them) are chips under its fields (`data-profile-featured-tags`, each a `button.badge`
`data-profile-featured-tag="{name}"` opening that hashtag's feed tab, its count and last use as its title); on the
persona's own profile, with a link to edit them (`data-profile-featured-tags-edit`, `settings?open=profile`, which opens
Settings' Profile section). Under another account's header is what the persona keeps of it **for itself alone**
(`data-profile-private`; the account is never told, and none of it federates):
- for an account it follows, a bell (`data-profile-notify="on|off"`: follow again with `notify`, which gives the persona a
`status` notification for each of its new posts), the languages of its posts that reach the home feed
(`data-profile-languages`, a `DDropdown SheetOnNarrow` whose value is the codes kept, comma-separated, empty for every
language; a `DCheckbox` `data-profile-language="{code}"` each, saved together with `data-profile-languages-save`,
`data-profile-languages-all` for every language; follow again with `languages[]`, an empty value for none, since an
empty list is every language) and its recommendation (`data-profile-endorse="on|off"`, `/accounts/:id/endorse|unendorse`:
Mastodon's endorsements, which PrivaPub keeps private, the owner's default: worded Recommend / Keep in my
recommendations, and said to be seen by the persona alone). Both follow calls send `reblogs` as the relationship has
it, since a follow always sets it (`AccountService.SetNotify`, `SetLanguages`). Unfollowing drops the endorsement;
- for any account, a private note (`textarea[data-profile-note]`, the relationship's `note`, `POST /accounts/:id/note`
with `comment`, 2000 characters at most, empty takes it away), saved as it loses focus (a `focusout` on its wrapper) or
with `data-profile-note-save`; `data-profile-note-saved` says it was. Settings' biography textarea in ProfileEditor is
`data-profile-note` too, on another page.
On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the
accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub
lists them to the persona alone (`hide_collections`), so another profile keeps plain counts. A follower is removed
@@ -444,9 +465,19 @@ Never redeclare the base's members in a component.
persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its featured
hashtags below it (`FeaturedTagsEditor`, `data-featured-tags`: `/api/v1/featured_tags`, public, so the persona's followers
are told of each change; each `data-featured-tag="{name}"` with `data-featured-tag-remove`, removed at once since a
hashtag can be featured again; added by `input[data-featured-tag-new]` (Enter or `data-featured-tag-add`, PrivaPub's 422
shown under it) or from those it used lately, `/featured_tags/suggestions`, as `data-featured-tag-suggestion="{name}"`;
at most the instance API's `max_featured_tags`, 10, counted in `data-featured-tags-count`), its reading preferences
(`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or
hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST
/clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at
`/api/v1/preferences`; see **Reading preferences**), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
imported as the CSV files Mastodon writes, so a persona moves between servers. An import
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
@@ -458,7 +489,17 @@ Never redeclare the base's members in a component.
Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups
(`backups`). Moderators also see Accounts (`accounts`), administrators Users & personas (`users`). PrivaPub checks the
role on every call (moderators: `/clientapi/moderator`, administrators: `/clientapi/admin`, checked against its database
too). Administrators also see Custom emoji (`emoji`, after Announcements; see **Custom emoji**).
too). Administrators also see Custom emoji (`emoji`, after Announcements; see **Custom emoji**) and Staff picks
(`staff-picks`, after Custom emoji).
- **Staff picks** (`StaffPicksPanel`, `data-staff-picks`, `InstanceAdminService.StaffPicks`, `AddStaffPick`,
`RemoveStaffPick`; `GET /clientapi/admin/staff-picks`, `POST /staff-picks/insert` with `StaffPickForm`, `POST
/staff-picks/delete?accountId=`, each answering the picks as `List<ViewStaffPick>`): the accounts suggested first to
every persona (Explore's Who to follow), in their order, each `data-staff-pick="{handle}"` (`data-staff-pick-id`,
`data-staff-pick-shown` false for one deleted, suspended or silenced since, which is suggested to nobody) with
`data-staff-pick-remove` (not asked: it can be picked again). Added from `input[data-staff-pick-search]`
(`/api/v2/search`, never resolving, communities left out) as `data-staff-pick-option="{account id}"`; 40 at most
(`data-staff-picks-count`). PrivaPub's 404 (no account it can suggest), 409 (picked already) and 422 (past 40) are
toasts. The staff's log names them `staff_pick.insert` and `staff_pick.delete`.
- **Users & personas** (`UsersPanel`, `data-users`, `/clientapi/admin/roots`; the one place a login is shown with its
personas): newest first, 50 at a time (`data-users-more`), found by a login's or a persona's name (`data-users-search`;
`data-users-reload`). Each login (`data-user="{login name}"`, `data-user-role` user|moderator|admin,
@@ -529,6 +570,13 @@ Never redeclare the base's members in a component.
- **A post from the feed:** a moderator removes another's post from its card's menu (`data-moderate-remove-post`,
`ActDialog`, `/clientapi/moderator/posts/remove`): one written here is deleted as its author would, and its author
warned; a copy of one from elsewhere is hidden here. The page takes the card out.
- **Reading preferences** (PrivaPub's R8, `ViewPersonaPreferences`): how the persona reads what others post, read with it
(`AuthService.PersonaPreferences` in `CascadingState.SwitchPersona`, PrivaPub's defaults when they can't be read) and
kept as `CascadingState.Preferences`, replaced by `PreferencesChanged` after Settings saves them (and when Settings reads
them changed from another device). The card applies them: media wait behind their button (`data-media-hidden`, its
blurhash behind it) when marked sensitive (`default`), never (`show_all`) or always (`hide_all`, "Hidden media, show"),
a blur filter's always (`Content.HidesMedia`); a content warning starts open with `ExpandSpoilers`. `Preferences` is in
`Content.RenderKey`. `Models/Types/ExpandMedia.cs` maps the modes (`ExpandMediaModes`).
- **Custom emoji** (PrivaPub's R7: the server's own emoji, `Services/EmojiService.cs`).
- **For everyone:** `GET /api/v1/custom_emojis` (no credentials; PrivaPub lists only those neither disabled nor kept out
of its picker) is read once a day and kept in localStorage (`CustomEmojiCache`, registered in `ClientJsonContext`);
@@ -594,7 +642,9 @@ Never redeclare the base's members in a component.
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait. A warning from the staff (`moderation_warning`, `Notification.ModerationWarning`) shows
links there while requests wait. A `status` notification (an account the persona follows with notify posted, PrivaPub's
R8) says "{0} posted" with the post (`data-notification="status"`, and `data-notification-status` holding the post's
id). A warning from the staff (`moderation_warning`, `Notification.ModerationWarning`) shows
what they did and what they wrote (`data-notification-warning="{action}"`, `data-notification-warning-text`). A
notification is dismissed from its line (`data-notification-dismiss`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
@@ -602,7 +652,14 @@ Never redeclare the base's members in a component.
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too.
it holds), the directory, who to follow, and the hashtags the persona follows. A hashtag's feed tab follows it too, and
features it on the persona's profile (`data-tag-feature="on|off"`, `/api/v1/tags/:name/feature|unfeature`, the Tag's
`featuring`; public, its followers are told).
- **Who to follow** (`data-suggestions`, `GET /api/v2/suggestions`, 40): each `data-suggestion="{acct}"` with
`data-suggestion-source` (staff: picked by the staff, past_interactions: the persona interacted with it, global:
popular here; its `sources` as the badge's title), `data-suggestion-follow` (taken off the list once followed or asked)
and `data-suggestion-dismiss` (`DELETE /api/v1/suggestions/:id`, for good, so asked first). PrivaPub never suggests the
persona itself nor a sibling persona of its login.
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
`PrivaPub.ClientModels`.
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
@@ -617,8 +674,12 @@ Never redeclare the base's members in a component.
ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
for home, lists, local, federated, hashtags, notifications and direct conversations, and wait behind a "new" button; a
deletion or an edit it tells applies at once. The Direct tab follows the `direct` stream, whose `conversation` event
(`StreamMessage.Conversation`) moves that conversation up: behind the "new" button, or at once at the top when the
persona wrote its newest message (the message it just sent, shown alone until then, is replaced by it). Elsewhere the
Direct tab's unread badge is still counted on every poll tick. Other feeds, and any feed while the stream is down, are
polled every 45 s while visible.
- **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones
shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`,
`-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to
+5
View File
@@ -54,6 +54,8 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
[JsonSerializable(typeof(InsertAvatarForm))]
[JsonSerializable(typeof(ViewPersonaPreferences))]
[JsonSerializable(typeof(PersonaPreferencesForm))]
[JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewGroupMember))]
@@ -99,6 +101,9 @@ namespace decePubClient;
[JsonSerializable(typeof(ViewRelay))]
[JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewStaffPick))]
[JsonSerializable(typeof(List<ViewStaffPick>))]
[JsonSerializable(typeof(StaffPickForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))]
[JsonSerializable(typeof(UsersIds))]
[JsonSerializable(typeof(ModerateAccountForm))]
@@ -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
};
}
+165
View File
@@ -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. *@
<div class="flex flex-col gap-4 w-full" data-staff-picks>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else
{
<p class="text-xs opacity-70">@Localizer["Suggested first to every persona of this server, in this order, under Explore's Who to follow."]</p>
@if (picks.Count == 0)
{
<p class="text-sm opacity-70" data-staff-picks-empty>@Localizer["No account is picked yet."]</p>
}
else
{
<ul class="flex flex-col gap-2">
@foreach (var pick in picks)
{
<li class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 p-2 rounded-lg neo-raised neo-2xs @(pick.IsShown ? null : "opacity-60")"
data-staff-pick="@pick.Handle" data-staff-pick-id="@pick.AccountId" data-staff-pick-shown="@(pick.IsShown ? "true" : "false")">
<i class="@(pick.IsLocal ? "ion-md-person" : "ion-md-globe") text-lg" aria-hidden="true"></i>
<a class="flex flex-col min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(pick.AccountId)}")">
<b class="truncate">@(pick.Name ?? pick.Handle ?? pick.AccountId)</b>
<span class="text-xs opacity-70 truncate">@(pick.Handle is { Length: > 0 } handle ? "@" + handle : pick.AccountId)</span>
</a>
@if (!pick.IsShown)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning"
title="@Localizer["Deleted, suspended or silenced since it was picked: suggested to nobody."]">@Localizer["Not suggested now"]</DBadge>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Remove(pick)" data-staff-pick-remove>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
</DButton>
</li>
}
</ul>
}
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<DInput TValue="string" class="flex-1 min-w-48" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true"
autocomplete="off" placeholder="@Localizer["Search accounts here and elsewhere"]" aria-label="@Localizer["Search accounts here and elsewhere"]"
disabled="@IsFull" data-staff-pick-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<span class="text-xs opacity-70 tabular-nums" data-staff-picks-count>@picks.Count/@MaxPicks</span>
</div>
@if (query is { Length: > 0 })
{
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2" data-staff-pick-options>
@foreach (var account in matches)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="@(IsDefaultDisabled || isActing || IsFull)" OnClick="() => Add(account)"
data-staff-pick-option="@account.Id">
<i class="ion-md-add" aria-hidden="true"></i>
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</DButton>
}
@if (matches.Count == 0 && !isSearching)
{
<span class="text-xs opacity-70">@Localizer["No account matches."]</span>
}
</div>
}
</div>
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] AccountService AccountService { get; set; }
/// <summary>PrivaPub's limit (InstanceAdminController.MaxStaffPicks).</summary>
const int MaxPicks = 40;
List<ViewStaffPick> picks = [];
string query;
List<Account> 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<ViewStaffPick>)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<ViewStaffPick>)result.Data;
Toast.AddResponseSuccess(Localizer["{0} is no longer suggested by the staff.", pick.Name ?? pick.Handle ?? pick.AccountId]);
}
}
+30 -8
View File
@@ -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)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -40,7 +42,8 @@
@if (Post.Notification is { } notification)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount">
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount"
data-notification-status="@(notification.Type == "status" ? Post.Status?.Id ?? string.Empty : null)">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
@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;
}
/// <summary>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.</summary>
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)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
@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 *@
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash" data-media-hidden>
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@HiddenMediaText(status)</span>
</button>
}
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"
};
+26 -1
View File
@@ -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. *@
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0">
@@ -99,6 +100,15 @@
OnClick="ToggleTagFollow" data-tag-follow>
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
</DButton>
@if (tag.Featuring is { } featuring)
{
<DButton Size="DSize.Xs" Active="featuring" Disabled="@(IsDefaultDisabled || isFollowing)" OnClick="ToggleTagFeature"
title="@Localizer["Featured hashtags are public: they show on the persona's profile, and its followers are told."]"
aria-pressed="@(featuring ? "true" : "false")" data-tag-feature="@(featuring ? "on" : "off")">
<i class="ion-md-ribbon" aria-hidden="true"></i>
<span>@(featuring ? Localizer["Featured on profile"] : Localizer["Feature on profile"])</span>
</DButton>
}
}
</div>
}
@@ -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"],
@@ -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. *@
<div class="flex flex-col gap-3 w-full" data-featured-tags>
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold">@Localizer["Featured hashtags"]</h3>
<p class="text-xs opacity-70">@Localizer["They show on this persona's profile, for anyone to read, and its followers are told of each change."]</p>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (CascadingState.PersonaId is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
@if (tags.Count == 0)
{
<p class="text-xs opacity-70" data-featured-tags-empty>@Localizer["None featured yet."]</p>
}
else
{
<ul class="flex flex-wrap gap-2 pointer-coarse:gap-2">
@foreach (var tag in tags)
{
<li class="flex items-center gap-1 ps-3 pe-1 py-1 rounded-full neo-raised neo-2xs text-sm min-w-0" data-featured-tag="@tag.Name">
<span class="truncate">#@tag.Name</span>
<span class="text-xs opacity-60 tabular-nums" title="@UsedTitle(tag)">@tag.Posts</span>
<DButton Size="DSize.Xs" Shape="DButtonShape.Circle" Variant="DButtonVariant.Ghost" Disabled="@(IsDefaultDisabled || isActing)"
OnClick="() => Remove(tag)" title="@Localizer["Stop featuring #{0}", tag.Name]" aria-label="@Localizer["Stop featuring #{0}", tag.Name]"
data-featured-tag-remove>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
</li>
}
</ul>
}
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DInput TValue="string" Size="DSize.Sm" class="flex-1 min-w-48 max-w-md" @bind-Value="newName" Immediate="true" autocomplete="off" maxlength="100"
placeholder="@Localizer["A hashtag to feature"]" aria-label="@Localizer["A hashtag to feature"]" disabled="@IsFull"
@onkeydown="AddOnEnter" data-featured-tag-new>
<Prefix><span class="opacity-60" aria-hidden="true">#</span></Prefix>
</DInput>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing || IsFull || string.IsNullOrWhiteSpace(newName))"
OnClick="AddTyped" data-featured-tag-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Feature"]</span>
</DButton>
@if (max > 0)
{
<span class="text-xs opacity-70 tabular-nums" data-featured-tags-count>@tags.Count/@max</span>
}
</div>
<ErrorViewer AdditionalError="@addError" />
@if (Suggested.Count > 0 && !IsFull)
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 text-sm" data-featured-tag-suggestions>
<span class="text-xs opacity-70">@Localizer["Used lately:"]</span>
@foreach (var suggestion in Suggested)
{
<button type="button" class="badge badge-soft badge-sm gap-1" disabled="@(IsDefaultDisabled || isActing)" @onclick="() => Add(suggestion.Name)"
title="@Localizer["Feature #{0}", suggestion.Name]" data-featured-tag-suggestion="@suggestion.Name">
<i class="ion-md-add" aria-hidden="true"></i><span>#@suggestion.Name</span>
</button>
}
</div>
}
}
</div>
@code {
[Inject] DiscoveryService DiscoveryService { get; set; }
[Inject] AboutService AboutService { get; set; }
List<FeaturedTag> tags = [];
List<Tag> 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<Tag> 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<bool> 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]);
}
}
@@ -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. *@
<div class="flex flex-col gap-4 w-full" data-reading-preferences>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (CascadingState.PersonaId is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
<label class="flex flex-col gap-1 max-w-md">
<span class="text-sm">@Localizer["Pictures, videos and sounds"]</span>
<DSelect TValue="ExpandMedia" class="w-full" @bind-Value="expandMedia" data-preference-expand-media>
<option value="@ExpandMedia.Default">@Localizer["Hide those marked sensitive"]</option>
<option value="@ExpandMedia.ShowAll">@Localizer["Show all of them"]</option>
<option value="@ExpandMedia.HideAll">@Localizer["Hide all of them"]</option>
</DSelect>
</label>
<DToggle LabelText="@Localizer["Open content warnings"]" @bind-Value="expandSpoilers" data-preference-expand-spoilers />
<p class="text-xs opacity-70">@Localizer["Kept for this persona on the server: other apps signed in as it read them too."]</p>
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-preferences-reset>@Localizer["Reset"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="Save" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-preferences-save>
@if (isSaving)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
}
</div>
@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."]);
}
}
+24 -1
View File
@@ -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. *@
<CascadingValue IsFixed=false Value=this>
@ChildContent
</CascadingValue>
@@ -43,6 +44,12 @@
public string PersonaId { get; private set; }
/// <summary>That persona's Mastodon account, null until it is read.</summary>
public Account Persona { get; private set; }
/// <summary>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.</summary>
public ViewPersonaPreferences Preferences { get; private set; } = new();
/// <summary>Preferences.ExpandMedia as the cards use it.</summary>
public ExpandMedia ExpandMedia => ExpandMediaModes.Of(Preferences.ExpandMedia);
/// <summary>The root handles reports and moderates accounts and posts (its JWT's policies); PrivaPub checks it anyway.</summary>
public bool IsModerator { get; private set; }
/// <summary>The root administers the server (its JWT's policies); PrivaPub checks it anyway.</summary>
@@ -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;
}
/// <summary>The current persona's reading preferences as PrivaPub now keeps them, after Settings saved them; the cards
/// show them from their next render.</summary>
public void PreferencesChanged(ViewPersonaPreferences preferences)
{
if (preferences is null || preferences.AvatarId != PersonaId)
return;
Preferences = preferences;
StateHasChanged();
}
/// <summary>Reads the root's personas again, after one was made.</summary>
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)
+4
View File
@@ -56,6 +56,10 @@ namespace decePubClient;
[JsonSerializable(typeof(ScheduleForm))]
[JsonSerializable(typeof(Tag))]
[JsonSerializable(typeof(List<Tag>))]
[JsonSerializable(typeof(FeaturedTag))]
[JsonSerializable(typeof(List<FeaturedTag>))]
[JsonSerializable(typeof(Suggestion))]
[JsonSerializable(typeof(List<Suggestion>))]
[JsonSerializable(typeof(List<TrendLink>))]
[JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))]
+13
View File
@@ -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";
+12
View File
@@ -94,10 +94,22 @@ public class Relationship
public bool Muting { get; set; }
public bool MutingNotifications { get; set; }
public bool ShowingReblogs { get; set; }
/// <summary>The persona is told of each of the account's new posts (a `status` notification): the follow's notify.</summary>
public bool Notifying { get; set; }
/// <summary>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.</summary>
public List<string> Languages { get; set; } = [];
public bool Requested { get; set; }
public bool RequestedBy { get; set; }
public bool DomainBlocking { get; set; }
/// <summary>In the persona's recommendations (Mastodon's endorsements): private here, listed to the persona alone and
/// never federated; only while it follows the account.</summary>
public bool Endorsed { get; set; }
/// <summary>The persona's own private note on the account (never its login's, never federated); empty for none.</summary>
public string Note { get; set; }
/// <summary>The languages chosen, none meaning every language.</summary>
public IReadOnlyList<string> WantedLanguages => Languages ?? [];
}
/// <summary>/api/v2/search: what matched, a remote handle or URL resolved by PrivaPub.</summary>
+34 -1
View File
@@ -1,13 +1,15 @@
namespace decePubClient.Models.Mastodon;
/// <summary>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).</summary>
/// follows it and features it on its profile (both null when nobody is signed in).</summary>
public class Tag
{
public string Name { get; set; }
public string Url { get; set; }
public List<TagDay> History { get; set; } = [];
public bool? Following { get; set; }
/// <summary>Featured on the persona's profile (Mastodon 4.4's `featuring`).</summary>
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);
}
/// <summary>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.</summary>
public class FeaturedTag
{
public string Id { get; set; }
/// <summary>The hashtag as the account wrote it (its case kept), without the #.</summary>
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;
/// <summary>The day it was last used, or null when never.</summary>
public DateTime? LastStatusDay =>
DateTime.TryParseExact(LastStatusAt, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var day)
? day
: null;
}
/// <summary>/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).</summary>
public class Suggestion
{
public string Source { get; set; }
public List<string> Sources { get; set; } = [];
public Account Account { get; set; }
}
+7
View File
@@ -37,6 +37,13 @@ public class InstanceRule
public class InstanceConfiguration
{
public MediaConfiguration MediaAttachments { get; set; } = new();
public AccountsConfiguration Accounts { get; set; } = new();
}
/// <summary>What an account may hold: how many hashtags it features on its profile (PrivaPub's 10).</summary>
public class AccountsConfiguration
{
public int MaxFeaturedTags { get; set; }
}
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
+30
View File
@@ -0,0 +1,30 @@
namespace decePubClient.Models.Types
{
/// <summary>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.</summary>
public enum ExpandMedia
{
Default = 1,
ShowAll,
HideAll
}
/// <summary>ExpandMedia as PrivaPub names it ("default", "show_all", "hide_all").</summary>
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"
};
}
}
+15 -1
View File
@@ -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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -118,6 +119,19 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="staff-picks">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-star text-xl"></i> <span>@Localizer["Staff picks"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<StaffPicksPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header>
<p class="inline-flex items-center gap-2">
+103 -2
View File
@@ -4,8 +4,10 @@
<Title>@Localizer["Explore"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -21,6 +23,7 @@
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
<DTab><Header><i class="ion-md-person-add"></i> @Localizer["Who to follow"]</Header></DTab>
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
</DTabs>
</div>
@@ -75,6 +78,41 @@
}
</div>
}
else if (tab is ExploreTab.Suggestions)
{
@if (suggestions.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["No one to suggest yet."]</p>
}
<div class="flex flex-col gap-2" data-suggestions>
@foreach (var suggestion in suggestions)
{
<div class="flex flex-wrap items-center gap-3 pointer-coarse:gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0"
data-suggestion="@suggestion.Account.Acct" data-suggestion-source="@suggestion.Source">
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(suggestion.Account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(suggestion.Account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(suggestion.Account.Name, suggestion.Account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + suggestion.Account.Acct)</span>
</span>
</a>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" title="@WhyText(suggestion)">@SourceName(suggestion.Source)</DBadge>
<div class="flex gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => FollowSuggested(suggestion)"
data-suggestion-follow>
<i class="ion-md-person-add" aria-hidden="true"></i><span>@Localizer["Follow"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Dismiss(suggestion)"
title="@Localizer["Never suggest {0} again", suggestion.Account.Name]" aria-label="@Localizer["Never suggest {0} again", suggestion.Account.Name]"
data-suggestion-dismiss>
<i class="ion-md-close" aria-hidden="true"></i><span class="max-sm:hidden">@Localizer["Dismiss"]</span>
</DButton>
</div>
</div>
}
</div>
}
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<FeedPost> posts = [];
List<TrendLink> links = [];
List<Account> people = [];
List<Suggestion> 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<FeedFilter>(nameof(FeedFilter)) ?? new();
+21 -5
View File
@@ -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). *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
@* 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
};
/// <summary>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.</summary>
/// 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.</summary>
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))
+221 -1
View File
@@ -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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -151,6 +156,26 @@
</dl>
}
@if (featuredTags.Count > 0 || IsOwn)
{
<div class="flex flex-wrap items-center gap-2 px-4 pt-3 text-sm" role="group" aria-label="@Localizer["Featured hashtags"]" data-profile-featured-tags>
@foreach (var featured in featuredTags)
{
<button type="button" class="badge badge-soft badge-sm gap-1" title="@FeaturedTitle(featured)" @onclick="() => OpenTag(featured.Name)"
data-profile-featured-tag="@featured.Name">
<span>#@featured.Name</span><span class="opacity-60 tabular-nums">@featured.Posts</span>
</button>
}
@if (IsOwn)
{
<a class="link link-hover text-xs touch-target" href="settings?open=profile" data-profile-featured-tags-edit>
<i class="ion-md-create" aria-hidden="true"></i>
@(featuredTags.Count == 0 ? Localizer["Feature hashtags on this profile"] : Localizer["Edit featured hashtags"])
</a>
}
</div>
}
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
@if (IsOwn)
@@ -170,6 +195,80 @@
</div>
</article>
@if (!IsOwn && relationship is { } related)
{
<section class="flex flex-none flex-col gap-3 p-3 md:p-4 rounded-xl neo-inset neo-2xs" aria-label="@Localizer["Only for this persona"]" data-profile-private>
<p class="flex items-center gap-2 text-xs opacity-80">
<i class="ion-md-lock" aria-hidden="true"></i>
<span>@Localizer["Only this persona sees what is here: {0} is never told.", account.Name]</span>
</p>
@if (related.Following)
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Active="related.Notifying" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleNotify"
aria-pressed="@(related.Notifying ? "true" : "false")" data-profile-notify="@(related.Notifying ? "on" : "off")">
<i class="@(related.Notifying ? "ion-md-notifications" : "ion-md-notifications-off")" aria-hidden="true"></i>
<span>@(related.Notifying ? Localizer["Notified of new posts"] : Localizer["Notify me of new posts"])</span>
</DButton>
<DDropdown IsOpen="isLanguagesOpen" IsOpenChanged="LanguagesOpened" TriggerClass="btn btn-sm" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-80 max-w-[calc(100vw-2rem)] bg-base-100 neo-raised neo-xs z-20"
title="@Localizer["Languages of its posts in your home feed"]" data-profile-languages="@(string.Join(",", related.WantedLanguages))">
<Trigger>
<i class="ion-md-globe" aria-hidden="true"></i><span class="truncate max-w-48">@LanguagesLabel</span>
</Trigger>
<ChildContent>
<p class="text-xs opacity-70">@Localizer["Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language."]</p>
<div class="grid grid-cols-2 gap-x-2 gap-y-1 max-h-64 overflow-y-auto">
@foreach (var (languageCode, languageName) in LanguageChoices)
{
<DCheckbox Size="DSize.Sm" LabelText="@languageName" Value="@wantedLanguages.Contains(languageCode)"
ValueChanged="on => TickLanguage(languageCode, on)" ValueExpression="() => languageFlag" data-profile-language="@languageCode" />
}
</div>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="EveryLanguage" data-profile-languages-all>@Localizer["Every language"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="SaveLanguages" data-profile-languages-save>
@Localizer["Save"]
</DButton>
</div>
</ChildContent>
</DDropdown>
<DButton Size="DSize.Sm" Active="related.Endorsed" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleEndorse"
title="@(related.Endorsed ? Localizer["Take {0} out of my recommendations", account.Name] : Localizer["Keep {0} in my recommendations", account.Name])"
aria-pressed="@(related.Endorsed ? "true" : "false")" data-profile-endorse="@(related.Endorsed ? "on" : "off")">
<i class="ion-md-ribbon" aria-hidden="true"></i>
<span>@(related.Endorsed ? Localizer["Recommended"] : Localizer["Recommend"])</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else."]</p>
}
@* the note is saved as it loses focus (focusout bubbles from the textarea), or with Save *@
<div class="flex flex-col gap-1" @onfocusout="SaveNote">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Private note"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="noteDraft" Immediate="true" rows="3" autocomplete="off"
maxlength="@AccountService.NoteLength" placeholder="@Localizer["Something to remember about {0}", account.Name]" data-profile-note />
</label>
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (noteSaved && !NoteChanged)
{
<span class="me-auto text-xs text-success" role="status" data-profile-note-saved>
<i class="ion-md-checkmark" aria-hidden="true"></i> @Localizer["Saved."]
</span>
}
<span class="text-xs opacity-70 tabular-nums">@(noteDraft?.Length ?? 0)/@AccountService.NoteLength</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSavingNote || !NoteChanged)" OnClick="SaveNote" data-profile-note-save>
@if (isSavingNote)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</div>
</section>
}
@if (IsOwn)
{
@if (showAccounts is { } followers)
@@ -246,6 +345,32 @@
bool? showAccounts;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
List<FeaturedTag> featuredTags = [];
bool isLanguagesOpen;
// the languages ticked while the languages menu is open, saved together
HashSet<string> 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<string> 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)
+41 -2
View File
@@ -107,9 +107,10 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
@* opened by a link to it (settings?open=profile, from the persona's own profile) *@
<DCollapse Icon="DCollapseIcon.Arrow" @bind-IsOpen="isProfileOpen">
<Header>
<p class="inline-flex items-center gap-2">
<p class="inline-flex items-center gap-2" data-settings-profile>
<i class="ion-md-person text-xl"></i> <span>@Localizer["Profile"]</span>
</p>
</Header>
@@ -117,7 +118,30 @@
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<div class="flex flex-col gap-6">
<ProfileEditor />
<FeaturedTagsEditor />
</div>
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-reading>
<i class="ion-md-eye text-xl"></i> <span>@Localizer["Reading"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<ReadingPreferencesEditor />
}
else
{
@@ -196,6 +220,21 @@
@code {
/// <summary>A section to open as the page opens: profile (the persona's profile and featured hashtags).</summary>
[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();
+210
View File
@@ -3599,4 +3599,214 @@
<data name="{0} reacted with" xml:space="preserve">
<value>{0} ha reagito con</value>
</data>
<data name="#{0} is featured on this persona's profile." xml:space="preserve">
<value>#{0} è in evidenza sul profilo di questa persona.</value>
</data>
<data name="#{0} is no longer featured." xml:space="preserve">
<value>#{0} non è più in evidenza.</value>
</data>
<data name="A hashtag to feature" xml:space="preserve">
<value>Un hashtag da mettere in evidenza</value>
</data>
<data name="Account picked by the staff" xml:space="preserve">
<value>Account scelto dallo staff</value>
</data>
<data name="Deleted, suspended or silenced since it was picked: suggested to nobody." xml:space="preserve">
<value>Eliminato, sospeso o silenziato dopo essere stato scelto: non è suggerito a nessuno.</value>
</data>
<data name="Edit featured hashtags" xml:space="preserve">
<value>Modifica gli hashtag in evidenza</value>
</data>
<data name="Every language" xml:space="preserve">
<value>Tutte le lingue</value>
</data>
<data name="Every post of {0} reaches your home feed, whatever its language." xml:space="preserve">
<value>Ogni post di {0} arriva nella tua home, in qualunque lingua.</value>
</data>
<data name="Feature" xml:space="preserve">
<value>Metti in evidenza</value>
</data>
<data name="Feature #{0}" xml:space="preserve">
<value>Metti in evidenza #{0}</value>
</data>
<data name="Feature hashtags on this profile" xml:space="preserve">
<value>Metti in evidenza degli hashtag su questo profilo</value>
</data>
<data name="Feature on profile" xml:space="preserve">
<value>Metti in evidenza sul profilo</value>
</data>
<data name="Featured hashtags" xml:space="preserve">
<value>Hashtag in evidenza</value>
</data>
<data name="Featured hashtags are public: they show on the persona's profile, and its followers are told." xml:space="preserve">
<value>Gli hashtag in evidenza sono pubblici: compaiono sul profilo della persona, e i suoi follower lo sanno.</value>
</data>
<data name="Featured on profile" xml:space="preserve">
<value>In evidenza sul profilo</value>
</data>
<data name="Hidden media, show" xml:space="preserve">
<value>Media nascosti, mostra</value>
</data>
<data name="Hide all of them" xml:space="preserve">
<value>Nascondili tutti</value>
</data>
<data name="Hide those marked sensitive" xml:space="preserve">
<value>Nascondi quelli segnati come sensibili</value>
</data>
<data name="Keep {0} in my recommendations" xml:space="preserve">
<value>Tieni {0} tra i miei consigliati</value>
</data>
<data name="Kept for this persona on the server: other apps signed in as it read them too." xml:space="preserve">
<value>Salvate per questa persona sul server: anche le altre app collegate come lei le leggono.</value>
</data>
<data name="Languages of its posts in your home feed" xml:space="preserve">
<value>Lingue dei suoi post nella tua home</value>
</data>
<data name="Never suggest {0} again" xml:space="preserve">
<value>Non suggerire più {0}</value>
</data>
<data name="Never suggest {0} again?" xml:space="preserve">
<value>Non suggerire più {0}?</value>
</data>
<data name="No account is picked yet." xml:space="preserve">
<value>Nessun account è ancora stato scelto.</value>
</data>
<data name="No account matches." xml:space="preserve">
<value>Nessun account corrisponde.</value>
</data>
<data name="No longer told when {0} posts." xml:space="preserve">
<value>Non ti verrà più detto quando {0} pubblica.</value>
</data>
<data name="No one to suggest yet." xml:space="preserve">
<value>Ancora nessuno da suggerire.</value>
</data>
<data name="None featured yet." xml:space="preserve">
<value>Ancora nessuno in evidenza.</value>
</data>
<data name="Not suggested now" xml:space="preserve">
<value>Ora non suggerito</value>
</data>
<data name="Notified of new posts" xml:space="preserve">
<value>Avvisi dei nuovi post attivi</value>
</data>
<data name="Notify me of new posts" xml:space="preserve">
<value>Avvisami dei nuovi post</value>
</data>
<data name="Only for this persona" xml:space="preserve">
<value>Solo per questa persona</value>
</data>
<data name="Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language." xml:space="preserve">
<value>Nella tua home arrivano solo i suoi post nelle lingue spuntate, e quelli di lingua sconosciuta. Nessuna spuntata: tutte le lingue.</value>
</data>
<data name="Only the posts of {0} in {1} reach your home feed." xml:space="preserve">
<value>Nella tua home arrivano solo i post di {0} in {1}.</value>
</data>
<data name="Only this persona sees what is here: {0} is never told." xml:space="preserve">
<value>Solo questa persona vede ciò che è qui: {0} non lo sa mai.</value>
</data>
<data name="Open content warnings" xml:space="preserve">
<value>Apri gli avvisi sul contenuto</value>
</data>
<data name="Picked by the staff" xml:space="preserve">
<value>Scelto dallo staff</value>
</data>
<data name="Pictures, videos and sounds" xml:space="preserve">
<value>Immagini, video e audio</value>
</data>
<data name="Popular here" xml:space="preserve">
<value>Popolare qui</value>
</data>
<data name="Private note" xml:space="preserve">
<value>Nota privata</value>
</data>
<data name="Reading" xml:space="preserve">
<value>Lettura</value>
</data>
<data name="Reading preferences saved." xml:space="preserve">
<value>Preferenze di lettura salvate.</value>
</data>
<data name="Recommend" xml:space="preserve">
<value>Consiglia</value>
</data>
<data name="Recommended" xml:space="preserve">
<value>Consigliato</value>
</data>
<data name="Search accounts here and elsewhere" xml:space="preserve">
<value>Cerca account qui e altrove</value>
</data>
<data name="Show all of them" xml:space="preserve">
<value>Mostrali tutti</value>
</data>
<data name="Something to remember about {0}" xml:space="preserve">
<value>Qualcosa da ricordare su {0}</value>
</data>
<data name="Staff pick removed" xml:space="preserve">
<value>Scelta dello staff rimossa</value>
</data>
<data name="Staff picks" xml:space="preserve">
<value>Scelte dello staff</value>
</data>
<data name="Stop featuring #{0}" xml:space="preserve">
<value>Togli #{0} dall'evidenza</value>
</data>
<data name="Suggested first to every persona of this server, in this order, under Explore's Who to follow." xml:space="preserve">
<value>Suggeriti per primi a ogni persona di questo server, in quest'ordine, in Esplora sotto Chi seguire.</value>
</data>
<data name="Take {0} out of my recommendations" xml:space="preserve">
<value>Togli {0} dai miei consigliati</value>
</data>
<data name="There is no undoing it, but {0} can still be followed from its profile." xml:space="preserve">
<value>Non si può annullare, ma {0} si può sempre seguire dal suo profilo.</value>
</data>
<data name="They show on this persona's profile, for anyone to read, and its followers are told of each change." xml:space="preserve">
<value>Compaiono sul profilo di questa persona, chiunque li legge, e i suoi follower sanno di ogni cambiamento.</value>
</data>
<data name="Used lately:" xml:space="preserve">
<value>Usati di recente:</value>
</data>
<data name="Who to follow" xml:space="preserve">
<value>Chi seguire</value>
</data>
<data name="You interacted with them" xml:space="preserve">
<value>Hai interagito con questo account</value>
</data>
<data name="You'll be told when {0} posts." xml:space="preserve">
<value>Ti verrà detto quando {0} pubblica.</value>
</data>
<data name="Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else." xml:space="preserve">
<value>I tuoi consigliati restano privati: li vede solo questa persona, mai sul suo profilo né altrove.</value>
</data>
<data name="followed by many here" xml:space="preserve">
<value>seguito da molti qui</value>
</data>
<data name="much favourited and boosted here lately" xml:space="preserve">
<value>aggiunto ai preferiti e condiviso molto qui di recente</value>
</data>
<data name="picked by this server's staff" xml:space="preserve">
<value>scelto dallo staff di questo server</value>
</data>
<data name="you favourited, boosted or answered them lately" xml:space="preserve">
<value>di recente l'hai aggiunto ai preferiti, condiviso o gli hai risposto</value>
</data>
<data name="{0} is in your recommendations, which only you see." xml:space="preserve">
<value>{0} è tra i tuoi consigliati, che vedi solo tu.</value>
</data>
<data name="{0} is no longer in your recommendations." xml:space="preserve">
<value>{0} non è più tra i tuoi consigliati.</value>
</data>
<data name="{0} is no longer suggested by the staff." xml:space="preserve">
<value>{0} non è più suggerito dallo staff.</value>
</data>
<data name="{0} is no longer suggested." xml:space="preserve">
<value>{0} non è più suggerito.</value>
</data>
<data name="{0} is suggested to every persona." xml:space="preserve">
<value>{0} è suggerito a ogni persona.</value>
</data>
<data name="{0} posts with it" xml:space="preserve">
<value>{0} post con questo hashtag</value>
</data>
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
<value>{0} post con questo hashtag, l'ultimo il {1}</value>
</data>
</root>
+210
View File
@@ -3599,4 +3599,214 @@
<data name="{0} reacted with" xml:space="preserve">
<value>{0} reacted with</value>
</data>
<data name="#{0} is featured on this persona's profile." xml:space="preserve">
<value>#{0} is featured on this persona's profile.</value>
</data>
<data name="#{0} is no longer featured." xml:space="preserve">
<value>#{0} is no longer featured.</value>
</data>
<data name="A hashtag to feature" xml:space="preserve">
<value>A hashtag to feature</value>
</data>
<data name="Account picked by the staff" xml:space="preserve">
<value>Account picked by the staff</value>
</data>
<data name="Deleted, suspended or silenced since it was picked: suggested to nobody." xml:space="preserve">
<value>Deleted, suspended or silenced since it was picked: suggested to nobody.</value>
</data>
<data name="Edit featured hashtags" xml:space="preserve">
<value>Edit featured hashtags</value>
</data>
<data name="Every language" xml:space="preserve">
<value>Every language</value>
</data>
<data name="Every post of {0} reaches your home feed, whatever its language." xml:space="preserve">
<value>Every post of {0} reaches your home feed, whatever its language.</value>
</data>
<data name="Feature" xml:space="preserve">
<value>Feature</value>
</data>
<data name="Feature #{0}" xml:space="preserve">
<value>Feature #{0}</value>
</data>
<data name="Feature hashtags on this profile" xml:space="preserve">
<value>Feature hashtags on this profile</value>
</data>
<data name="Feature on profile" xml:space="preserve">
<value>Feature on profile</value>
</data>
<data name="Featured hashtags" xml:space="preserve">
<value>Featured hashtags</value>
</data>
<data name="Featured hashtags are public: they show on the persona's profile, and its followers are told." xml:space="preserve">
<value>Featured hashtags are public: they show on the persona's profile, and its followers are told.</value>
</data>
<data name="Featured on profile" xml:space="preserve">
<value>Featured on profile</value>
</data>
<data name="Hidden media, show" xml:space="preserve">
<value>Hidden media, show</value>
</data>
<data name="Hide all of them" xml:space="preserve">
<value>Hide all of them</value>
</data>
<data name="Hide those marked sensitive" xml:space="preserve">
<value>Hide those marked sensitive</value>
</data>
<data name="Keep {0} in my recommendations" xml:space="preserve">
<value>Keep {0} in my recommendations</value>
</data>
<data name="Kept for this persona on the server: other apps signed in as it read them too." xml:space="preserve">
<value>Kept for this persona on the server: other apps signed in as it read them too.</value>
</data>
<data name="Languages of its posts in your home feed" xml:space="preserve">
<value>Languages of its posts in your home feed</value>
</data>
<data name="Never suggest {0} again" xml:space="preserve">
<value>Never suggest {0} again</value>
</data>
<data name="Never suggest {0} again?" xml:space="preserve">
<value>Never suggest {0} again?</value>
</data>
<data name="No account is picked yet." xml:space="preserve">
<value>No account is picked yet.</value>
</data>
<data name="No account matches." xml:space="preserve">
<value>No account matches.</value>
</data>
<data name="No longer told when {0} posts." xml:space="preserve">
<value>No longer told when {0} posts.</value>
</data>
<data name="No one to suggest yet." xml:space="preserve">
<value>No one to suggest yet.</value>
</data>
<data name="None featured yet." xml:space="preserve">
<value>None featured yet.</value>
</data>
<data name="Not suggested now" xml:space="preserve">
<value>Not suggested now</value>
</data>
<data name="Notified of new posts" xml:space="preserve">
<value>Notified of new posts</value>
</data>
<data name="Notify me of new posts" xml:space="preserve">
<value>Notify me of new posts</value>
</data>
<data name="Only for this persona" xml:space="preserve">
<value>Only for this persona</value>
</data>
<data name="Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language." xml:space="preserve">
<value>Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language.</value>
</data>
<data name="Only the posts of {0} in {1} reach your home feed." xml:space="preserve">
<value>Only the posts of {0} in {1} reach your home feed.</value>
</data>
<data name="Only this persona sees what is here: {0} is never told." xml:space="preserve">
<value>Only this persona sees what is here: {0} is never told.</value>
</data>
<data name="Open content warnings" xml:space="preserve">
<value>Open content warnings</value>
</data>
<data name="Picked by the staff" xml:space="preserve">
<value>Picked by the staff</value>
</data>
<data name="Pictures, videos and sounds" xml:space="preserve">
<value>Pictures, videos and sounds</value>
</data>
<data name="Popular here" xml:space="preserve">
<value>Popular here</value>
</data>
<data name="Private note" xml:space="preserve">
<value>Private note</value>
</data>
<data name="Reading" xml:space="preserve">
<value>Reading</value>
</data>
<data name="Reading preferences saved." xml:space="preserve">
<value>Reading preferences saved.</value>
</data>
<data name="Recommend" xml:space="preserve">
<value>Recommend</value>
</data>
<data name="Recommended" xml:space="preserve">
<value>Recommended</value>
</data>
<data name="Search accounts here and elsewhere" xml:space="preserve">
<value>Search accounts here and elsewhere</value>
</data>
<data name="Show all of them" xml:space="preserve">
<value>Show all of them</value>
</data>
<data name="Something to remember about {0}" xml:space="preserve">
<value>Something to remember about {0}</value>
</data>
<data name="Staff pick removed" xml:space="preserve">
<value>Staff pick removed</value>
</data>
<data name="Staff picks" xml:space="preserve">
<value>Staff picks</value>
</data>
<data name="Stop featuring #{0}" xml:space="preserve">
<value>Stop featuring #{0}</value>
</data>
<data name="Suggested first to every persona of this server, in this order, under Explore's Who to follow." xml:space="preserve">
<value>Suggested first to every persona of this server, in this order, under Explore's Who to follow.</value>
</data>
<data name="Take {0} out of my recommendations" xml:space="preserve">
<value>Take {0} out of my recommendations</value>
</data>
<data name="There is no undoing it, but {0} can still be followed from its profile." xml:space="preserve">
<value>There is no undoing it, but {0} can still be followed from its profile.</value>
</data>
<data name="They show on this persona's profile, for anyone to read, and its followers are told of each change." xml:space="preserve">
<value>They show on this persona's profile, for anyone to read, and its followers are told of each change.</value>
</data>
<data name="Used lately:" xml:space="preserve">
<value>Used lately:</value>
</data>
<data name="Who to follow" xml:space="preserve">
<value>Who to follow</value>
</data>
<data name="You interacted with them" xml:space="preserve">
<value>You interacted with them</value>
</data>
<data name="You'll be told when {0} posts." xml:space="preserve">
<value>You'll be told when {0} posts.</value>
</data>
<data name="Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else." xml:space="preserve">
<value>Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else.</value>
</data>
<data name="followed by many here" xml:space="preserve">
<value>followed by many here</value>
</data>
<data name="much favourited and boosted here lately" xml:space="preserve">
<value>much favourited and boosted here lately</value>
</data>
<data name="picked by this server's staff" xml:space="preserve">
<value>picked by this server's staff</value>
</data>
<data name="you favourited, boosted or answered them lately" xml:space="preserve">
<value>you favourited, boosted or answered them lately</value>
</data>
<data name="{0} is in your recommendations, which only you see." xml:space="preserve">
<value>{0} is in your recommendations, which only you see.</value>
</data>
<data name="{0} is no longer in your recommendations." xml:space="preserve">
<value>{0} is no longer in your recommendations.</value>
</data>
<data name="{0} is no longer suggested by the staff." xml:space="preserve">
<value>{0} is no longer suggested by the staff.</value>
</data>
<data name="{0} is no longer suggested." xml:space="preserve">
<value>{0} is no longer suggested.</value>
</data>
<data name="{0} is suggested to every persona." xml:space="preserve">
<value>{0} is suggested to every persona.</value>
</data>
<data name="{0} posts with it" xml:space="preserve">
<value>{0} posts with it</value>
</data>
<data name="{0} posts with it, the last on {1}" xml:space="preserve">
<value>{0} posts with it, the last on {1}</value>
</data>
</root>
+76 -6
View File
@@ -29,9 +29,11 @@ namespace decePubClient.Services
}
/// <summary>
/// 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.
/// </summary>
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
@@ -93,16 +95,84 @@ namespace decePubClient.Services
}
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
public Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default) =>
FollowWith(accountId, [new("reblogs", Flag(showReblogs))], cancellationToken);
/// <summary>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.</summary>
public Task<WebResult> Follow(string accountId, bool showReblogs, bool notify, IReadOnlyCollection<string> languages,
CancellationToken cancellationToken = default) =>
FollowWith(accountId, [new("reblogs", Flag(showReblogs)), new("notify", Flag(notify)), .. LanguageFields(languages)], cancellationToken);
/// <summary>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.</summary>
public Task<WebResult> SetNotify(string accountId, bool showReblogs, bool notify, CancellationToken cancellationToken = default) =>
FollowWith(accountId, [new("reblogs", Flag(showReblogs)), new("notify", Flag(notify))], cancellationToken);
/// <summary>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.</summary>
public Task<WebResult> SetLanguages(string accountId, bool showReblogs, IReadOnlyCollection<string> 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<KeyValuePair<string, string>> LanguageFields(IReadOnlyCollection<string> languages) => languages.Count == 0
? [new("languages[]", string.Empty)]
: languages.Select(language => new KeyValuePair<string, string>("languages[]", language));
async Task<WebResult> FollowWith(string accountId, List<KeyValuePair<string, string>> fields, CancellationToken cancellationToken)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
new[] { new KeyValuePair<string, string>("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<Relationship>(cancellationToken) };
}
static string Flag(bool value) => value ? "true" : "false";
/// <summary>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.</summary>
public async Task<WebResult> 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<Relationship>(cancellationToken) };
}
/// <summary>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.</summary>
public async Task<WebResult> SetNote(string accountId, string comment, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountNote(accountId),
new[] { new KeyValuePair<string, string>("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<Relationship>(cancellationToken) };
}
/// <summary>The longest private note PrivaPub keeps (Mastodon's AccountNote limit).</summary>
public const int NoteLength = 2000;
/// <summary>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.</summary>
public async Task<List<FeaturedTag>> 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<List<FeaturedTag>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FeaturedTags), cancellationToken);
return [];
}
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
{
+26
View File
@@ -121,6 +121,32 @@ namespace decePubClient.Services
return new WebResult();
}
/// <summary>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.</summary>
public async Task<ViewPersonaPreferences> 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<ViewPersonaPreferences>(cancellationToken);
}
/// <summary>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.</summary>
public async Task<WebResult> 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<ViewPersonaPreferences>(cancellationToken) };
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{
+76 -3
View File
@@ -18,9 +18,11 @@ namespace decePubClient.Services
}
/// <summary>
/// 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.
/// </summary>
public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
@@ -44,6 +46,77 @@ namespace decePubClient.Services
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Tag>(cancellationToken) };
}
/// <summary>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.</summary>
public async Task<WebResult> 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<Tag>(cancellationToken) };
}
/// <summary>The hashtags the persona features on its profile, with how often it used them.</summary>
public async Task<List<FeaturedTag>> FeaturedTags(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_FeaturedTags, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<FeaturedTag>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FeaturedTags), cancellationToken);
return [];
}
/// <summary>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.</summary>
public async Task<WebResult> AddFeaturedTag(string name, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_FeaturedTag,
new[] { new KeyValuePair<string, string>("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<FeaturedTag>(cancellationToken) };
}
/// <summary>Stops featuring a hashtag, by the featured tag's id; its followers are told.</summary>
public async Task<WebResult> 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);
}
/// <summary>Hashtags the persona used lately and does not feature, the most used first.</summary>
public async Task<List<Tag>> FeaturedTagSuggestions(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_FeaturedTagSuggestions, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Tag>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FeaturedTagSuggestions), cancellationToken);
return [];
}
/// <summary>Accounts to follow, best first, each with why (/api/v2/suggestions).</summary>
public async Task<List<Suggestion>> 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<List<Suggestion>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Suggestions), cancellationToken);
return [];
}
/// <summary>Never suggests the account to this persona again; there is no undoing it, but the account can still be
/// followed from its profile.</summary>
public async Task<WebResult> 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<List<Tag>> FollowedTags(CancellationToken cancellationToken = default) =>
List<Tag>(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken);
+36
View File
@@ -150,6 +150,42 @@ namespace decePubClient.Services
return await RelayList(response, cancellationToken);
}
/// <summary>The accounts the staff suggest first to every persona, in their order; one no longer shown (deleted,
/// suspended or silenced since) has IsShown false.</summary>
public async Task<List<ViewStaffPick>> StaffPicks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminStaffPicks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewStaffPick>>(cancellationToken) ?? [];
await LogFailure(response, nameof(StaffPicks), cancellationToken);
return [];
}
/// <summary>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.</summary>
public async Task<WebResult> 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);
}
/// <summary>No longer suggests the account (it can be picked again); Data is the picks now.</summary>
public async Task<WebResult> 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<WebResult> StaffPickList(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewStaffPick>>(cancellationToken) ?? [] };
}
async Task<WebResult> Saved(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
+9 -1
View File
@@ -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);
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<string> AccountRow(string acct, AccountAction action, CancellationToken cancellationToken)
+7 -3
View File
@@ -12,9 +12,11 @@ namespace decePubClient.Services
/// <summary>One stream of PrivaPub's streaming API: its name and, for a hashtag or a list, which one.</summary>
public sealed record StreamSubscription(string Stream, string Tag = default, string List = default);
/// <summary>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).</summary>
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
/// <summary>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).</summary>
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default,
Conversation Conversation = default);
/// <summary>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).</summary>
@@ -215,6 +217,8 @@ namespace decePubClient.Services
{
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
"notification" => new StreamMessage(name, Notification: JsonSerializer.Deserialize<Notification>(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<Conversation>(payload, SUtility.MastodonSerializer)),
"delete" => new StreamMessage(name, DeletedId: payload),
_ => null
};