271 lines
9.2 KiB
Plaintext
271 lines
9.2 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 class="overflow-x-auto">
|
||
|
|
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||
|
|
<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 {
|
||
|
|
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("");
|
||
|
|
}
|
||
|
|
}
|