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
+1784
-52
No files matched your search
@@ -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."]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user