Files
decePubClient/Pages/Explore.razor
T
thepraandClaude Opus 5.5 d33697e3fc 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
2026-10-18 08:58:30 +02:00

385 lines
14 KiB
Plaintext

@page "/explore"
@attribute [Authorize]
@inherits LocalizableComponentBase
<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, 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">
<div class="flex items-center gap-2">
<i class="ion-md-compass text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
</div>
<div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
aria-label="@Localizer["Explore"]">
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
<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>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (tab is ExploreTab.Hashtags or ExploreTab.Followed)
{
@if (tags.Count == 0)
{
<p class="w-full text-center" data-feed-empty>
<i class="ion-ios-remove-circle-outline"></i> @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."])
</p>
}
<div class="flex flex-col gap-2">
@foreach (var item in tags)
{
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
<button type="button" class="flex flex-col items-start justify-center min-w-0 flex-1 text-left pointer-coarse:min-h-11" @onclick="() => OpenTag(item.Name)">
<b class="truncate">#@item.Name</b>
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
</button>
<span class="flex items-end gap-px h-6" aria-hidden="true">
@foreach (var day in Enumerable.Reverse(item.History))
{
<span class="w-1.5 rounded-sm bg-primary/60" style="@($"height:{Bar(day.UsesCount, item)}%")"></span>
}
</span>
<DButton Size="DSize.Xs" Color="@(item.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isActing)"
OnClick="@(() => ToggleFollow(item))">
<span>@(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"])</span>
</DButton>
</div>
}
</div>
}
else if (tab is ExploreTab.Posts)
{
@if (posts.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
}
<div class="flex flex-col gap-4">
@foreach (var post in posts)
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Public" OnTag="OpenTag" />
}
</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)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var link in links)
{
<a class="flex gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@link.Url" target="_blank" rel="noopener noreferrer" data-link="@link.Url">
@if (link.Image is { Length: > 0 } image)
{
@* alt="": the title is next to it. *@
<img alt="" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-lg" src="@image" />
}
<span class="flex flex-col min-w-0 gap-1">
<b class="text-sm break-words">@link.Title</b>
@if (link.Description is { Length: > 0 } description)
{
<span class="text-xs opacity-80 line-clamp-2">@description</span>
}
<span class="text-xs opacity-60">@link.ProviderName · @Localizer["{0} people this week", link.WeekAccounts]</span>
</span>
</a>
}
</div>
}
else
{
<div class="flex flex-wrap items-center gap-3">
<DSelect TValue="DirectoryOrder" Size="DSize.Sm" class="w-auto" Value="order" ValueChanged="ChangeOrder" ValueExpression="() => order">
<option value="@DirectoryOrder.Active">@Localizer["Recently active"]</option>
<option value="@DirectoryOrder.New">@Localizer["Newcomers"]</option>
</DSelect>
<label class="label text-sm">
<input type="checkbox" class="checkbox checkbox-sm" checked="@localOnly" @onchange="ToggleLocal" />
<span>@Localizer["Only this server"]</span>
</label>
</div>
@if (people.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
}
<div class="flex flex-col gap-2">
@foreach (var account in people)
{
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Id">
@* alt="": the name is next to it. *@
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@account.Name</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
@if (morePeople)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isActing)" OnClick="LoadMorePeople">@Localizer["Load more"]</DButton>
}
</div>
}
</div>
</section>
@code {
ElementReference strip;
int revealed = -1;
protected override void OnAfterRender(bool firstRender)
{
if ((int)tab == revealed)
return;
revealed = (int)tab;
CascadingState.Status.RevealActive(strip);
}
enum ExploreTab
{
Hashtags,
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; }
ExploreTab tab = ExploreTab.Hashtags;
List<Tag> tags = [];
List<FeedPost> posts = [];
List<TrendLink> links = [];
List<Account> people = [];
List<Suggestion> suggestions = [];
DirectoryOrder order = DirectoryOrder.Active;
bool localOnly;
bool morePeople;
bool isActing;
string loadedPersonaId;
static int Bar(int uses, Tag tag) => tag.History.Max(h => h.UsesCount) is > 0 and var most ? Math.Max(8, uses * 100 / most) : 8;
// The persona changed (NavMenu's switcher): what it follows is another persona's.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
}
async Task LoadData()
{
IsLoading = true;
switch (tab)
{
case ExploreTab.Hashtags:
tags = await DiscoveryService.TrendingTags();
break;
case ExploreTab.Followed:
tags = await DiscoveryService.FollowedTags();
break;
case ExploreTab.Posts:
posts = [.. (await DiscoveryService.TrendingStatuses()).Select(FeedPost.Of)];
await InstanceService.Resolve(posts.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(posts);
break;
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;
break;
}
IsLoading = false;
}
async Task SelectTab(int index)
{
tab = (ExploreTab)index;
await LoadData();
}
async Task ChangeOrder(DirectoryOrder value)
{
order = value;
await LoadData();
}
async Task ToggleLocal(ChangeEventArgs e)
{
localOnly = e.Value is true;
await LoadData();
}
async Task LoadMorePeople()
{
isActing = true;
var known = people.Select(a => a.Id).ToHashSet();
var page = await DiscoveryService.Directory(order, localOnly, people.Count);
isActing = false;
people.AddRange(page.Where(a => known.Add(a.Id)));
morePeople = page.Count == DiscoveryService.DirectoryPage;
}
async Task ToggleFollow(Tag item)
{
isActing = true;
var result = await DiscoveryService.FollowTag(item.Name, item.Following != true);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
item.Following = ((Tag)result.Data).Following;
if (tab is ExploreTab.Followed && item.Following != true)
tags.Remove(item);
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();
filter.Tag = tagName;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
}