The feed hears new posts as they come

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 04:15:54 +02:00
1 parent 6d5cf17723
commit 963565b316
7 files changed
+312 -5

No files matched your search

+52 -3
View File
@@ -7,8 +7,10 @@
@* 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. *@
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">
@@ -104,6 +106,7 @@
[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; }
@@ -133,6 +136,7 @@
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;
@@ -205,6 +209,8 @@
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;
@@ -238,6 +244,7 @@
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)
@@ -290,7 +297,7 @@
{
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
{
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
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;
@@ -322,6 +329,45 @@
}
}
/// <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;
@@ -400,6 +446,7 @@
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)
@@ -412,6 +459,8 @@
public override async ValueTask DisposeAsync()
{
Streaming.Received -= OnStream;
await Streaming.Stop();
await pageCancellation.CancelAsync();
pageCancellation.Dispose();
loadCancellation?.Cancel();