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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 01:21:41 +02:00
1 parent 5a7e1804a9
commit 23dbc68fb4
6 files changed
+164 -1

No files matched your search

+3 -1
View File
@@ -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.
+51
View File
@@ -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<FeedPost> 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();
}
/// <summary>Shows the feed again as the reader left it, when it is the same feed of the same persona and recent.</summary>
async Task<bool> 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<IJSObjectReference>("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();
}
/// <summary>Keeps the feed for the way back (FeedMemory), when it shows something worth keeping.</summary>
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<double>("scrollOffset", ScrollId);
}
catch (JSDisconnectedException)
{
}
Memory.Save(Query, posts, pending, groups, lists, minId, maxId, scrollTop);
}
sealed class GeoAnswer
{
public double? Latitude { get; set; }
+1
View File
@@ -40,6 +40,7 @@ builder.Services.AddOptions()
.AddScoped<RequestStats>()
.AddScoped<AuthService>()
.AddScoped<TimelineService>()
.AddScoped<FeedMemory>()
.AddScoped<StatusService>()
.AddScoped<AccountService>()
.AddScoped<PortabilityService>()
+51
View File
@@ -0,0 +1,51 @@
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels.Group;
namespace decePubClient.Services
{
/// <summary>
/// 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.
/// </summary>
public class FeedMemory
{
public static readonly TimeSpan Keep = TimeSpan.FromMinutes(10);
public FeedQuery Query { get; private set; }
public List<FeedPost> Posts { get; private set; } = [];
public List<FeedPost> Pending { get; private set; } = [];
public List<ViewGroup> Groups { get; private set; } = [];
public List<MastodonList> 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<FeedPost> posts, List<FeedPost> pending, List<ViewGroup> groups, List<MastodonList> 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;
}
/// <summary>Whether what is kept is recent and belongs to this persona.</summary>
public bool Holds(string personaId) => Query is not null && Query.PersonaId == personaId && DateTime.UtcNow - SavedAt < Keep;
public void Forget()
{
Query = null;
Posts = [];
Pending = [];
}
}
}
+47
View File
@@ -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);
}
}
}
+11
View File
@@ -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" })
}