From 4e417389fef2789955a8599f065744cfb2c172f2 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 9 Oct 2026 12:13:16 +0000 Subject: [PATCH] Catch the feed list up when the page comes back into view (#168) All Subscriptions said 1 unread when the server had none. The badge is the page's own sum of its feed list, loaded once and patched only by events the server sends when a scan or a download changes a feed. Reading an item, marking everything read or unsubscribing sends none, so another device's page kept its old counts, and an iPhone stops the event stream while the app is in the background, missing everything meanwhile; the reconnect did not catch up. The page now takes the feed list and the open list again when it comes back into view and when the stream reconnects after a drop. A browser test reads an item behind the page's back and checks the badge follows once the page is shown again; it fails without this. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + tests/ui/app.spec.js | 18 ++++++++++++++++++ web/src/events.ts | 14 +++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e1cb2..cdbcc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload. - A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing. - The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server. - Downloading or deleting a file the server no longer has answers "not found" instead of a server error. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index ff57559..0d7dc87 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1683,3 +1683,21 @@ test.describe('on a narrow phone', () => { await page.evaluate(() => setTheme('modern', 'dark')); }); }); + +test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => { + // Reading an item tells no other page, and a phone's page misses everything while it is in the + // background: All Subscriptions went on counting an item read elsewhere as unread (#168). + const badge = page.locator('.place[data-id=":all"] .badge'); + const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries; + const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } }); + await flag(false); + await page.reload(); + const before = Number(await badge.textContent()); + expect(before).toBeGreaterThan(0); + await flag(true); // read on another device + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await expect(badge).toHaveText(String(before - 1)); +}); diff --git a/web/src/events.ts b/web/src/events.ts index 49a9cad..06d8c49 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -1,7 +1,19 @@ /* ---------------- live events ---------------- */ let sse; +/// The feed list and the open list, taken again from the server. The events keep both current, +/// and one missed is a count wrong until a reload: reading an item sends none to your other +/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while +/// the app is in the background, when everything is missed (#168). +const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ + loadFeeds(true).catch(()=>{}); + if(S.feed) loadEntries().catch(()=>{}); +},300); }; })(); +document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); }); +let dropped=false; function connect(){ sse=new EventSource('/api/events'); + // Back after a drop: whatever was sent meanwhile went to nobody. + sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. @@ -48,7 +60,7 @@ function connect(){ // page reloads; reload the list here when a scan synced an OPML if that ever matters. else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } }; - sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; + sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; } connect(); loadFeeds();