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

+15 -1
View File
@@ -6,7 +6,8 @@
@* Moderators handle reports, moderate accounts here and elsewhere (Accounts) and read the staff's log. Administrators
also change the logins and their personas (Users & personas), say what the server is (Instance), set its rules, write
its announcements, add its custom emoji, block servers for everyone, choose its relays and media limits, read the
its announcements, add its custom emoji, pick the accounts suggested to every persona (Staff picks), block servers for
everyone, choose its relays and media limits, read the
federation statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. Each section is
a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -118,6 +119,19 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="staff-picks">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-star text-xl"></i> <span>@Localizer["Staff picks"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<StaffPicksPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header>
<p class="inline-flex items-center gap-2">
+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();
+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))
+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)
+42 -3
View File
@@ -107,9 +107,10 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
@* opened by a link to it (settings?open=profile, from the persona's own profile) *@
<DCollapse Icon="DCollapseIcon.Arrow" @bind-IsOpen="isProfileOpen">
<Header>
<p class="inline-flex items-center gap-2">
<p class="inline-flex items-center gap-2" data-settings-profile>
<i class="ion-md-person text-xl"></i> <span>@Localizer["Profile"]</span>
</p>
</Header>
@@ -117,7 +118,30 @@
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<ProfileEditor />
<div class="flex flex-col gap-6">
<ProfileEditor />
<FeaturedTagsEditor />
</div>
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-reading>
<i class="ion-md-eye text-xl"></i> <span>@Localizer["Reading"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<ReadingPreferencesEditor />
}
else
{
@@ -196,6 +220,21 @@
@code {
/// <summary>A section to open as the page opens: profile (the persona's profile and featured hashtags).</summary>
[SupplyParameterFromQuery(Name = "open")] public string Open { get; set; }
bool isProfileOpen;
string openedFor;
protected override void OnParametersSet()
{
if (Open == openedFor)
return;
openedFor = Open;
if (Open == "profile")
isProfileOpen = true;
}
bool IsDark => CascadingState.Status.IsDarkTheme();
bool IsGray => CascadingState.PublicCacheData.PageSettings.IsGray();