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:
1 parent
566b0e33ea
commit
d33697e3fc
28 files changed
+1784
-52
No files matched your search
+221
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user