A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a replies policy and made exclusive on /lists, filled from the accounts it follows (accounts/search?following=true), and read as the feed's Lists tab. A followed account's profile has a Lists menu to put it in or take it out. ListService does the calls; the e2e tests check each step against PrivaPub's own answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
431 lines
14 KiB
Plaintext
431 lines
14 KiB
Plaintext
@page "/"
|
|
@page "/home"
|
|
@page "/index"
|
|
@attribute [Authorize]
|
|
@inherits LocalizableComponentBase
|
|
<Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
|
|
|
|
@* 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 are polled every
|
|
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
|
|
<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">
|
|
|
|
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
|
|
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
|
|
</aside>
|
|
|
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
|
OnChanged="ChangeFeed" />
|
|
|
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
|
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
|
|
<div class="flex flex-col gap-4">
|
|
|
|
@if (isComposing)
|
|
{
|
|
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
|
</div>
|
|
}
|
|
|
|
@if (pending.Count > 0)
|
|
{
|
|
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
|
|
<i class="ion-md-arrow-up"></i>
|
|
<span>@Localizer["{0} new", pending.Count]</span>
|
|
</DButton>
|
|
}
|
|
|
|
@if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
|
{
|
|
<div class="flex flex-col items-center gap-2 text-sm text-center">
|
|
<p>@Localizer["Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only."]</p>
|
|
@if (positionError is { Length: > 0 })
|
|
{
|
|
<p class="text-error">@positionError</p>
|
|
}
|
|
<DButton Size="DSize.Sm" OnClick="AskPosition" Disabled="IsDefaultDisabled">
|
|
<i class="ion-md-locate"></i><span>@Localizer["Use my position"]</span>
|
|
</DButton>
|
|
</div>
|
|
}
|
|
else 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 (loadError is { Length: > 0 })
|
|
{
|
|
<DAlert Color="DAlertColor.Error" Variant="DAlertVariant.Soft" class="text-sm">
|
|
<span>@loadError</span>
|
|
<DButton Size="DSize.Sm" OnClick="Reload">@Localizer["Retry"]</DButton>
|
|
</DAlert>
|
|
}
|
|
else if (posts.Count == 0)
|
|
{
|
|
<p class="w-full text-center text-lg" data-feed-empty>
|
|
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
</p>
|
|
}
|
|
|
|
@foreach (var post in posts)
|
|
{
|
|
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
|
}
|
|
|
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
|
@if (isLoadingMore)
|
|
{
|
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
|
}
|
|
else if (!IsLoading && posts.Count > 0 && maxId is null)
|
|
{
|
|
<span>@Localizer["That's all."]</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
@code {
|
|
const string ScrollId = "feed-scroll";
|
|
const string SentinelId = "feed-end";
|
|
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
|
|
|
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
|
[Inject] IJSRuntime JS { get; set; }
|
|
[Inject] TimelineService TimelineService { get; set; }
|
|
[Inject] GroupService GroupService { get; set; }
|
|
[Inject] ListService ListService { get; set; }
|
|
[Inject] InstanceService InstanceService { get; set; }
|
|
[Inject] RequestStats RequestStats { get; set; }
|
|
|
|
FeedFilter Filter { get; set; } = new();
|
|
List<FeedPost> posts = [];
|
|
List<FeedPost> pending = [];
|
|
List<ViewGroup> groups = [];
|
|
List<MastodonList> lists = [];
|
|
string maxId;
|
|
string minId;
|
|
string loadError;
|
|
bool isLoadingMore;
|
|
bool isComposing;
|
|
(double Latitude, double Longitude)? position;
|
|
string positionError;
|
|
string loadedPersonaId;
|
|
FeedQuery lastQuery;
|
|
DateTime? lastPollAt;
|
|
TimeSpan? lastLoadTook;
|
|
|
|
GlobePanel globe;
|
|
IJSObjectReference feedModule;
|
|
DotNetObjectReference<Index> reference;
|
|
PeriodicTimer pollTimer;
|
|
CancellationTokenSource pageCancellation = new();
|
|
CancellationTokenSource loadCancellation;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
|
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
|
Filter.TimelineType = TimelineType.Home;
|
|
|
|
if (CascadingState.IsOffline)
|
|
{
|
|
WhenOnline(Reload);
|
|
StartOnlinePolling();
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
if (CascadingState.PersonaId is null)
|
|
{
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
await Reload();
|
|
}
|
|
|
|
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
|
|
return;
|
|
await Reload();
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
if (!firstRender)
|
|
return;
|
|
|
|
reference = DotNetObjectReference.Create(this);
|
|
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
|
|
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
|
|
pollTimer = new(PollInterval);
|
|
_ = PollNewer(pageCancellation.Token);
|
|
}
|
|
|
|
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
|
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
|
|
|
|
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
|
async Task Reload()
|
|
{
|
|
loadCancellation?.Cancel();
|
|
loadCancellation = new();
|
|
var cancellationToken = loadCancellation.Token;
|
|
|
|
IsLoading = true;
|
|
loadError = default;
|
|
pending = [];
|
|
posts = [];
|
|
maxId = default;
|
|
minId = default;
|
|
StateHasChanged();
|
|
|
|
if (CascadingState.PersonaId != loadedPersonaId)
|
|
{
|
|
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
|
lists = await ListService.All(cancellationToken);
|
|
loadedPersonaId = CascadingState.PersonaId;
|
|
}
|
|
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
|
Filter.GroupId = groups.FirstOrDefault()?.Id;
|
|
if (Filter.TimelineType is TimelineType.Lists && lists.All(l => l.Id != Filter.ListId))
|
|
Filter.ListId = lists.FirstOrDefault()?.Id;
|
|
lastQuery = Query;
|
|
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
|
{
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
var started = DateTime.UtcNow;
|
|
var result = await TimelineService.Load(Query, cancellationToken: cancellationToken);
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
lastLoadTook = DateTime.UtcNow - started;
|
|
IsLoading = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
if (result.StatusCode is not 410)
|
|
loadError = result.ErrorMessage;
|
|
return;
|
|
}
|
|
|
|
var page = (FeedPage)result.Data;
|
|
posts = page.Posts;
|
|
maxId = page.MaxId;
|
|
minId = page.MinId;
|
|
lastPollAt = DateTime.UtcNow;
|
|
StateHasChanged();
|
|
await Locate(posts, cancellationToken);
|
|
if (feedModule is not null)
|
|
await feedModule.InvokeVoidAsync("checkEnd");
|
|
}
|
|
|
|
/// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
|
|
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
|
|
{
|
|
await InstanceService.Resolve(located.Select(p => p.Host).Append(InstanceService.SelfHost), cancellationToken);
|
|
InstanceService.Locate(located, position);
|
|
StateHasChanged();
|
|
}
|
|
|
|
[JSInvokable]
|
|
public async Task NearEnd()
|
|
{
|
|
if (IsLoading || isLoadingMore || maxId is null)
|
|
return;
|
|
|
|
isLoadingMore = true;
|
|
StateHasChanged();
|
|
var cancellationToken = loadCancellation?.Token ?? default;
|
|
var result = await TimelineService.Load(Query, maxId: maxId, cancellationToken: cancellationToken);
|
|
isLoadingMore = false;
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
StateHasChanged();
|
|
return;
|
|
}
|
|
|
|
var page = (FeedPage)result.Data;
|
|
var known = posts.Select(p => p.Key).ToHashSet();
|
|
var older = page.Posts.Where(p => known.Add(p.Key)).ToList();
|
|
posts.AddRange(older);
|
|
maxId = older.Count == 0 ? null : page.MaxId;
|
|
StateHasChanged();
|
|
await Locate(older, cancellationToken);
|
|
if (feedModule is not null)
|
|
await feedModule.InvokeVoidAsync("checkEnd");
|
|
}
|
|
|
|
/// <summary>Asks for posts newer than the newest shown, while the page is visible; they wait behind the "new" button.</summary>
|
|
async Task PollNewer(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
|
{
|
|
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|
|
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
|
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
|
continue;
|
|
|
|
var query = Query;
|
|
var result = await TimelineService.Load(query, minId: pending.FirstOrDefault()?.CursorId ?? minId, cancellationToken: cancellationToken);
|
|
lastPollAt = DateTime.UtcNow;
|
|
if (result.IsInvalid || query != Query)
|
|
continue;
|
|
|
|
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
|
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
|
|
if (newer.Count == 0)
|
|
continue;
|
|
|
|
pending.InsertRange(0, newer);
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await CascadingState.ProcessError(ex, $"{nameof(Index)}.{nameof(PollNewer)}", cancellationToken);
|
|
}
|
|
}
|
|
|
|
async Task ShowPending()
|
|
{
|
|
var shown = pending;
|
|
pending = [];
|
|
posts.InsertRange(0, shown);
|
|
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
|
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
|
await Locate(shown, loadCancellation?.Token ?? default);
|
|
}
|
|
|
|
/// <summary>Saves the choices; reloads only when they change what the feed shows (not for the globe's toggle).</summary>
|
|
async Task ChangeFeed(FeedFilter filter)
|
|
{
|
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
|
|
if (Query == lastQuery)
|
|
return;
|
|
await Reload();
|
|
}
|
|
|
|
async Task OpenTag(string tag)
|
|
{
|
|
Filter.Tag = tag;
|
|
Filter.TimelineType = TimelineType.Tag;
|
|
await ChangeFeed(Filter);
|
|
}
|
|
|
|
async Task AskPosition()
|
|
{
|
|
positionError = default;
|
|
var answer = await JS.InvokeAsync<GeoAnswer>("feedPosition");
|
|
if (answer?.Latitude is not { } latitude || answer.Longitude is not { } longitude)
|
|
{
|
|
positionError = Localizer["This device did not give its position: {0}", answer?.Error ?? "?"];
|
|
return;
|
|
}
|
|
|
|
position = (latitude, longitude);
|
|
await Reload();
|
|
}
|
|
|
|
async Task Published(FeedPost post)
|
|
{
|
|
isComposing = false;
|
|
if (post is null)
|
|
{
|
|
if (Filter.TimelineType is TimelineType.Nearby or TimelineType.Groups)
|
|
await Reload();
|
|
return;
|
|
}
|
|
|
|
if (Filter.TimelineType is TimelineType.Home or TimelineType.Local || post.Status?.Visibility == "direct" && Filter.TimelineType is TimelineType.Direct)
|
|
{
|
|
posts.Insert(0, post);
|
|
await Locate([post], loadCancellation?.Token ?? default);
|
|
}
|
|
}
|
|
|
|
async Task Replied(FeedPost reply)
|
|
{
|
|
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
|
|
return;
|
|
posts.Insert(0, reply);
|
|
await Locate([reply], loadCancellation?.Token ?? default);
|
|
}
|
|
|
|
void Remove(FeedPost post)
|
|
{
|
|
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
|
}
|
|
|
|
IEnumerable<NerdRow> FeedStats()
|
|
{
|
|
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
|
yield return NerdRow.Of(Localizer["waiting"], pending.Count > 0 ? pending.Count : null);
|
|
yield return NerdRow.Of("min_id", minId);
|
|
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
|
|
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
|
|
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
|
|
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
|
|
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
|
|
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
|
|
{
|
|
yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}");
|
|
yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs:0} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}");
|
|
yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining);
|
|
}
|
|
}
|
|
|
|
public override async ValueTask DisposeAsync()
|
|
{
|
|
await pageCancellation.CancelAsync();
|
|
pageCancellation.Dispose();
|
|
loadCancellation?.Cancel();
|
|
pollTimer?.Dispose();
|
|
try
|
|
{
|
|
if (feedModule is not null)
|
|
{
|
|
await feedModule.InvokeVoidAsync("dispose");
|
|
await feedModule.DisposeAsync();
|
|
}
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
reference?.Dispose();
|
|
await base.DisposeAsync();
|
|
}
|
|
|
|
sealed class GeoAnswer
|
|
{
|
|
public double? Latitude { get; set; }
|
|
public double? Longitude { get; set; }
|
|
public string Error { get; set; }
|
|
}
|
|
}
|