/* ---------------- feeds ---------------- */ /// One feed's row from the server, in place of the old one (or added, for a feed an OPML just /// listed). A scan sends dozens; the list is redrawn once a frame, not once each. let rowsQueued=0; function patchFeed(row: Feed){ const i=S.feeds.findIndex(f=>f.id===row.id); if(i<0) S.feeds.push(row); else S.feeds[i]=row; if(row.id===S.feed) patchFeedHead(row); if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); } async function loadFeeds(keepSel?: boolean){ // A Signal list that fails leaves the feeds to show. [S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]); // Only for a hint in a feed's settings, so once, on the page's first load, not on every reload // of the list (#103). if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{}); renderFeeds(); // Land back where you were; a feed you no longer subscribe to, or a first visit, goes to // All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a // brand new account) leaves S.feed alone, so the empty state's own message shows instead. if(!keepSel && S.feeds.length){ const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed)); selectFeed(known ? S.feed! : ':all'); } } // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // server's alphabetical order still holds within each half. const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0); // The original's source list opened with these, above the feeds. They are places, not feeds: // an id starting with ':' can never be a feed's, since feed ids are slugs. const VIEWS: Record={ // The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124). ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'}, ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, ':all':{title:'All Subscriptions',icon:ICON.all}, }; /// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or /// creator it came from, is hidden from them (#288, #289). The server leaves out the same. const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key])); const inAll=(f: Feed)=>shownIn(f,'hide_from_all'); /// All Subscriptions' unread count. const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0); /// Feeds being checked right now, from the event stream, and since when: their rows, and the row /// of a folder holding one, carry a spinner. const scanning=new Map(); /// A row's spinner takes the place of its unread count, so it waits this long: most feeds are /// checked in a few milliseconds, and each count blinked out and back as its feed went by. const SPIN_AFTER=300; function setScanning(id: string, on: boolean){ if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id); paintScanning(); } function paintScanning(){ const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER; for(const row of $$('#feedlist .feed')){ const id=row.dataset.id!; const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id)); row.classList.toggle('scanning',on); if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); } // A feed's button turns on its own page only, wherever the person has gone since pressing it. const self=spinFeed!==null&&spinFeed===S.feed, any=document.body.classList.contains('scan-any'); document.body.classList.toggle('scan-this',self); // Turning was all that said a check was running, and with motion reduced nothing turns (#310). for(const b of $$('#scanAll,.fhead [data-a=scanall],.fhead [data-a=scan]')){ const on=b.dataset.a==='scan'?self:any; b.dataset.label??=b.getAttribute('aria-label')!; if(on) b.setAttribute('aria-busy','true'); else b.removeAttribute('aria-busy'); b.title=on?'Checking for new items…':b.dataset.label; b.setAttribute('aria-label',b.title); } } /// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On , /// because a feed's page is redrawn as its items come in and would take a class on the button /// with it. Following the feeds being checked instead, the icon snapped back upright in the gap /// between one feed and the next, many times a second (#231). let spinFeed: string|null=null; function spinStart(feed: string|null){ if(feed===null) document.body.classList.add('scan-any'); else spinFeed=feed; paintScanning(); } /// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves /// a pressed button turning, since the scan it asked for is still queued behind it. Nothing /// (an error ended the job) stops them all. function spinEnd(done?: {only: string|null, forced: boolean}){ if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any'); if(!done||done.forced&&done.only===spinFeed) spinFeed=null; paintScanning(); } function renderFeeds(){ const q=$('#feedFilter').value.trim().toLowerCase(); const list=$('#feedlist'); const top=list.scrollTop; // Every row is replaced, so put the keyboard back on the row, or the triangle, it was on. const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]'); const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]); const done=()=>{ list.scrollTop=top; const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`); if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus(); }; // The images already drawn, to go into the rows that replace theirs. Made anew, every icon in // the list went blank and loaded again, once a frame through a check of every feed, as each // feed's new row came in (#134). Matched by their markup, so a changed one is made anew. const drawn=new Map(); for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); } list.innerHTML=''; const unreadAll=unreadInAll(); const places=document.createElement('div'); places.className='places'; for(const [id,v] of Object.entries(VIEWS)){ const el=document.createElement('div'); el.className='place'+(S.feed===id?' sel':''); el.tabIndex=0; el.dataset.id=id; el.innerHTML=`${v.icon}${v.title}`+(id===':all' ?`${unreadAll>999?'999+':unreadAll}`:''); asLink(el,S.feed===id); el.onclick=()=>{ selectFeed(id); nav(false); }; el.onpointerenter=el.ontouchstart=()=>prefetch(id); places.appendChild(el); } list.appendChild(places); signalFolder(list,q); const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); if(!shown.length){ list.insertAdjacentHTML('beforeend','

No feeds.

'); return done(); } // Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // go first: a folder with its feeds under it, a feed from inside one lifted out of it. const byId=Object.fromEntries(shown.map(f=>[f.id,f])); const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned); const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group])); const order: [Feed, number][]=[]; for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ order.push([f,0]); // A subscription can hold dozens of feeds, so a folder starts closed. Searching // opens them all, or matches inside a closed folder would be invisible. if(expanded.has(f.id) || q) for(const c of inside(f).sort(unreadFirst)) order.push([c,1]); } // The rule under the pinned block goes under its last row: a pinned folder's last feed when // it is open. const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned); const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0]; for(const [f,depth] of order){ const kids=inside(f).length; // A subscription holds no entries itself, so its counts are the sum of what is inside -- // taken from every feed it holds, not just the ones a filter left showing. const mine=S.feeds.filter(c=>c.group===f.id); const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0); const [unread,eps,saved]=mine.length ? [sum('unread'),sum('entries'),sum('downloaded')] : [f.unread,f.entries,f.downloaded]; // A group's own row has no error of its own worth mentioning if the OPML itself // reads fine; it is failing when any feed inside it is. The feed's own page says what // went wrong (failBannerHTML); the list only has to make it findable. const bad=(c: Feed)=>c.failing?.reason||c.last_error; const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const nbad=mine.filter(bad).length; const el=document.createElement('div'); // A feed that has failed for a day goes grey, as if switched off: a change in lightness // shows in every theme, where the red mark alone was easy to miss on a dark one. el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+ (err?' err':'')+(f.failing?' failing':'')+ (f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':''); el.tabIndex=0; el.dataset.id=f.id; const open = !!(kids && (expanded.has(f.id) || q)); el.innerHTML = (kids?``:'')+ // The mark sits on the artwork, the thing the eye scans the list by, and the line under // the name says what is wrong in place of the counts. `${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+ (err?`${ICON.alert}`:'')+ (f.pinned?`${ICON.pinOn}`:'')+``+ `
${esc(f.title||f.id)}`+ (nbad?`${plural(nbad,'feed')} not updating` :err?esc(f.failing?.reason||'The last check failed') :`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+ `
`+ (f.orphaned?'Gone':'')+ `${unread>999?'999+':unread}`; asLink(el,S.feed===f.id); 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); } paintScanning(); done(); } function selectFeed(id: string){ S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; try{ localStorage.setItem('ipx.feed',id); }catch{} renderFeeds(); renderFeed(); loadEntries(); } /// A failing feed's error, in plain words with something to do about it, once `failing` is /// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in /// src/feed.rs). Anything else still shows the raw error, as before. function failBannerHTML(f: Feed){ if(f.failing) return `
${esc(f.failing.reason)} ${ f.failing.new_url?` `:''}
`; if(f.last_error) return `
${esc(f.last_error)}
`; return ''; } function wireFailBanner(box: ParentNode,f: Feed){ const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f); const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined); }