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:
1 parent
5a7e1804a9
commit
23dbc68fb4
6 files changed
+164
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -40,6 +40,7 @@ builder.Services.AddOptions()
|
||||
.AddScoped<RequestStats>()
|
||||
.AddScoped<AuthService>()
|
||||
.AddScoped<TimelineService>()
|
||||
.AddScoped<FeedMemory>()
|
||||
.AddScoped<StatusService>()
|
||||
.AddScoped<AccountService>()
|
||||
.AddScoped<PortabilityService>()
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user