Reported: the unread count on the icon only changed when the app was
opened. Two reasons:
- Items read in the page never reached it, since only the player's own
mark-read refreshed the badge.
- The background refresh that should bring new items runs when iOS
chooses, which for this app has been rarely.
The page now sends All Subscriptions' count ({t:"unread", n}) each time
its list is drawn with a new one, so reading, and new items arriving
over the event stream while the app is open, set it at once. While an
episode plays the app is awake with the screen locked, so the player's
tick also re-checks every quarter of an hour.
With the app closed and nothing playing, the count still waits on iOS's
refresh. Push (#433) is what makes that exact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
13 KiB
TypeScript
216 lines
13 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: 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<string,{title: string, icon: string, url?: string, blurb?: string}>={
|
|
// 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<string, number>();
|
|
/// 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=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 <body>,
|
|
/// 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=$<HTMLInputElement>('#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<HTMLElement>(`[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<string,HTMLImageElement[]>();
|
|
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
|
list.innerHTML='';
|
|
const unreadAll=unreadInAll();
|
|
hostBadge(unreadAll);
|
|
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>`:'');
|
|
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','<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: 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?`<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>`;
|
|
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=''; $<HTMLInputElement>('#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 `<div class="sub fail" 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 fail" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
|
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);
|
|
}
|
|
|