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

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

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

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

No files matched your search

@@ -166,6 +166,8 @@
"persona.delete" => Localizer["Persona deleted"],
"group.delete" => Localizer["Group deleted"],
"post.remove" => Localizer["Post removed"],
"staff_pick.insert" => Localizer["Account picked by the staff"],
"staff_pick.delete" => Localizer["Staff pick removed"],
_ => action
};
}
+165
View File
@@ -0,0 +1,165 @@
@inherits LocalizableComponentBase
@* The staff's picks (/clientapi/admin/staff-picks): the accounts suggested first to every persona, in their order, a
persona here or a person elsewhere, found among the accounts this server knows (/api/v2/search, never resolving).
PrivaPub keeps 40 at most and refuses an account picked already (409) or one it can't suggest (404: a community, a
server's actor, a suspended or deleted account). One deleted, suspended or silenced since stays listed, suggested to
nobody. A pick is removed at once: it can be picked again. Every change is in the staff's log. *@
<div class="flex flex-col gap-4 w-full" data-staff-picks>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else
{
<p class="text-xs opacity-70">@Localizer["Suggested first to every persona of this server, in this order, under Explore's Who to follow."]</p>
@if (picks.Count == 0)
{
<p class="text-sm opacity-70" data-staff-picks-empty>@Localizer["No account is picked yet."]</p>
}
else
{
<ul class="flex flex-col gap-2">
@foreach (var pick in picks)
{
<li class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 p-2 rounded-lg neo-raised neo-2xs @(pick.IsShown ? null : "opacity-60")"
data-staff-pick="@pick.Handle" data-staff-pick-id="@pick.AccountId" data-staff-pick-shown="@(pick.IsShown ? "true" : "false")">
<i class="@(pick.IsLocal ? "ion-md-person" : "ion-md-globe") text-lg" aria-hidden="true"></i>
<a class="flex flex-col min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(pick.AccountId)}")">
<b class="truncate">@(pick.Name ?? pick.Handle ?? pick.AccountId)</b>
<span class="text-xs opacity-70 truncate">@(pick.Handle is { Length: > 0 } handle ? "@" + handle : pick.AccountId)</span>
</a>
@if (!pick.IsShown)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning"
title="@Localizer["Deleted, suspended or silenced since it was picked: suggested to nobody."]">@Localizer["Not suggested now"]</DBadge>
}
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Remove(pick)" data-staff-pick-remove>
<i class="ion-md-close" aria-hidden="true"></i><span>@Localizer["Remove"]</span>
</DButton>
</li>
}
</ul>
}
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<DInput TValue="string" class="flex-1 min-w-48" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true"
autocomplete="off" placeholder="@Localizer["Search accounts here and elsewhere"]" aria-label="@Localizer["Search accounts here and elsewhere"]"
disabled="@IsFull" data-staff-pick-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<span class="text-xs opacity-70 tabular-nums" data-staff-picks-count>@picks.Count/@MaxPicks</span>
</div>
@if (query is { Length: > 0 })
{
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2" data-staff-pick-options>
@foreach (var account in matches)
{
<DButton Size="DSize.Sm" class="max-w-full" Disabled="@(IsDefaultDisabled || isActing || IsFull)" OnClick="() => Add(account)"
data-staff-pick-option="@account.Id">
<i class="ion-md-add" aria-hidden="true"></i>
<span class="truncate">@account.Name</span><span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</DButton>
}
@if (matches.Count == 0 && !isSearching)
{
<span class="text-xs opacity-70">@Localizer["No account matches."]</span>
}
</div>
}
</div>
}
</div>
@code {
[Inject] InstanceAdminService InstanceAdminService { get; set; }
[Inject] AccountService AccountService { get; set; }
/// <summary>PrivaPub's limit (InstanceAdminController.MaxStaffPicks).</summary>
const int MaxPicks = 40;
List<ViewStaffPick> picks = [];
string query;
List<Account> matches = [];
int searching;
bool isSearching;
bool isActing;
bool IsFull => picks.Count >= MaxPicks;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => picks = await InstanceAdminService.StaffPicks();
async Task Search(string typed)
{
query = typed;
var lookup = ++searching;
if (string.IsNullOrWhiteSpace(typed))
{
matches = [];
isSearching = false;
return;
}
isSearching = true;
await Task.Delay(300);
if (lookup != searching)
return;
var found = await AccountService.Suggest(typed.Trim(), false);
if (lookup != searching)
return;
// people only (PrivaPub takes no community), and none picked already
matches = [.. found.Accounts.Where(a => !a.Group && picks.All(p => p.AccountId != a.Id))];
isSearching = false;
}
async Task Add(Account account)
{
isActing = true;
var result = await InstanceAdminService.AddStaffPick(account.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
picks = (List<ViewStaffPick>)result.Data;
query = null;
matches = [];
Toast.AddResponseSuccess(Localizer["{0} is suggested to every persona.", account.Name]);
}
async Task Remove(ViewStaffPick pick)
{
isActing = true;
var result = await InstanceAdminService.RemoveStaffPick(pick.AccountId);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
picks = (List<ViewStaffPick>)result.Data;
Toast.AddResponseSuccess(Localizer["{0} is no longer suggested by the staff.", pick.Name ?? pick.Handle ?? pick.AccountId]);
}
}
+30 -8
View File
@@ -12,8 +12,10 @@
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a
warning from the server's staff (moderation_warning) shows what they did and what they wrote. A moderator removes
another's post from its menu, with a note and a warning, asked first. Nerd stats are on the time, the author and its
server's history, the place or the CDN, the media and the counts. *@
another's post from its menu, with a note and a warning, asked first. The persona's reading preferences
(CascadingState.Preferences) say whether a content warning starts open and which media wait behind a button: those
marked sensitive, all, or none. A `status` notification (an account followed with notify posted) shows the post. Nerd
stats are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -40,7 +42,8 @@
@if (Post.Notification is { } notification)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount">
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount"
data-notification-status="@(notification.Type == "status" ? Post.Status?.Id ?? string.Empty : null)">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
@if (Post.GroupAccounts.Count > 1)
{
@@ -667,7 +670,25 @@
bool IsBlurred => !showFiltered && Matched.Any(r => r.Filter.FilterAction == FilterAction.Blur);
protected override void OnInitialized() => IsLoading = false;
protected override void OnInitialized()
{
IsLoading = false;
// a content warning starts open when the persona reads them so (reading:expand:spoilers)
isSpoilerOpen = CascadingState?.Preferences.ExpandSpoilers == true;
}
/// <summary>Whether a picture, video or sound waits behind a button: a blur filter's always; else as the persona's
/// reading preference says, those marked sensitive (default), none or all.</summary>
bool HidesMedia(Status status) => IsBlurred || (CascadingState?.ExpandMedia ?? ExpandMedia.Default) switch
{
ExpandMedia.ShowAll => false,
ExpandMedia.HideAll => true,
_ => status.Sensitive
};
string HiddenMediaText(Status status) => IsBlurred ? Localizer["Filtered media, show"]
: status.Sensitive ? Localizer["Sensitive media, show"]
: Localizer["Hidden media, show"];
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
@@ -677,7 +698,7 @@
int handlingEvents;
object RenderKey() => (Post, Post?.Status, Post?.Notification, Post?.Conversation, Post?.Location, Post?.GroupCount, Post?.GroupAccounts,
(FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline));
(FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline, CascadingState?.Preferences));
protected override bool ShouldRender()
{
@@ -720,12 +741,12 @@
@foreach (var media in status.MediaAttachments)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
@if (HidesMedia(status) && !revealedMedia.Contains(media.Id))
{
@* its blurhash shows behind it: the colours, nothing of what it shows *@
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash" data-media-hidden>
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@HiddenMediaText(status)</span>
</button>
}
else
@@ -1352,6 +1373,7 @@
"update" or "quoted_update" => "ion-md-create",
"quote" => "ion-md-quote",
"moderation_warning" => "ion-md-warning text-warning",
"status" => "ion-md-notifications text-primary",
_ => "ion-md-notifications"
};
+26 -1
View File
@@ -2,7 +2,8 @@
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being
read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was
read, with its week, a button to follow it (its public posts then come home) and one to feature it on the persona's
profile (public: its followers are told). The stats icon shows how the current feed was
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0">
@@ -99,6 +100,15 @@
OnClick="ToggleTagFollow" data-tag-follow>
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
</DButton>
@if (tag.Featuring is { } featuring)
{
<DButton Size="DSize.Xs" Active="featuring" Disabled="@(IsDefaultDisabled || isFollowing)" OnClick="ToggleTagFeature"
title="@Localizer["Featured hashtags are public: they show on the persona's profile, and its followers are told."]"
aria-pressed="@(featuring ? "true" : "false")" data-tag-feature="@(featuring ? "on" : "off")">
<i class="ion-md-ribbon" aria-hidden="true"></i>
<span>@(featuring ? Localizer["Featured on profile"] : Localizer["Feature on profile"])</span>
</DButton>
}
}
</div>
}
@@ -176,6 +186,21 @@
Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]);
}
async Task ToggleTagFeature()
{
isFollowing = true;
var result = await DiscoveryService.FeatureTag(tag.Name, tag.Featuring != true);
isFollowing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
tag = (Tag)result.Data;
Toast.AddResponseSuccess(tag.Featuring == true ? Localizer["#{0} is featured on this persona's profile.", tag.Name] : Localizer["#{0} is no longer featured.", tag.Name]);
}
string TabName(TimelineType type) => type switch
{
TimelineType.Federation => Localizer["Federated"],
@@ -0,0 +1,186 @@
@inherits LocalizableComponentBase
@* The hashtags the current persona features on its profile (/api/v1/featured_tags). They are public, as on Mastodon:
anyone reads them on the profile, and the persona's followers are told of each one featured or no longer (PrivaPub
sends Add and Remove of a Hashtag). Added by name, or from the hashtags the persona used lately and does not feature
(/featured_tags/suggestions); removed at once, since a hashtag can always be featured again. PrivaPub keeps as many as
its instance API says (max_featured_tags, 10) and refuses more. *@
<div class="flex flex-col gap-3 w-full" data-featured-tags>
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold">@Localizer["Featured hashtags"]</h3>
<p class="text-xs opacity-70">@Localizer["They show on this persona's profile, for anyone to read, and its followers are told of each change."]</p>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (CascadingState.PersonaId is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
@if (tags.Count == 0)
{
<p class="text-xs opacity-70" data-featured-tags-empty>@Localizer["None featured yet."]</p>
}
else
{
<ul class="flex flex-wrap gap-2 pointer-coarse:gap-2">
@foreach (var tag in tags)
{
<li class="flex items-center gap-1 ps-3 pe-1 py-1 rounded-full neo-raised neo-2xs text-sm min-w-0" data-featured-tag="@tag.Name">
<span class="truncate">#@tag.Name</span>
<span class="text-xs opacity-60 tabular-nums" title="@UsedTitle(tag)">@tag.Posts</span>
<DButton Size="DSize.Xs" Shape="DButtonShape.Circle" Variant="DButtonVariant.Ghost" Disabled="@(IsDefaultDisabled || isActing)"
OnClick="() => Remove(tag)" title="@Localizer["Stop featuring #{0}", tag.Name]" aria-label="@Localizer["Stop featuring #{0}", tag.Name]"
data-featured-tag-remove>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
</li>
}
</ul>
}
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DInput TValue="string" Size="DSize.Sm" class="flex-1 min-w-48 max-w-md" @bind-Value="newName" Immediate="true" autocomplete="off" maxlength="100"
placeholder="@Localizer["A hashtag to feature"]" aria-label="@Localizer["A hashtag to feature"]" disabled="@IsFull"
@onkeydown="AddOnEnter" data-featured-tag-new>
<Prefix><span class="opacity-60" aria-hidden="true">#</span></Prefix>
</DInput>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing || IsFull || string.IsNullOrWhiteSpace(newName))"
OnClick="AddTyped" data-featured-tag-add>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Feature"]</span>
</DButton>
@if (max > 0)
{
<span class="text-xs opacity-70 tabular-nums" data-featured-tags-count>@tags.Count/@max</span>
}
</div>
<ErrorViewer AdditionalError="@addError" />
@if (Suggested.Count > 0 && !IsFull)
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 text-sm" data-featured-tag-suggestions>
<span class="text-xs opacity-70">@Localizer["Used lately:"]</span>
@foreach (var suggestion in Suggested)
{
<button type="button" class="badge badge-soft badge-sm gap-1" disabled="@(IsDefaultDisabled || isActing)" @onclick="() => Add(suggestion.Name)"
title="@Localizer["Feature #{0}", suggestion.Name]" data-featured-tag-suggestion="@suggestion.Name">
<i class="ion-md-add" aria-hidden="true"></i><span>#@suggestion.Name</span>
</button>
}
</div>
}
}
</div>
@code {
[Inject] DiscoveryService DiscoveryService { get; set; }
[Inject] AboutService AboutService { get; set; }
List<FeaturedTag> tags = [];
List<Tag> suggestions = [];
string newName;
string addError;
// the most PrivaPub keeps (its instance API's max_featured_tags); 0 while unknown, when only PrivaPub refuses
int max;
bool isActing;
string loadedPersonaId;
bool IsFull => max > 0 && tags.Count >= max;
// the hashtags used lately that are not featured (a hashtag is the same whatever its case)
List<Tag> Suggested => [.. suggestions.Where(s => tags.All(t => !string.Equals(t.Name, s.Name, StringComparison.OrdinalIgnoreCase)))];
string UsedTitle(FeaturedTag tag) => tag.LastStatusDay is { } last
? Localizer["{0} posts with it, the last on {1}", tag.Posts, last.ToString(SUtility.dateTimeFormat)]
: Localizer["{0} posts with it", tag.Posts];
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
addError = null;
if (CascadingState.PersonaId is null)
{
tags = [];
suggestions = [];
return;
}
max = (await AboutService.Instance())?.Configuration.Accounts.MaxFeaturedTags ?? 0;
tags = await DiscoveryService.FeaturedTags();
suggestions = await DiscoveryService.FeaturedTagSuggestions();
}
async Task AddOnEnter(KeyboardEventArgs e)
{
if (e.Key != "Enter" || IsDefaultDisabled || isActing || IsFull || string.IsNullOrWhiteSpace(newName))
return;
await AddTyped();
}
async Task AddTyped()
{
if (await Add(newName))
newName = null;
}
async Task<bool> Add(string name)
{
addError = null;
isActing = true;
var result = await DiscoveryService.AddFeaturedTag(name);
isActing = false;
if (result.IsInvalid)
{
addError = result.ErrorMessage;
return false;
}
var featured = (FeaturedTag)result.Data;
if (featured is not null && tags.All(t => t.Id != featured.Id))
tags.Add(featured);
Toast.AddResponseSuccess(Localizer["#{0} is featured on this persona's profile.", featured?.Name ?? name.Trim().TrimStart('#')]);
return true;
}
async Task Remove(FeaturedTag tag)
{
addError = null;
isActing = true;
var result = await DiscoveryService.RemoveFeaturedTag(tag.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
tags.Remove(tag);
// it may be one of the hashtags used lately again
suggestions = await DiscoveryService.FeaturedTagSuggestions();
Toast.AddResponseSuccess(Localizer["#{0} is no longer featured.", tag.Name]);
}
}
@@ -0,0 +1,117 @@
@inherits LocalizableComponentBase
@* How the current persona reads what others post: Mastodon's reading preferences (reading:expand:media and
reading:expand:spoilers), which Mastodon's API only reads (/api/v1/preferences). PrivaPub keeps them for each persona
and sets them through /clientapi/avatar/private/preferences, so other apps signed in as the persona read them too. The
cards here follow them as soon as they are saved (CascadingState.Preferences): which media wait behind a button, and
whether a content warning starts open. *@
<div class="flex flex-col gap-4 w-full" data-reading-preferences>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (CascadingState.PersonaId is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
<label class="flex flex-col gap-1 max-w-md">
<span class="text-sm">@Localizer["Pictures, videos and sounds"]</span>
<DSelect TValue="ExpandMedia" class="w-full" @bind-Value="expandMedia" data-preference-expand-media>
<option value="@ExpandMedia.Default">@Localizer["Hide those marked sensitive"]</option>
<option value="@ExpandMedia.ShowAll">@Localizer["Show all of them"]</option>
<option value="@ExpandMedia.HideAll">@Localizer["Hide all of them"]</option>
</DSelect>
</label>
<DToggle LabelText="@Localizer["Open content warnings"]" @bind-Value="expandSpoilers" data-preference-expand-spoilers />
<p class="text-xs opacity-70">@Localizer["Kept for this persona on the server: other apps signed in as it read them too."]</p>
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-preferences-reset>@Localizer["Reset"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="Save" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-preferences-save>
@if (isSaving)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
}
</div>
@code {
[Inject] AuthService AuthService { get; set; }
// what PrivaPub keeps, and the choices being made
ViewPersonaPreferences saved = new();
ExpandMedia expandMedia = ExpandMedia.Default;
bool expandSpoilers;
bool isSaving;
string loadedPersonaId;
bool IsChanged => expandMedia != ExpandMediaModes.Of(saved.ExpandMedia) || expandSpoilers != saved.ExpandSpoilers;
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
saved = CascadingState.Preferences;
Reset();
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
// read again: another device may have changed them since the persona was chosen here
async Task LoadData()
{
if (CascadingState.PersonaId is null)
return;
var read = await AuthService.PersonaPreferences(CascadingState.PersonaId);
saved = read ?? CascadingState.Preferences;
if (read is not null && (read.ExpandMedia != CascadingState.Preferences.ExpandMedia || read.ExpandSpoilers != CascadingState.Preferences.ExpandSpoilers))
CascadingState.PreferencesChanged(read);
Reset();
}
void Reset()
{
expandMedia = ExpandMediaModes.Of(saved.ExpandMedia);
expandSpoilers = saved.ExpandSpoilers;
}
async Task Save()
{
isSaving = true;
var result = await AuthService.SavePersonaPreferences(new PersonaPreferencesForm
{
AvatarId = CascadingState.PersonaId,
ExpandMedia = ExpandMediaModes.Name(expandMedia),
ExpandSpoilers = expandSpoilers
});
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
saved = (ViewPersonaPreferences)result.Data ?? saved;
CascadingState.PreferencesChanged(saved);
Reset();
Toast.AddResponseSuccess(Localizer["Reading preferences saved."]);
}
}