/* ---------------- feed page ---------------- */ function statHTML(f: Feed){ return `
${ plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${ f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}
`; } /// The open feed's counts, last check and error, from a row that came after its page was drawn: /// the page kept an error a later check had cleared (#316). Not the whole page, which would close /// the open item. function patchFeedHead(f: Feed){ const meta=document.querySelector('#content .fhead .meta'); if(!meta) return; meta.querySelector('.fail')?.remove(); const stat=meta.querySelector('.stat'); if(!stat) return; // An OPML's header has a stat line of its own. if(!S.feeds.some(c=>c.group===f.id)) stat.outerHTML=statHTML(f); meta.querySelector('.stat')!.insertAdjacentHTML('afterend',failBannerHTML(f)); wireFailBanner(meta,f); } function renderFeed(){ paintScanning(); // another feed's page may be the one open now const box=$('#content'); box.classList.remove('plain'); const v=VIEWS[S.feed ?? '']; if(v&&v.url) return renderListed(v); if(S.feed===SIGNALS) return renderSignals(); const f=v?null:S.feeds.find(x=>x.id===S.feed); const sg=signalOf(S.feed); $('#tbRemove').disabled=!f; syncTools(null); if(!v&&!f&&!sg){ box.innerHTML='

Add a feed to get started.

'; $('#count').textContent=''; return; } const name=f?(f.title||f.id):sg?sg.name:v.title; // The box says only Search: a list's name ran past a phone's narrow box (#417). What it // searches is in its name for a screen reader. $('#epSearch').setAttribute('aria-label',`Search ${name}`); const kids=f?S.feeds.filter(c=>c.group===f.id):[]; const unreadAll=unreadInAll(); box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
${artHTML(f.image,name)}

${esc(name)}

${statHTML(f)} ${failBannerHTML(f)} ${f.orphaned?`
This feed is no longer listed in its OPML subscription. It was kept rather than removed because it has downloaded items.
`:''} ${f.group?`
From the OPML subscription ${esc(f.group)}
`:''}
${keepHTML(f)}
` : sg ? signalHead(sg) : `
${v.icon}

${v.title}

Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you subscribe to, newest first · ${unreadAll} unread
`) + `
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=> ``).join('')}
`; const pane=document.createElement('div'); pane.id='split'; // One feed's list leaves out the feed column; an OPML's shows are told apart by it. if(f&&!kids.length) pane.className='one'; pane.innerHTML='
'+sortHead()+ '
'; $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!)); box.appendChild(pane); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%'); dragSplit(pane); // Nothing is open any more, so nothing is kept on the Unread tab for being open. S.sel=null; showDetail(null); $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):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{} renderFeed(); loadEntries(); }); if(f) wireFailBanner(box,f); } /// The item table's headings, each a button that sorts by its column. The first click goes the /// natural way round (A to Z; newest, largest and kept first) and the next one reverses it. const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']]; function sortHead(){ return '
'+COLS.map(([k,label,icon])=>{ const on=S.sort.col===k; return ``; }).join('')+'
'; } function sortBy(col: string){ const first=['published','size','kept'].includes(col)?'desc':'asc'; S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first}; try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{} S.offset=0; renderFeed(); loadEntries(); } /// An OPML subscription's page lists the items of the shows inside it together, as All /// Subscriptions does every feed's (#283), under the header a feed has, its buttons in the /// same places: it is still an ordinary feed entry underneath. // A Patreon creator split into its shows is drawn like an OPML, and named for what it is. const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||''); function groupHead(f: Feed,kids: Feed[]){ const unread=kids.reduce((n,c)=>n+c.unread,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0); const gone=kids.filter(c=>c.orphaned).length; return `
${folderArt(f,kids)}

${esc(f.title||f.id)}

${isPatreon(f)?'Patreon creator':'OPML subscription'} · ${plural(kids.length,'show')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}
${failBannerHTML(f)} ${gone?`
${gone} show${gone===1?' is':'s are'} no longer listed but kept because ${gone===1?'it has':'they have'} downloads.
`:''}
`; } /// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should /// the list drop it or you leave the list (#280). A plus to keep it, a check once kept. function keepHTML(f: Feed){ if(!f.managed||!f.group) return ''; const list=S.feeds.find(p=>p.id===f.group); const name=esc(list?(list.title||list.id):f.group); const title=f.kept?`Kept: stays on your list if ${name} drops it. Stop keeping it`:`Keep it on your list if ${name} drops it`; return ``; } async function feedAction(a: string,f: Feed){ if(a==='scan'){ spinStart(f.id); try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } catch(e){ spinEnd(); throw e; } } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='rm') removeFeed(f); if(a==='pin'){ try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})}); await loadFeeds(true); renderFeed(); }catch(e){ toast(why(e),true); } } if(a==='keep'){ try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({kept:!f.kept})}); await loadFeeds(true); renderFeed(); }catch(e){ toast(why(e),true); } } if(a==='settings') settingsModal(f); if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]')); if(a==='dl') downloadLatestModal(f); } /// All Subscriptions' own buttons: a feed's, across every feed you read. async function allAction(a: string){ if(a==='readall'){ const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); if(!n){ toast('Nothing unread'); return; } // One click across every feed is a lot to take back, so this one asks first. if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return; try{ const r=await api('/api/read-all',{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }catch(e){ toast(why(e),true); } } }