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,
|
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.
|
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`,
|
- **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)
|
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
|
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.
|
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] StreamingService Streaming { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] RequestStats RequestStats { get; set; }
|
[Inject] RequestStats RequestStats { get; set; }
|
||||||
|
[Inject] FeedMemory Memory { get; set; }
|
||||||
|
|
||||||
FeedFilter Filter { get; set; } = new();
|
FeedFilter Filter { get; set; } = new();
|
||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
@@ -129,6 +130,8 @@
|
|||||||
FeedQuery lastQuery;
|
FeedQuery lastQuery;
|
||||||
DateTime? lastPollAt;
|
DateTime? lastPollAt;
|
||||||
TimeSpan? lastLoadTook;
|
TimeSpan? lastLoadTook;
|
||||||
|
// the scroll a recalled feed goes back to, once rendered
|
||||||
|
double? recalledScroll;
|
||||||
|
|
||||||
GlobePanel globe;
|
GlobePanel globe;
|
||||||
IJSObjectReference feedModule;
|
IJSObjectReference feedModule;
|
||||||
@@ -158,9 +161,36 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await Recall())
|
||||||
|
return;
|
||||||
await Reload();
|
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.
|
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -177,6 +207,8 @@
|
|||||||
|
|
||||||
reference = DotNetObjectReference.Create(this);
|
reference = DotNetObjectReference.Create(this);
|
||||||
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
|
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);
|
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
|
||||||
pollTimer = new(PollInterval);
|
pollTimer = new(PollInterval);
|
||||||
_ = PollNewer(pageCancellation.Token);
|
_ = PollNewer(pageCancellation.Token);
|
||||||
@@ -511,6 +543,7 @@
|
|||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
Streaming.Received -= OnStream;
|
Streaming.Received -= OnStream;
|
||||||
|
await Remember();
|
||||||
await Streaming.Stop();
|
await Streaming.Stop();
|
||||||
await pageCancellation.CancelAsync();
|
await pageCancellation.CancelAsync();
|
||||||
pageCancellation.Dispose();
|
pageCancellation.Dispose();
|
||||||
@@ -531,6 +564,24 @@
|
|||||||
await base.DisposeAsync();
|
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
|
sealed class GeoAnswer
|
||||||
{
|
{
|
||||||
public double? Latitude { get; set; }
|
public double? Latitude { get; set; }
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<RequestStats>()
|
.AddScoped<RequestStats>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<TimelineService>()
|
.AddScoped<TimelineService>()
|
||||||
|
.AddScoped<FeedMemory>()
|
||||||
.AddScoped<StatusService>()
|
.AddScoped<StatusService>()
|
||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
.AddScoped<PortabilityService>()
|
.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"
|
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) {
|
export function scrollToTop(rootId) {
|
||||||
document.getElementById(rootId)?.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" })
|
document.getElementById(rootId)?.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user