Checking every feed made all the feeds' icons in the feed list flash while it ran. A check sends each feed's new row as it reads the feed, and renderFeeds, run once a frame while they come in, emptied the list and built every row anew, every <img> with it: each icon went blank and was loaded and drawn again, many times a second through a scan of 150 feeds. The rows are still rebuilt, but the images already drawn go into the new ones in place of the fresh copies, matched by their markup, so only an icon that has changed is made anew. A browser test checks the rows are new and the images in them the same elements, and fails without this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
9.9 KiB
TypeScript
174 lines
9.9 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={
|
|
// 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},
|
|
};
|
|
/// 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();
|
|
};
|
|
// 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<string,HTMLImageElement[]>();
|
|
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=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>`:'')+
|
|
(f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
|
|
`<div class="txt"><b>${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); };
|
|
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){
|
|
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);
|
|
}
|
|
|