After a feed was checked, failed or downloaded a file, and after every item read, the page fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event stream now knows who is connected and, after an event that changes a feed, sends that person its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for one feed). Marking an item read answers with the feed's row. The page puts the row in place and redraws once a frame. A routine skip of a feed not due sends nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
9.5 KiB
TypeScript
169 lines
9.5 KiB
TypeScript
/* ---------------- 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){
|
|
const i=S.feeds.findIndex(f=>f.id===row.id);
|
|
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
|
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
|
}
|
|
|
|
async function loadFeeds(keepSel?: boolean){
|
|
S.feeds = await api('/api/feeds');
|
|
// 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.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,b)=>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={
|
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
|
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
|
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
|
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
|
':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},
|
|
};
|
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
|
/// holding one, carry a spinner.
|
|
const scanning=new Set<string>();
|
|
function setScanning(id: string, on: boolean){
|
|
if(on) scanning.add(id); else scanning.delete(id);
|
|
paintScanning();
|
|
}
|
|
function paintScanning(){
|
|
for(const row of $$('#feedlist .feed')){
|
|
const id=row.dataset.id;
|
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
|
row.classList.toggle('scanning',on);
|
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
|
}
|
|
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
|
|
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
|
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
|
document.body.classList.toggle('scan-this',busy(S.feed));
|
|
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
|
|
}
|
|
|
|
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<HTMLElement>('#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).focus();
|
|
};
|
|
list.innerHTML='';
|
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
|
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=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
|
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
|
el.onclick=()=>{ selectFeed(id); nav(false); };
|
|
places.appendChild(el);
|
|
}
|
|
list.appendChild(places);
|
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); 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=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
|
const order=[];
|
|
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=>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=>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?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
|
// 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.
|
|
`<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
|
|
(err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+`</span>`+
|
|
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
|
(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`)+
|
|
`</small></div>`+
|
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
|
list.appendChild(el);
|
|
}
|
|
paintScanning();
|
|
done();
|
|
}
|
|
function selectFeed(id){
|
|
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){
|
|
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
|
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
|
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
|
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
|
return '';
|
|
}
|
|
function wireFailBanner(box,f){
|
|
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);
|
|
}
|
|
|