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

+21 -5
View File
@@ -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))