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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user