decePub opens PrivaPub's streaming API (one WebSocket for the persona, its token given as the socket's protocol, never in a URL) and subscribes to the stream of the feed in view: home, a list, local, federated, a hashtag or notifications. New posts wait behind the "new" button as polled ones did; a deletion or an edit applies at once. Polling stays for the feeds PrivaPub does not stream and for any feed while the socket is down, which opens again after a pause growing to a minute. The e2e test sees a post made elsewhere in seconds and its deletion take the card away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
490 lines
17 KiB
Plaintext
490 lines
17 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 come through
|
|
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
|
|
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
|
|
the stream tells is applied at once. *@
|
|
<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" FilterIn="FilterIn" 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] StreamingService Streaming { 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()
|
|
{
|
|
Streaming.Received += OnStream;
|
|
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);
|
|
}
|
|
|
|
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
|
|
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
|
|
FilterContext? FilterIn => Filter.TimelineType switch
|
|
{
|
|
TimelineType.Home or TimelineType.Lists => FilterContext.Home,
|
|
TimelineType.Local or TimelineType.Federation or TimelineType.Tag or TimelineType.Nearby or TimelineType.Groups => FilterContext.Public,
|
|
TimelineType.Notifications => FilterContext.Notifications,
|
|
_ => null
|
|
};
|
|
|
|
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)
|
|
{
|
|
// a stream is the persona's: another one opens its own
|
|
await Streaming.Stop();
|
|
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;
|
|
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
|
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 || Streaming.IsOpen || 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
|
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
|
{
|
|
TimelineType.Home => new("user"),
|
|
TimelineType.Local => new("public:local"),
|
|
TimelineType.Federation => new("public:remote"),
|
|
TimelineType.Notifications => new("user:notification"),
|
|
TimelineType.Tag when filter.Tag is { Length: > 0 } tag => new("hashtag", Tag: tag),
|
|
TimelineType.Lists when filter.ListId is { Length: > 0 } list => new("list", List: list),
|
|
_ => null
|
|
};
|
|
|
|
/// <summary>What the stream tells: a new post or notification waits behind the "new" button; a deletion or an edit is
|
|
/// applied where the post is.</summary>
|
|
async Task OnStream(StreamMessage message)
|
|
{
|
|
switch (message.Event)
|
|
{
|
|
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
|
|
case "notification" when message.Notification is not null && Filter.TimelineType is TimelineType.Notifications:
|
|
var post = message.Status is { } s ? FeedPost.Of(s) : FeedPost.Of(message.Notification);
|
|
if (posts.Concat(pending).Any(p => p.Key == post.Key))
|
|
return;
|
|
pending.Insert(0, post);
|
|
break;
|
|
case "delete" when message.DeletedId is { } id:
|
|
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
|
pending.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
|
break;
|
|
case "status.update" when message.Status is { } edited:
|
|
foreach (var shown in posts.Concat(pending).Where(p => p.Status?.Id == edited.Id))
|
|
shown.Status = edited;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
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["stream"], Streaming.IsOpen ? Localizer["open"] : Localizer["closed, polling"]);
|
|
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()
|
|
{
|
|
Streaming.Received -= OnStream;
|
|
await Streaming.Stop();
|
|
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; }
|
|
}
|
|
}
|