Posts for later, hashtags followed, and Explore

The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 03:53:51 +02:00
1 parent 9a752d9288
commit 6d5cf17723
17 files changed
+1001 -7

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
@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("");
}
}
+144
View File
@@ -0,0 +1,144 @@
@page "/scheduled"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Scheduled posts"]</Title>
@* The current persona's posts waiting for their time (/api/v1/scheduled_statuses): moved to another time, or dropped so
they are never published. The composer schedules them. *@
<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-time text-lg"></i>
<h1 class="font-bold truncate">@Localizer["Scheduled posts"]</h1>
</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 (scheduled.Count == 0)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is waiting to be published."]</p>
}
<div class="flex flex-col gap-3">
@foreach (var item in scheduled)
{
<article class="flex flex-col gap-2 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-scheduled-id="@item.Id">
<div class="flex flex-wrap items-center gap-2 text-sm">
<i class="ion-md-time"></i>
<b>@item.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<span class="opacity-70">@VisibilityName(item.Params.Visibility)</span>
@if (item.MediaAttachments.Count > 0)
{
<span class="opacity-70"><i class="ion-md-images"></i> @item.MediaAttachments.Count</span>
}
</div>
@if (item.Params.SpoilerText is { Length: > 0 } warning)
{
<p class="text-sm"><i class="ion-md-warning text-warning"></i> @warning</p>
}
<p class="text-sm whitespace-pre-wrap break-words">@item.Params.Text</p>
<div class="flex flex-wrap items-center gap-2">
<input type="datetime-local" class="input input-sm w-auto" autocomplete="off" aria-label="@Localizer["Publish it on"]"
value="@(moves.GetValueOrDefault(item.Id) ?? item.ScheduledAt.ToLocalTime().ToString("yyyy-MM-ddTHH:mm"))"
@onchange="@((ChangeEventArgs e) => moves[item.Id] = e.Value?.ToString())" />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving || !moves.ContainsKey(item.Id))" OnClick="@(() => Move(item))">
<i class="ion-md-checkmark"></i><span>@Localizer["Move"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Drop(item))">
<i class="ion-md-trash text-error"></i><span>@Localizer["Never publish it"]</span>
</DButton>
</div>
</article>
}
</div>
</div>
</section>
@code {
[Inject] StatusService StatusService { get; set; }
List<ScheduledStatus> scheduled = [];
readonly Dictionary<string, string> moves = [];
string loadedPersonaId;
bool isSaving;
string VisibilityName(string visibility) => visibility switch
{
"unlisted" => Localizer["Unlisted"],
"private" => Localizer["Followers only"],
"direct" => Localizer["Direct"],
_ => Localizer["Public"]
};
// The persona changed (NavMenu's switcher): its scheduled posts are 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;
moves.Clear();
scheduled = await StatusService.Scheduled();
IsLoading = false;
}
async Task Move(ScheduledStatus item)
{
if (!DateTime.TryParse(moves[item.Id], System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var local))
return;
isSaving = true;
var result = await StatusService.Reschedule(item.Id, local.ToUniversalTime());
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var moved = (ScheduledStatus)result.Data;
moves.Remove(item.Id);
item.ScheduledAt = moved.ScheduledAt;
scheduled = [.. scheduled.OrderBy(s => s.ScheduledAt)];
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", moved.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
}
async Task Drop(ScheduledStatus item)
{
isSaving = true;
var result = await StatusService.Unschedule(item.Id);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
scheduled.Remove(item);
Toast.AddResponseSuccess(Localizer["It will not be published."]);
}
}