From 23dbc68fb46dbdcf202a8080097e6d74d40b429c Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 01:21:41 +0200 Subject: [PATCH] Coming back to the feed shows it as it was left Leaving the feed keeps it in Services/FeedMemory.cs (posts, waiting posts, cursors and scroll; ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once, older pages included, instead of reading it anew; newer posts come behind the "new" button and through the stream as before. tests/e2e FeedMemoryTests goes to a thread and back without a new read of the home timeline; the whole suite passes (123, 5 skipped). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 4 ++- Pages/Index.razor | 51 ++++++++++++++++++++++++++++ Program.cs | 1 + Services/FeedMemory.cs | 51 ++++++++++++++++++++++++++++ tests/e2e/Actions/FeedMemoryTests.cs | 47 +++++++++++++++++++++++++ wwwroot/js/feed.js | 11 ++++++ 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 Services/FeedMemory.cs create mode 100644 tests/e2e/Actions/FeedMemoryTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 64abf29..6bae6db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,7 +289,9 @@ supplies: are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host, since the API may be reached under another name. - **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`, - `GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's + `GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll, + ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once + without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL) for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible. diff --git a/Pages/Index.razor b/Pages/Index.razor index f136632..a8c0c24 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -110,6 +110,7 @@ [Inject] StreamingService Streaming { get; set; } [Inject] InstanceService InstanceService { get; set; } [Inject] RequestStats RequestStats { get; set; } + [Inject] FeedMemory Memory { get; set; } FeedFilter Filter { get; set; } = new(); List posts = []; @@ -129,6 +130,8 @@ FeedQuery lastQuery; DateTime? lastPollAt; TimeSpan? lastLoadTook; + // the scroll a recalled feed goes back to, once rendered + double? recalledScroll; GlobePanel globe; IJSObjectReference feedModule; @@ -158,9 +161,36 @@ return; } + if (await Recall()) + return; await Reload(); } + /// Shows the feed again as the reader left it, when it is the same feed of the same persona and recent. + async Task Recall() + { + if (!Memory.Holds(CascadingState.PersonaId)) + return false; + groups = Memory.Groups; + lists = Memory.Lists; + if (Query != Memory.Query) + return false; + + loadCancellation = new(); + posts = Memory.Posts; + pending = Memory.Pending; + minId = Memory.MinId; + maxId = Memory.MaxId; + recalledScroll = Memory.ScrollTop; + loadedPersonaId = CascadingState.PersonaId; + lastQuery = Query; + IsLoading = false; + Memory.Forget(); + await Streaming.Follow(StreamOf(Filter), loadCancellation.Token); + await ReadNotifications(loadCancellation.Token); + return true; + } + // The persona changed (NavMenu's switcher): everything on the page belongs to the old one. protected override async Task OnParametersSetAsync() { @@ -177,6 +207,8 @@ reference = DotNetObjectReference.Create(this); feedModule = await JS.InvokeAsync("import", "./js/feed.js"); + if (recalledScroll is { } top) + await feedModule.InvokeVoidAsync("scrollToOffset", ScrollId, top); await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId); pollTimer = new(PollInterval); _ = PollNewer(pageCancellation.Token); @@ -511,6 +543,7 @@ public override async ValueTask DisposeAsync() { Streaming.Received -= OnStream; + await Remember(); await Streaming.Stop(); await pageCancellation.CancelAsync(); pageCancellation.Dispose(); @@ -531,6 +564,24 @@ await base.DisposeAsync(); } + /// Keeps the feed for the way back (FeedMemory), when it shows something worth keeping. + async Task Remember() + { + if (IsLoading || loadError is { Length: > 0 } || posts.Count == 0 || CascadingState.PersonaId is null + || Filter.TimelineType is TimelineType.Nearby) + return; + var scrollTop = 0d; + try + { + if (feedModule is not null) + scrollTop = await feedModule.InvokeAsync("scrollOffset", ScrollId); + } + catch (JSDisconnectedException) + { + } + Memory.Save(Query, posts, pending, groups, lists, minId, maxId, scrollTop); + } + sealed class GeoAnswer { public double? Latitude { get; set; } diff --git a/Program.cs b/Program.cs index 8539a85..dfd3bba 100644 --- a/Program.cs +++ b/Program.cs @@ -40,6 +40,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Services/FeedMemory.cs b/Services/FeedMemory.cs new file mode 100644 index 0000000..b3447a7 --- /dev/null +++ b/Services/FeedMemory.cs @@ -0,0 +1,51 @@ +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels.Group; + +namespace decePubClient.Services +{ + /// + /// The feed as the reader left it: its query, posts, waiting posts, cursors and scroll. Coming back from a thread or a + /// profile shows it again at once instead of reading it anew; newer posts come behind the "new" button as usual. Kept + /// ten minutes, and only for the same feed of the same persona. + /// + public class FeedMemory + { + public static readonly TimeSpan Keep = TimeSpan.FromMinutes(10); + + public FeedQuery Query { get; private set; } + public List Posts { get; private set; } = []; + public List Pending { get; private set; } = []; + public List Groups { get; private set; } = []; + public List Lists { get; private set; } = []; + public string MinId { get; private set; } + public string MaxId { get; private set; } + public double ScrollTop { get; private set; } + public DateTime SavedAt { get; private set; } + + public void Save(FeedQuery query, List posts, List pending, List groups, List lists, + string minId, string maxId, double scrollTop) + { + Query = query; + Posts = posts; + Pending = pending; + Groups = groups; + Lists = lists; + MinId = minId; + MaxId = maxId; + ScrollTop = scrollTop; + SavedAt = DateTime.UtcNow; + } + + /// Whether what is kept is recent and belongs to this persona. + public bool Holds(string personaId) => Query is not null && Query.PersonaId == personaId && DateTime.UtcNow - SavedAt < Keep; + + public void Forget() + { + Query = null; + Posts = []; + Pending = []; + } + } +} diff --git a/tests/e2e/Actions/FeedMemoryTests.cs b/tests/e2e/Actions/FeedMemoryTests.cs new file mode 100644 index 0000000..0a812f8 --- /dev/null +++ b/tests/e2e/Actions/FeedMemoryTests.cs @@ -0,0 +1,47 @@ +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // Coming back to the feed from a thread shows it as it was left, with its older pages, without reading it again + // (Services/FeedMemory.cs). + public sealed class FeedMemoryTests(Browser browser) + { + [Fact] + public async Task The_feed_comes_back_from_a_thread_as_it_was_left() + { + var persona = Pasture.Personas().First(); + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.action.feed-memory", "privapub", persona.UserName, async () => + { + await page.GotoAsync($"{Pasture.Site}/"); + await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')"); + await page.GotoAsync($"{Pasture.Site}/"); + var feed = page.Locator("[data-feed-count]"); + await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 30000 }); + // an older page too when there is one, so there is more to keep than the first + await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = root.scrollHeight"); + await Pasture.Eventually(async () => int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") > 20, 10); + var count = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0"); + + var homeReads = 0; + page.Request += (_, request) => + { + if (request.Url.Contains("/api/v1/timelines/home") && !request.Url.Contains("min_id")) + homeReads++; + }; + await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = 0"); + await page.Locator("article[data-uri] footer a[href^='expand/']").First.ClickAsync(); + await page.WaitForURLAsync("**/expand/**"); + await page.GoBackAsync(); + await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 10000 }); + + Assert.True(int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") >= count, "the feed came back shorter than it was left"); + Assert.Equal(0, homeReads); + await session.AssertGuards(); + }, page); + } + } +} diff --git a/wwwroot/js/feed.js b/wwwroot/js/feed.js index 0113260..d9f842d 100644 --- a/wwwroot/js/feed.js +++ b/wwwroot/js/feed.js @@ -34,6 +34,17 @@ export function isVisible() { return document.visibilityState === "visible" } +// The scroll a feed was left at, and the way back to it (Services/FeedMemory.cs). +export function scrollOffset(rootId) { + return document.getElementById(rootId)?.scrollTop ?? 0 +} + +export function scrollToOffset(rootId, top) { + const root = document.getElementById(rootId) + if (root) + root.scrollTop = top +} + export function scrollToTop(rootId) { document.getElementById(rootId)?.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }) }