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:
1 parent
566b0e33ea
commit
d33697e3fc
28 files changed
+1782
-50
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
|
||||
@@ -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."]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user