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

+103 -2
View File
@@ -4,8 +4,10 @@
<Title>@Localizer["Explore"]</Title>
@* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already
holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the
hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@
holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, who to follow
(PrivaPub's suggestions, each with why: picked by the staff, past interactions, popular here; followed or dismissed
from here, a dismissal for good, so asked first), and the hashtags the persona follows. A hashtag opens as a tab of
the feed; a link goes out to its site. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -21,6 +23,7 @@
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
<DTab><Header><i class="ion-md-person-add"></i> @Localizer["Who to follow"]</Header></DTab>
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
</DTabs>
</div>
@@ -75,6 +78,41 @@
}
</div>
}
else if (tab is ExploreTab.Suggestions)
{
@if (suggestions.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["No one to suggest yet."]</p>
}
<div class="flex flex-col gap-2" data-suggestions>
@foreach (var suggestion in suggestions)
{
<div class="flex flex-wrap items-center gap-3 pointer-coarse:gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0"
data-suggestion="@suggestion.Account.Acct" data-suggestion-source="@suggestion.Source">
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(suggestion.Account.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(suggestion.Account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(suggestion.Account.Name, suggestion.Account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + suggestion.Account.Acct)</span>
</span>
</a>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" title="@WhyText(suggestion)">@SourceName(suggestion.Source)</DBadge>
<div class="flex gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => FollowSuggested(suggestion)"
data-suggestion-follow>
<i class="ion-md-person-add" aria-hidden="true"></i><span>@Localizer["Follow"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Dismiss(suggestion)"
title="@Localizer["Never suggest {0} again", suggestion.Account.Name]" aria-label="@Localizer["Never suggest {0} again", suggestion.Account.Name]"
data-suggestion-dismiss>
<i class="ion-md-close" aria-hidden="true"></i><span class="max-sm:hidden">@Localizer["Dismiss"]</span>
</DButton>
</div>
</div>
}
</div>
}
else if (tab is ExploreTab.Links)
{
@if (links.Count == 0)
@@ -157,10 +195,13 @@
Posts,
Links,
People,
Suggestions,
Followed
}
[Inject] DiscoveryService DiscoveryService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
@@ -169,6 +210,7 @@
List<FeedPost> posts = [];
List<TrendLink> links = [];
List<Account> people = [];
List<Suggestion> suggestions = [];
DirectoryOrder order = DirectoryOrder.Active;
bool localOnly;
bool morePeople;
@@ -219,6 +261,9 @@
case ExploreTab.Links:
links = await DiscoveryService.TrendingLinks();
break;
case ExploreTab.Suggestions:
suggestions = [.. (await DiscoveryService.Suggestions()).Where(s => s.Account is not null)];
break;
default:
people = await DiscoveryService.Directory(order, localOnly, 0);
morePeople = people.Count == DiscoveryService.DirectoryPage;
@@ -272,6 +317,62 @@
Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]);
}
// why PrivaPub suggests an account: its v2 source
string SourceName(string source) => source switch
{
"staff" => Localizer["Picked by the staff"],
"past_interactions" => Localizer["You interacted with them"],
"global" => Localizer["Popular here"],
_ => source
};
// every reason PrivaPub has for it (its sources)
string WhyText(Suggestion suggestion) => string.Join(", ", suggestion.Sources.Select(source => source switch
{
"featured" => Localizer["picked by this server's staff"],
"most_followed" => Localizer["followed by many here"],
"most_interactions" => Localizer["much favourited and boosted here lately"],
"past_interactions" => Localizer["you favourited, boosted or answered them lately"],
_ => source
}));
async Task FollowSuggested(Suggestion suggestion)
{
isActing = true;
var result = await AccountService.Act(suggestion.Account.Id, AccountAction.Follow);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
suggestions.Remove(suggestion);
Toast.AddResponseSuccess(((Relationship)result.Data)?.Requested == true
? Localizer["Asked {0} to accept you.", suggestion.Account.Name]
: Localizer["Following {0}.", suggestion.Account.Name]);
}
// for good: PrivaPub never suggests it to this persona again, so it is asked first
async Task Dismiss(Suggestion suggestion)
{
if (!await Confirm.Ask(Localizer["Never suggest {0} again?", suggestion.Account.Name],
Localizer["There is no undoing it, but {0} can still be followed from its profile.", suggestion.Account.Name], Localizer["Dismiss"], false))
return;
isActing = true;
var result = await DiscoveryService.DismissSuggestion(suggestion.Account.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
suggestions.Remove(suggestion);
Toast.AddResponseSuccess(Localizer["{0} is no longer suggested.", suggestion.Account.Name]);
}
async Task OpenTag(string tagName)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();