/* ---------------- items ---------------- */ /// An item is its feed and its guid: two feeds can give one post the same guid, as a blog's RSS /// and Atom feeds do, and keyed by guid alone, All Subscriptions marked the first of the two /// whichever was clicked (#361). const entryKey=(e: Entry)=>e.feed_id+'\n'+e.guid; /// Whether this item is the one in the player. const inPlayer=(e: Entry)=>player.guid===e.guid&&player.feed===e.feed_id; 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); const sg=signalId(feed); return feed===':all' ? `/api/entries?${p}` : sg!==null ? `/api/signals/${sg}/entries?${p}` : `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`; } function keepList(key: string, r: ListPage){ lists.delete(key); lists.set(key,r); if(r) savePictures(r.entries); 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){ // Currently Listening and the Directory draw from their own address. const url=VIEWS[feed]?.url; if(url) return prefetchPage(url); 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)); } /// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening /// and the Directory's catalogue. A request already on its way is shared, not sent twice. const pages=new Map(); const pageFetching=new Map>(); function fetchPage(url: string): Promise{ const on=pageFetching.get(url); if(on) return on; const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; }); pageFetching.set(url,got); got.finally(()=>pageFetching.delete(url)).catch(()=>{}); return got; } const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); }; /// 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){ // A Signal holds items of any feed, and an OPML's list its shows' (#283). const group=S.feeds.find(f=>f.id===feed)?.group; for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\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',':listening',':directory',...[...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); const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f)); // On to the next when this one is in, failed or not: a failure here is nobody's to see. if(on) on.then(next,next); else 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 || S.feed===SIGNALS) return; 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,append?S.offset:0))); }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; // Reading an item takes it off the server's Unread list and off nextOffset() alike, so what is // left there stays as this answer says; S.total against nextOffset() said more once read (#385). S.more=(append?S.offset:0)+r.entries.length!S.entries.some(x=>entryKey(x)===entryKey(e)))) : r.entries; // A background scan finishing refreshes the list from the server, which -- on the Unread // tab -- would drop the item you have open the moment reading it took it off the filter. // Keep it until you pick a different one; the next refresh after that no longer protects it. // The items turned past on the way to it are kept too, so a swipe back still finds them. const kept=(e: Entry)=>entryKey(e)===S.sel||turned.has(entryKey(e)); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e)); if(!append && S.sel){ const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>entryKey(n)===entryKey(e))); entries=[...gone,...entries]; } // Reloaded for a new version (#388): the item that was open, open again, where it was in the list. const back=append ? null : takeReopen(); if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries]; S.entries = entries; renderEntries(); if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); } } /// A list being waited for: said with a spinner, so the click is seen to have been heard. function listLoading(box=$('#eps')){ if(!box) return; box.innerHTML='

Loading…

'; } function renderEntries(){ const box=$('#eps'); if(!box) return; const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed); // An OPML's own row counts nothing: its shows do. const kids=f?S.feeds.filter(c=>c.group===f.id):[]; const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); $('#count').textContent= `${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; const pane=$('#list'), top=pane?pane.scrollTop:0; box.innerHTML=''; if(!S.entries.length){ // Said only for a search, where the words typed are why; an empty feed or tab speaks for itself. if(S.q) box.innerHTML='

Nothing matches that search.

'; // A new Signal is empty while Jev reads, which would otherwise look broken. else if(sg&&S.filter==='all') box.innerHTML='

Nothing has come through yet. New items are read as your feeds are checked.

'; return; } for(const e of S.entries) box.appendChild(epEl(e)); syncPlayButtons(); if(pane) pane.scrollTop=top; if(hasMore()){ const b=document.createElement('button'); b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`; b.onclick=()=>{S.offset=nextOffset();loadEntries(true)}; box.appendChild(b); } } function epEl(e: Entry){ // An item may carry several files. The row summarises the one you would act on -- // the playable one, else anything already downloaded, else the first -- and says how // many others there are; the pane below lists them all. const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0]; const has=!!(enc&&enc.path); const playable=isPlayable(enc); const others=e.enclosures.length-1; const el=document.createElement('div'); el.className='ep'+(e.read?' read':'')+(S.sel===entryKey(e)?' sel':''); el.dataset.guid=e.guid; el.dataset.key=entryKey(e); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const name=entryName(e); // The title is a button, its click the row's, so Tab and a screen reader can open the item: // the row alone was a click target only a mouse could reach (#298). el.innerHTML=`
${[ num&&`${num}`, left&&`${left}`, others>0&&`+${others} more file${others===1?'':'s'}`, enc&&enc.deadair&&enc.deadair>0&&`${shorter(enc.deadair)}`, enc&&enc.deadair_pending&&DEAD_PENDING, // Kept on this device to play offline (#413), which only the files pane said. e.enclosures.some(isKept)&&`${ICON.kept}On this device`, enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, ].filter(Boolean).join('')}
${esc(feedName(e.feed_id))}
${enc?kindIcon(enc):''} ${enc&&!has?`
`:''}
${enc?mb(enc.length):''} ${dateOf(e.published)}
${playable?``: (enc&&!has?``:'')} ${has?``:''}
`; el.onclick=()=>selectEntry(e); $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)}); return el; } /// Whether the browser can play it. Having a file is not the same as being playable: /// blog feeds put article images in enclosures, and an