Files
ipx/web/src/feeds.ts
rays 6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00

204 lines
12 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(!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.
document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
}
/// 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();
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" 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: 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);
}