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

@@ -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."]);
}
}