Files
decePubClient/Pages/Explore.razor
T
thepraandClaude Opus 5.5 dd38002682 Feed tabs say what they are
Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes
an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every
tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and
on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one);
the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:31:07 +02:00

284 lines
9.5 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, 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-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 min-w-0 flex-1 text-left" @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.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,
Followed
}
[Inject] DiscoveryService DiscoveryService { 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 = [];
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;
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]);
}
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("");
}
}