diff --git a/tests/page-unit.js b/tests/page-unit.js
index 4d9b393..753103d 100644
--- a/tests/page-unit.js
+++ b/tests/page-unit.js
@@ -125,6 +125,19 @@ test("a file plays from DeadAir's playlist when it has one", () => {
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
});
+test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
+ run('lists.clear()');
+ for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
+ assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
+ assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
+ run(`keepList(listKey('f10'), {total: 1, entries: []})`);
+ run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
+ run(`forgetLists('f10')`);
+ assert.equal(run(`lists.has(listKey('f10'))`), false);
+ assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
+ assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
+});
+
test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js
index 0258aef..f391f5c 100644
--- a/tests/ui/app.spec.js
+++ b/tests/ui/app.spec.js
@@ -2015,3 +2015,22 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
});
+
+test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
+ // #247. Every list's answer held back a second and a half, as a slow connection would.
+ await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
+ await new Promise(r => setTimeout(r, 1500));
+ await route.continue();
+ });
+ await page.evaluate(() => { lists.clear(); fetching.clear(); });
+ await page.locator('.place[data-id=":all"]').click();
+ await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
+ await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
+ await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
+ await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
+ // Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
+ await page.locator('.place[data-id=":all"]').click();
+ await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
+ await expect(page.locator('#eps .listload')).toHaveCount(0);
+ await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
+});
diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md
index c3daf96..b99b3dc 100644
--- a/web/CHANGELOG.md
+++ b/web/CHANGELOG.md
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247).
+
### Changed
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
diff --git a/web/app.css b/web/app.css
index ff705ed..a84068b 100644
--- a/web/app.css
+++ b/web/app.css
@@ -797,6 +797,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
+/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
+.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
+.listload::before{content:"";width:12px;height:12px;border-radius:50%;
+ border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
diff --git a/web/src/events.ts b/web/src/events.ts
index 76ef871..ea296a7 100644
--- a/web/src/events.ts
+++ b/web/src/events.ts
@@ -19,6 +19,8 @@ function connect(){
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
+ // The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
+ const preloadSoon=soon(preloadLists,2000);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
@@ -61,7 +63,7 @@ function connect(){
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
// The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
- else if(ev.ev==='feed_row') patchFeed(ev.row);
+ else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
// Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
@@ -73,4 +75,4 @@ function connect(){
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
}
connect();
-loadFeeds().catch(()=>{});
+loadFeeds().then(preloadLists).catch(()=>{});
diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts
index 601dd08..c8693bd 100644
--- a/web/src/feedpage.ts
+++ b/web/src/feedpage.ts
@@ -70,6 +70,7 @@ function renderFeed(){
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
+ $$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f!; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
diff --git a/web/src/feeds.ts b/web/src/feeds.ts
index 5f8a829..139f6b4 100644
--- a/web/src/feeds.ts
+++ b/web/src/feeds.ts
@@ -99,6 +99,7 @@ function renderFeeds(){
el.innerHTML=`${v.icon}${v.title}`+(id===':all'
?`${unreadAll>999?'999+':unreadAll}`:'');
el.onclick=()=>{ selectFeed(id); nav(false); };
+ el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
}
list.appendChild(places);
@@ -160,6 +161,8 @@ function renderFeeds(){
(f.orphaned?'Gone':'')+
`${unread>999?'999+':unread}`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
+ // Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
+ el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el);
diff --git a/web/src/items.ts b/web/src/items.ts
index 539b095..7e72d2c 100644
--- a/web/src/items.ts
+++ b/web/src/items.ts
@@ -1,17 +1,77 @@
/* ---------------- items ---------------- */
let loadSeq=0;
+type ListPage={total: number, entries: Entry[]};
+/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
+/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
+/// 50 items, about 100 KB as JSON.
+const lists=new Map();
+const LISTS_KEPT=40;
+/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
+const fetching=new Map>();
+const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
+function listUrl(feed: string, filter=S.filter, offset=0){
+ const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
+ if(S.q) p.set('q',S.q);
+ return feed===':all' ? `/api/entries?${p}` : `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
+}
+function keepList(key: string, r: ListPage){
+ lists.delete(key); lists.set(key,r);
+ if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
+}
+/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
+/// reaches a feed or a filter. Not one kept already or on its way.
+function prefetch(feed: string, filter=S.filter){
+ // A folder's page lists its feeds, not items.
+ if(VIEWS[feed]?.url||S.feeds.some(c=>c.group===feed)) return;
+ const key=listKey(feed,filter);
+ if(lists.has(key)||fetching.has(key)) return;
+ const got: Promise=api(listUrl(feed,filter));
+ fetching.set(key,got);
+ got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
+}
+/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
+/// are fetched again when next wanted.
+function forgetLists(feed: string){
+ for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')) lists.delete(k);
+}
+/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
+/// and the feeds with something unread, most first.
+function preloadLists(){
+ const want=[':all',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
+ const next=()=>{ const f=want.shift(); if(!f) return; prefetch(f); (fetching.get(listKey(f))||Promise.resolve()).finally(next); };
+ const go=()=>{ next(); next(); };
+ 'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
+}
+
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
async function loadEntries(append?: boolean, quiet?: boolean){
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url) return;
- const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
- if(S.q) p.set('q',S.q);
- const asked=performance.now(), seq=++loadSeq;
- const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
- : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
+ const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
+ // From asking to the list drawn: what someone waits for after a click, under that click's trace
+ // (#246). Once, when the list first shows: from what is kept, at once.
+ let timed=quiet;
+ const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
+ const kept=append ? undefined : lists.get(key);
+ let spin=0;
+ if(kept){ showList(kept,false,asked); shown(); }
+ // Nothing to show yet: a spinner, if the answer is not back in a blink.
+ else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
+ let r: ListPage;
+ try{
+ r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
+ }finally{ clearTimeout(spin); }
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
if(seq!==loadSeq) return;
+ if(!append) keepList(key,r);
+ // The kept one was this already: drawn again it only flickers.
+ if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
+ showList(r,!!append,asked);
+ shown();
+}
+/// A list's page into S and onto the screen.
+function showList(r: ListPage, append: boolean, asked: number){
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
@@ -30,9 +90,11 @@ async function loadEntries(append?: boolean, quiet?: boolean){
}
S.entries = entries;
renderEntries();
- // From asking to the list drawn: what someone waits for after a click, under that click's trace
- // (#246). The API's own time is in the trace; this is the whole wait, the network included.
- if(!quiet) report('list_shown',{ms:Math.round(performance.now()-asked),feed:S.feed,filter:S.filter});
+}
+/// A list being waited for: said with a spinner, so the click is seen to have been heard.
+function listLoading(){
+ const box=$('#eps'); if(!box) return;
+ box.innerHTML='Loading…
';
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
@@ -180,6 +242,8 @@ const readWrites=new Map();
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e: Entry,read: boolean){
e.read=read;
+ // Kept lists of its feed hold their own copy of it, still as it was.
+ forgetLists(e.feed_id);
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})