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})})