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>
89 lines
5.3 KiB
TypeScript
89 lines
5.3 KiB
TypeScript
/* ---------------- live events ---------------- */
|
|
let sse: EventSource;
|
|
/// The feed list and the open list, taken again from the server. The events keep both current,
|
|
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
|
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
|
/// the app is in the background, when everything is missed (#168).
|
|
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
|
loadFeeds(true).catch(()=>{});
|
|
if(S.feed) loadEntries(false,true).catch(()=>{});
|
|
},300); }; })();
|
|
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
|
let dropped=false;
|
|
function connect(){
|
|
sse=new EventSource('/api/events');
|
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
|
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
|
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
|
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
|
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
|
|
const preloadSoon=soon(preloadLists,2000);
|
|
sse.onmessage=m=>{
|
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
|
// Someone else's download is theirs: its file's name and its failure stay off this page (#294).
|
|
if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) return;
|
|
if(ev.ev==='progress'){
|
|
const pct=ev.total?ev.done/ev.total*100:0;
|
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
|
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
|
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
|
}
|
|
else if(ev.ev==='download_done'){
|
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
|
// everyone's feeds used to announce themselves to everyone.
|
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
|
refreshEntries();
|
|
}
|
|
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
|
else if(ev.ev==='deadair_done'){
|
|
if(!mine(ev.feed)) return;
|
|
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
|
refreshEntries();
|
|
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
|
}
|
|
else if(ev.ev==='download_error'){
|
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
|
if(bar) bar.classList.remove('live');
|
|
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
|
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
|
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
|
}
|
|
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
|
else if(ev.ev==='signal_hits'){
|
|
if(!S.signals.length) return;
|
|
refreshSignals();
|
|
if(signalOf(S.feed)) refreshEntries();
|
|
}
|
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
|
else if(ev.ev==='feed_done'){
|
|
setScanning(ev.feed,false);
|
|
if(!mine(ev.feed)) return;
|
|
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) refreshEntries();
|
|
}
|
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
|
// red ! marks the feed instead, and its page says why.
|
|
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
|
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
|
// only to those who subscribe; the page used to reload the whole list after each event.
|
|
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
|
|
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
|
// every open page reloaded the whole list each time (#103).
|
|
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
|
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
|
|
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
|
|
else if(ev.ev==='error') spinEnd();
|
|
};
|
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
|
}
|
|
connect();
|
|
loadFeeds().then(preloadLists).catch(()=>{});
|