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

+221 -1
View File
@@ -8,7 +8,12 @@
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows
(AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow
requests waiting for it show above its posts. *@
requests waiting for it show above its posts. The hashtags an account features are chips opening their feed (the
persona's own link to Settings, where they are edited). Under another account's header is what the persona keeps of
it for itself alone, which the account is never told: for one it follows, whether it is told of its new posts (a
`status` notification), which languages of its posts reach the home feed, and whether it is in the persona's
recommendations (endorsements, private here); for any account, a private note, saved when it loses focus or with
Save. *@
<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">
@@ -151,6 +156,26 @@
</dl>
}
@if (featuredTags.Count > 0 || IsOwn)
{
<div class="flex flex-wrap items-center gap-2 px-4 pt-3 text-sm" role="group" aria-label="@Localizer["Featured hashtags"]" data-profile-featured-tags>
@foreach (var featured in featuredTags)
{
<button type="button" class="badge badge-soft badge-sm gap-1" title="@FeaturedTitle(featured)" @onclick="() => OpenTag(featured.Name)"
data-profile-featured-tag="@featured.Name">
<span>#@featured.Name</span><span class="opacity-60 tabular-nums">@featured.Posts</span>
</button>
}
@if (IsOwn)
{
<a class="link link-hover text-xs touch-target" href="settings?open=profile" data-profile-featured-tags-edit>
<i class="ion-md-create" aria-hidden="true"></i>
@(featuredTags.Count == 0 ? Localizer["Feature hashtags on this profile"] : Localizer["Edit featured hashtags"])
</a>
}
</div>
}
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
@if (IsOwn)
@@ -170,6 +195,80 @@
</div>
</article>
@if (!IsOwn && relationship is { } related)
{
<section class="flex flex-none flex-col gap-3 p-3 md:p-4 rounded-xl neo-inset neo-2xs" aria-label="@Localizer["Only for this persona"]" data-profile-private>
<p class="flex items-center gap-2 text-xs opacity-80">
<i class="ion-md-lock" aria-hidden="true"></i>
<span>@Localizer["Only this persona sees what is here: {0} is never told.", account.Name]</span>
</p>
@if (related.Following)
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Active="related.Notifying" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleNotify"
aria-pressed="@(related.Notifying ? "true" : "false")" data-profile-notify="@(related.Notifying ? "on" : "off")">
<i class="@(related.Notifying ? "ion-md-notifications" : "ion-md-notifications-off")" aria-hidden="true"></i>
<span>@(related.Notifying ? Localizer["Notified of new posts"] : Localizer["Notify me of new posts"])</span>
</DButton>
<DDropdown IsOpen="isLanguagesOpen" IsOpenChanged="LanguagesOpened" TriggerClass="btn btn-sm" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-80 max-w-[calc(100vw-2rem)] bg-base-100 neo-raised neo-xs z-20"
title="@Localizer["Languages of its posts in your home feed"]" data-profile-languages="@(string.Join(",", related.WantedLanguages))">
<Trigger>
<i class="ion-md-globe" aria-hidden="true"></i><span class="truncate max-w-48">@LanguagesLabel</span>
</Trigger>
<ChildContent>
<p class="text-xs opacity-70">@Localizer["Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language."]</p>
<div class="grid grid-cols-2 gap-x-2 gap-y-1 max-h-64 overflow-y-auto">
@foreach (var (languageCode, languageName) in LanguageChoices)
{
<DCheckbox Size="DSize.Sm" LabelText="@languageName" Value="@wantedLanguages.Contains(languageCode)"
ValueChanged="on => TickLanguage(languageCode, on)" ValueExpression="() => languageFlag" data-profile-language="@languageCode" />
}
</div>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="EveryLanguage" data-profile-languages-all>@Localizer["Every language"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="SaveLanguages" data-profile-languages-save>
@Localizer["Save"]
</DButton>
</div>
</ChildContent>
</DDropdown>
<DButton Size="DSize.Sm" Active="related.Endorsed" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleEndorse"
title="@(related.Endorsed ? Localizer["Take {0} out of my recommendations", account.Name] : Localizer["Keep {0} in my recommendations", account.Name])"
aria-pressed="@(related.Endorsed ? "true" : "false")" data-profile-endorse="@(related.Endorsed ? "on" : "off")">
<i class="ion-md-ribbon" aria-hidden="true"></i>
<span>@(related.Endorsed ? Localizer["Recommended"] : Localizer["Recommend"])</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else."]</p>
}
@* the note is saved as it loses focus (focusout bubbles from the textarea), or with Save *@
<div class="flex flex-col gap-1" @onfocusout="SaveNote">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Private note"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="noteDraft" Immediate="true" rows="3" autocomplete="off"
maxlength="@AccountService.NoteLength" placeholder="@Localizer["Something to remember about {0}", account.Name]" data-profile-note />
</label>
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (noteSaved && !NoteChanged)
{
<span class="me-auto text-xs text-success" role="status" data-profile-note-saved>
<i class="ion-md-checkmark" aria-hidden="true"></i> @Localizer["Saved."]
</span>
}
<span class="text-xs opacity-70 tabular-nums">@(noteDraft?.Length ?? 0)/@AccountService.NoteLength</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSavingNote || !NoteChanged)" OnClick="SaveNote" data-profile-note-save>
@if (isSavingNote)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</div>
</section>
}
@if (IsOwn)
{
@if (showAccounts is { } followers)
@@ -246,6 +345,32 @@
bool? showAccounts;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
List<FeaturedTag> featuredTags = [];
bool isLanguagesOpen;
// the languages ticked while the languages menu is open, saved together
HashSet<string> wantedLanguages = [];
// DCheckbox's ValueExpression for the languages, which are bound one by one (never read)
bool languageFlag = false;
// the private note as it is being written; relationship.Note is what PrivaPub keeps
string noteDraft;
bool isSavingNote;
bool noteSaved;
bool NoteChanged => (noteDraft?.Trim() ?? string.Empty) != (relationship?.Note ?? string.Empty);
string LanguagesLabel => relationship.WantedLanguages.Count == 0
? Localizer["Every language"]
: string.Join(", ", relationship.WantedLanguages.Select(LanguageName));
// the languages a post can be marked with, and any other the follow keeps
IEnumerable<(string Code, string Name)> LanguageChoices =>
Languages.All.Concat(relationship.WantedLanguages.Where(code => Languages.All.All(l => l.Code != code)).Select(code => (code, code)));
static string LanguageName(string code) => Languages.All.Where(l => l.Code == code).Select(l => l.Name).FirstOrDefault() ?? code;
string FeaturedTitle(FeaturedTag featured) => featured.LastStatusDay is { } last
? Localizer["{0} posts with it, the last on {1}", featured.Posts, last.ToString(SUtility.dateTimeFormat)]
: Localizer["{0} posts with it", featured.Posts];
bool IsOwn => account is not null && account.Id == CascadingState.PersonaId;
@@ -288,6 +413,9 @@
return;
}
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
noteDraft = relationship?.Note;
noteSaved = false;
featuredTags = await AccountService.FeaturedTags(account.Id);
await LoadLists();
pinned = await AccountService.Pinned(account.Id);
pinnedKeys = [.. pinned.Select(p => p.Key)];
@@ -391,6 +519,98 @@
});
}
async Task ToggleNotify()
{
isActing = true;
var result = await AccountService.SetNotify(account.Id, relationship.ShowingReblogs, !relationship.Notifying);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
Toast.AddResponseSuccess(relationship.Notifying ? Localizer["You'll be told when {0} posts.", account.Name] : Localizer["No longer told when {0} posts.", account.Name]);
}
// the menu opens on the languages the follow keeps
void LanguagesOpened(bool open)
{
isLanguagesOpen = open;
if (open)
wantedLanguages = [.. relationship.WantedLanguages];
}
void TickLanguage(string code, bool on)
{
if (on)
wantedLanguages.Add(code);
else
wantedLanguages.Remove(code);
}
Task SaveLanguages() => SetLanguages(wantedLanguages);
Task EveryLanguage() => SetLanguages([]);
async Task SetLanguages(IReadOnlyCollection<string> languages)
{
isActing = true;
var result = await AccountService.SetLanguages(account.Id, relationship.ShowingReblogs, languages);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
isLanguagesOpen = false;
Toast.AddResponseSuccess(relationship.WantedLanguages.Count == 0
? Localizer["Every post of {0} reaches your home feed, whatever its language.", account.Name]
: Localizer["Only the posts of {0} in {1} reach your home feed.", account.Name, LanguagesLabel]);
}
async Task ToggleEndorse()
{
isActing = true;
var result = await AccountService.Endorse(account.Id, !relationship.Endorsed);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
Toast.AddResponseSuccess(relationship.Endorsed ? Localizer["{0} is in your recommendations, which only you see.", account.Name]
: Localizer["{0} is no longer in your recommendations.", account.Name]);
}
// as the note loses focus too: only a note changed is sent, one at a time
async Task SaveNote()
{
if (isSavingNote || relationship is null || !NoteChanged)
return;
var sent = noteDraft;
isSavingNote = true;
var result = await AccountService.SetNote(account.Id, sent);
isSavingNote = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
// what was written meanwhile stays as it is
if (noteDraft == sent)
noteDraft = relationship.Note;
noteSaved = true;
}
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
void Remove(FeedPost post)