Page type-check: noImplicitAny, every parameter and variable typed (#235)

290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:57:05 +00:00
parent af6eae9baf
commit ec2b9e87b8
11 changed files with 203 additions and 161 deletions

View File

@@ -154,7 +154,9 @@ node tests/page-smoke.js
node --test tests/page-unit.js node --test tests/page-unit.js
``` ```
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second The first type-checks `web/src`: every parameter and variable has a type, and what the API sends
is typed in `web/src/types.ts`, which mirrors the Rust structs and changes with them. Not yet
null-safe: `strictNullChecks` is off and `$` returns `any` (#236). The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a function, `String.replace` silently matched nothing, and the whole UI died with a

View File

@@ -5,8 +5,9 @@
"target": "es2022", "target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"], "lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true, "noEmit": true,
// strict, a flag at a time (#235); noImplicitAny and strictNullChecks still to come. // strict, a flag at a time (#235); strictNullChecks still to come, with a typed $ (#236).
"strict": false, "strict": false,
"noImplicitAny": true,
"strictFunctionTypes": true, "strictFunctionTypes": true,
"strictBindCallApply": true, "strictBindCallApply": true,
"noImplicitThis": true, "noImplicitThis": true,

View File

@@ -61,7 +61,7 @@ async function drawServer(){
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0, max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0, max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
@@ -137,7 +137,7 @@ async function drawDirectoryStatus(){
/// sharing with it, and the feeds removed from it. /// sharing with it, and the feeds removed from it.
async function drawDirectoryAdmin(){ async function drawDirectoryAdmin(){
const box = $('#dadmin'); const box = $('#dadmin');
let a; let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; } try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return; if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2> box.innerHTML = `<h2>Servers</h2>
@@ -155,7 +155,7 @@ async function drawDirectoryAdmin(){
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div> <small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('') <button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`; : '<p class="hint">Nothing is blocked.</p>'}`;
box.onclick = async ev => { box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]'); const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return; if(!b) return;
const row = b.closest<HTMLElement>('.urow'); const row = b.closest<HTMLElement>('.urow');
@@ -177,7 +177,7 @@ async function drawDirectoryAdmin(){
/* ---------------- accounts ---------------- */ /* ---------------- accounts ---------------- */
async function drawAccounts(){ async function drawAccounts(){
const box = $('#accounts'); const box = $('#accounts');
const users = await api('/api/users') || []; const users: Account[] = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2> box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}"> ${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b> <div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
@@ -195,7 +195,7 @@ async function drawAccounts(){
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in <span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div> through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`; <div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => { const change = async (u: Account, opts: RequestInit) => {
try{ try{
await api(`/api/users/${u.id}`, opts); await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page // Demoting yourself takes this page away; go back to the app rather than stay on a page
@@ -206,7 +206,7 @@ async function drawAccounts(){
}; };
for(const row of $$('#accounts [data-id]')){ for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id); const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = e => $('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
$('[data-a="rm"]', row).onclick = () => { $('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
@@ -223,16 +223,16 @@ async function drawAccounts(){
} }
/* ---------------- log ---------------- */ /* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every // Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it. // command in and every event out, whatever sent it.
const LOG_TABS = { const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
all: null, all: null,
daemon: t => t === 'ipx::io', daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan', scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http', web: t => t === 'ipx::http',
}; };
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){ function drawLogView(){
logSeq = 0; logLines = []; logSeq = 0; logLines = [];
@@ -263,8 +263,8 @@ function drawLogView(){
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog(); drawLog();
}; };
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; $('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; $('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l => $('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog(); pollLog();

View File

@@ -9,7 +9,7 @@ function closeModal(){
$('#modal').classList.remove('on'); $('#modal').classList.remove('on');
} }
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{ $('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3> openModal(`<h3>Add a feed</h3>
@@ -27,7 +27,7 @@ $('#addFeed').onclick=()=>{
try{ try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, allow_explicit:$('#nexp').checked, url, allow_explicit:$('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(why(e),true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } }catch(e){ toast(why(e),true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
@@ -43,7 +43,7 @@ let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The /// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its /// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128). /// page in the Directory, to see what it is before you take it (#128).
function listedFeed(p,cls,rank?: number){ function listedFeed(p: DirFeed,cls: string,rank?: number){
const el=document.createElement('div'); const el=document.createElement('div');
el.className=cls+(p.subscribed?' mine':''); el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id; el.tabIndex=0; el.dataset.id=p.id;
@@ -64,11 +64,11 @@ function listedFeed(p,cls,rank?: number){
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; } if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well. // Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); }; $('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
return el; return el;
} }
async function subscribeListed(p){ async function subscribeListed(p: DirFeed){
try{ try{
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`); toast(`Subscribed to ${NAME(p)}`);
@@ -78,29 +78,29 @@ async function subscribeListed(p){
// Where you are in the Directory: what kind of feed, which category and subcategory, in what // Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits. // order. Out here because a search redraws the page, and the kind and order hold across visits.
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[]; let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was. // The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=null; let dirShow: string|null=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is // The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon. // Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other'; const EVERY=':every', OTHER=':other';
const NAME=p=>(p.title||p.id); const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b); const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it. /// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={ const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
subscribers:['Most subscribers',bySubs], subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName], az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)], za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
}; };
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'}; const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the /// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its /// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across /// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it. /// every page, and the search box looks through the lot. One fetch draws all of it.
async function renderDirectory(url,box){ async function renderDirectory(url: string,box: HTMLElement){
// Each visit starts at the front, as going back to a tab does. // Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null; dirCat=null; dirSub=null; dirShow=null;
dirError=''; dirError='';
@@ -117,24 +117,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs); const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All'; if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind]; const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c); const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front. // A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null; if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`; const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
// back: where its button goes, '' for the front; none on the front itself. // back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'': const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`} `<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${ <div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div> Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
<p class="sub">${esc(sub)}</p></div>`; <p class="sub">${esc(sub)}</p></div>`;
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`; const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${ const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`; Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>'; const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
let html, chart=[], tiles=null; let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow); const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){ if(show){
const n=show.subscribers; const n=show.subscribers;
@@ -168,7 +168,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
if(!subs.includes(dirSub)) dirSub=null; if(!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat); if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v); const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'') html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'') +(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends. // Its own heading only when the chart is above it, to say where one list ends.
@@ -178,10 +178,10 @@ function drawDirectory(box=$('#directory'), focus?: string){
box.innerHTML=html; box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); } if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
const c=$('#dchart',box); const c=$('#dchart',box);
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); } if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
const t=$('#dtiles',box); const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{ box.onclick=(ev: MouseEvent)=>{
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]'); const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return; if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from. // Back from a feed's page, onto the tile or row it was opened from.
@@ -204,9 +204,9 @@ function drawDirectory(box=$('#directory'), focus?: string){
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the /// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
/// rest, and its latest items, each one's title, opening its post on the publisher's site where /// rest, and its latest items, each one's title, opening its post on the publisher's site where
/// it has one, its first lines, and when and how long. /// it has one, its first lines, and when and how long.
async function fillShow(box,p){ async function fillShow(box: HTMLElement,p: DirFeed){
const list=$('#dshow',box), about=$('#dabout',box); const list=$('#dshow',box), about=$('#dabout',box);
let got; let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); } try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; } catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
// Gone back, or on to another, while it loaded. // Gone back, or on to another, while it loaded.
@@ -256,7 +256,7 @@ async function fillShow(box,p){
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed /// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are. /// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
function catTile(c,name,count,rows){ function catTile(c: string,name: string,count: string,rows: DirFeed[]){
// Covers before initials, which are all Other had; the likeliest first, and first is in front: // Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front. // the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse(); const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
@@ -265,7 +265,7 @@ function catTile(c,name,count,rows){
} }
/// The Directory and Currently Listening open in the main pane, as the original's Directory did. /// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){ async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content'); const box=$('#content');
box.classList.add('plain'); box.classList.add('plain');
$('#tbRemove').disabled=true; $('#tbRemove').disabled=true;
@@ -294,14 +294,14 @@ async function renderListed(v){
/// Currently Listening: episodes you started and have not finished, across every feed you /// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead. /// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){ async function renderListening(url: string,box: HTMLElement){
let rows=[]; let rows: Entry[]=[];
try{ rows=(await api(url)).entries||[]; }catch{} try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>` box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>'; :'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){ for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves. // Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div'); const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
el.className='childrow'; el.className='childrow';
el.entry=e; el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
@@ -319,7 +319,7 @@ async function renderListening(url,box){
} }
/// One row's time left, progress and play button, taken from the player when it is the one in it. /// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){ function paintListenRow(el: HTMLElement & {entry?: Entry}){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then. // Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
@@ -341,7 +341,7 @@ function syncListening(){
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go. /// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){ async function forget(e: Entry){
// Closed without saving first, or the player's next save would put it straight back. // Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
try{ try{
@@ -356,8 +356,8 @@ $('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) remov
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; $('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; $('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT; let searchT: number;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT); $('#epSearch').oninput=(ev: Event & {target: HTMLInputElement})=>{ clearTimeout(searchT);
// The Directory looks through what it already has; Currently Listening and the rest ask the // The Directory looks through what it already has; Currently Listening and the rest ask the
// server (#126, #127). // server (#126, #127).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
@@ -368,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){ function toggleGroup(id: string){
expanded.has(id) ? expanded.delete(id) : expanded.add(id); expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds(); renderFeeds();
} }
let globalMax = 3; let globalMax = 3;
function due(ts){ function due(ts: number){
const d = ts - Date.now()/1000; const d = ts - Date.now()/1000;
if(d <= 0) return 'due now'; if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
@@ -385,7 +385,7 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an /// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only. /// admin is sent (issue #19); this used to hold them too, shown to admins only.
function deadSavedText(me){ function deadSavedText(me: Me|null){
const s=me&&me.deadair_saved||0; const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.` return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.'; :'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
@@ -429,8 +429,8 @@ async function prefsModal(){
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`); ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
drawTokens(); drawTokens();
$('#tokadd').onclick=async()=>{ $('#tokadd').onclick=async()=>{
@@ -448,7 +448,7 @@ async function prefsModal(){
$('#tokval').select(); $('#tokval').select();
}catch(err){ toast(why(err),true); } }catch(err){ toast(why(err),true); }
}; };
$('#sblock').onchange=async e=>{ $('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{
const blocked=splitWords(e.target.value); const blocked=splitWords(e.target.value);
try{ try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})}); await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
@@ -461,7 +461,7 @@ async function prefsModal(){
/// Your API tokens, each with when it was made and last used, and a button to revoke it. /// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){ async function drawTokens(){
const box=$('#tokens'); if(!box) return; const box=$('#tokens'); if(!box) return;
const list=await api('/api/tokens').catch(()=>[]); const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b> box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span> <span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join(''); <button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
@@ -486,10 +486,10 @@ function chipsHTML(words: string[], input: string){
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`; placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
} }
/// The words in a chip box, with whatever is typed and not yet a chip. /// The words in a chip box, with whatever is typed and not yet a chip.
function chipWords(box){ function chipWords(box: ParentNode){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)]; return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
} }
function wireChips(box){ function wireChips(box: HTMLElement){
const input=$('.chipin',box); const input=$('.chipin',box);
// The hint only in an empty box: beside chips it reads as one more. // The hint only in an empty box: beside chips it reads as one more.
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; }; const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
@@ -501,14 +501,14 @@ function wireChips(box){
} }
input.value=''; hint(); input.value=''; hint();
}; };
input.onkeydown=e=>{ input.onkeydown=(e: KeyboardEvent)=>{
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); } if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); } else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
}; };
// Typed and left without Enter is still meant: Save takes it either way. // Typed and left without Enter is still meant: Save takes it either way.
input.onblur=add; input.onblur=add;
box.onclick=e=>{ box.onclick=(e: MouseEvent)=>{
const x=e.target.closest('.chipx'); const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement.remove(); hint(); } if(x){ x.parentElement.remove(); hint(); }
input.focus(); input.focus();
}; };
@@ -516,7 +516,7 @@ function wireChips(box){
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare /// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
/// box whose blank, number and 0 were explained in bold small print. /// box whose blank, number and 0 were explained in bold small print.
function settingsModal(f, newUrl?: string){ function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length; const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0; const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null; const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
@@ -576,7 +576,7 @@ function settingsModal(f, newUrl?: string){
// What only matters while it downloads on its own folds away, out of reach, when it does not. // What only matters while it downloads on its own folds away, out of reach, when it does not.
const deps=$('#sdeps'), inner=$('.fdepsin',deps); const deps=$('#sdeps'), inner=$('.fdepsin',deps);
inner.inert=!f.auto_download; inner.inert=!f.auto_download;
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; }; $('#sauto').onchange=(e: Event & {target: HTMLInputElement})=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
// The download limit, said as what it does. A number in a box hid that it is a window on the // The download limit, said as what it does. A number in a box hid that it is a window on the
// newest items, not a pace through the back catalogue (#97). // newest items, not a pace through the back catalogue (#97).
@@ -591,7 +591,7 @@ function settingsModal(f, newUrl?: string){
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}` ? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`; : `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest; const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
let bars=[]; let bars: HTMLElement[]=[];
const draw=(words: boolean)=>{ const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all // The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place. // skipped holds no place.
@@ -610,16 +610,16 @@ function settingsModal(f, newUrl?: string){
}; };
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); }; for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand. // Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; }; $('#sdead').onchange=(e: Event & {target: HTMLInputElement})=>{ $('#sdpause').disabled=!e.target.checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); }; const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText(); pause.oninput=pauseText; pauseText();
draw(true); draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show. // The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{ if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
const items=page?.entries||[]; const items: Entry[]=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return; const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0)); const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{ strip.innerHTML=items.map((e: Entry,i: number)=>{
const file=e.enclosures.some(x=>x.state!=='skipped'); const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60; const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`; return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
@@ -637,8 +637,8 @@ function settingsModal(f, newUrl?: string){
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); } else { $('#smax').value=String(newest); draw(false); }
}; };
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{}); }).catch(()=>{});
wireChips($('#skw')); wireChips($('#skw'));
@@ -668,7 +668,7 @@ function settingsModal(f, newUrl?: string){
}; };
} }
function downloadLatestModal(f){ function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3> openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label> <div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5"> <input type="number" id="dcount" min="1" max="100" value="5">
@@ -685,7 +685,7 @@ function downloadLatestModal(f){
}; };
} }
function removeFeed(f){ function removeFeed(f: Feed){
openModal(`<h3>Unsubscribe?</h3> openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else <p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state. reading it keeps it, along with their own read state.
@@ -753,8 +753,8 @@ api('/api/me').then(u=>{
$('#feedFilter').oninput=renderFeeds; $('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too. // folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
const row=ev.target.closest('[data-id]'); if(!row) return; const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
const id=row.dataset.id; const id=row.dataset.id;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click(); if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&& else if(row.classList.contains('group')&&

View File

@@ -1,10 +1,10 @@
/* ---------------- live events ---------------- */ /* ---------------- live events ---------------- */
let sse; let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current, /// 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 /// 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 /// 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). /// the app is in the background, when everything is missed (#168).
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{}); loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{}); if(S.feed) loadEntries().catch(()=>{});
},300); }; })(); },300); }; })();
@@ -14,11 +14,11 @@ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody. // Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; 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). // Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id); const mine=(id: string)=>S.feeds.some(f=>f.id===id);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){

View File

@@ -89,7 +89,7 @@ function sortHead(){
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`; `${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>'; }).join('')+'<span></span></div>';
} }
function sortBy(col){ function sortBy(col: string){
const first=['published','size','kept'].includes(col)?'desc':'asc'; const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first}; S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{} try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
@@ -99,9 +99,9 @@ function sortBy(col){
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps /// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath. /// every action a normal feed has -- it is still an ordinary feed entry underneath.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is. // A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||''); const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f,kids){ function renderGroup(f: Feed,kids: Feed[]){
const unread=kids.reduce((n,c)=>n+c.unread,0); const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length; const gone=kids.filter(c=>c.orphaned).length;
@@ -158,7 +158,7 @@ function renderGroup(f,kids){
draw(); draw();
} }
async function feedAction(a,f){ async function feedAction(a: string,f: Feed){
if(a==='scan'){ if(a==='scan'){
spinStart(f.id); spinStart(f.id);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
@@ -177,7 +177,7 @@ async function feedAction(a,f){
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/// All Subscriptions' own buttons: a feed's, across every feed you read. /// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){ async function allAction(a: string){
if(a==='scanall') return scanAll(); if(a==='scanall') return scanAll();
if(a==='readall'){ if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);

View File

@@ -2,7 +2,7 @@
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just /// 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. /// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0; let rowsQueued=0;
function patchFeed(row){ function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id); const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row; if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
@@ -24,10 +24,10 @@ async function loadFeeds(keepSel?: boolean){
} }
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // 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. // server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0); 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: // 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. // an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={ 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). // 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'}, ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
@@ -108,9 +108,9 @@ function renderFeeds(){
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // 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. // 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 byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned); 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 tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[]; const order: [Feed, number][]=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]); order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching // A subscription can hold dozens of feeds, so a folder starts closed. Searching
@@ -127,14 +127,14 @@ function renderFeeds(){
// A subscription holds no entries itself, so its counts are the sum of what is inside -- // 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. // 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 mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0); const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')] ? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded]; : [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself // 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 // 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. // went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error; const bad=(c: Feed)=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length; const nbad=mine.filter(bad).length;
const el=document.createElement('div'); const el=document.createElement('div');
@@ -160,14 +160,14 @@ function renderFeeds(){
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (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>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(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); } for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el); list.appendChild(el);
} }
paintScanning(); paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id: string){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{} try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries(); renderFeeds(); renderFeed(); loadEntries();
@@ -176,14 +176,14 @@ function selectFeed(id){
/// A failing feed's error, in plain words with something to do about it, once `failing` is /// 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 /// 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. /// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){ function failBannerHTML(f: Feed){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)} 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>${ <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>`; 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>`; if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return ''; return '';
} }
function wireFailBanner(box,f){ function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(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); const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
} }

View File

@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q); if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq; const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}` const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking // Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one. // Unread straight after could have that answer land last and replace the Unread one.
@@ -21,7 +21,7 @@ async function loadEntries(append?: boolean){
// tab -- would drop the item you have open the moment reading it took it off the filter. // tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it. // Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them. // The items turned past on the way to it are kept too, so a swipe back still finds them.
const kept=e=>e.guid===S.sel||turned.has(e.guid); const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e)); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){ if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid)); const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -53,7 +53,7 @@ function renderEntries(){
box.appendChild(b); box.appendChild(b);
} }
} }
function epEl(e){ function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on -- // An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how // the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all. // many others there are; the pane below lists them all.
@@ -97,13 +97,13 @@ function epEl(e){
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''} ${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`; </div>`;
el.onclick=()=>selectEntry(e); el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)}); $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el; return el;
} }
/// Whether the browser can play it. Having a file is not the same as being playable: /// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG /// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player. /// is just a broken player.
function isPlayable(x){ function isPlayable(x: Enclosure){
if(!x || !x.path) return false; if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase(); const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true; if(m.startsWith('audio/')||m.startsWith('video/')) return true;
@@ -114,7 +114,7 @@ function isPlayable(x){
} }
/// What an enclosure is, for a row that is not media: "image", "pdf", "document". /// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){ function kindOf(enc: {mime?: string|null, url?: string}){
const m=(enc.mime||'').toLowerCase(); const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image'; if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video'; if(m.startsWith('video/')) return 'video';
@@ -128,9 +128,9 @@ function kindOf(enc){
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when /// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon /// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip. /// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){ function kindIcon(enc: Enclosure){
const k=kindOf(enc); const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file; const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`] const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`] : enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k]; : ['',k];
@@ -138,7 +138,7 @@ function kindIcon(enc){
} }
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; } function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; }; const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional, /// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's /// and Scripting News titles almost none of its posts) the opening of its text, then its file's
@@ -150,7 +150,7 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
const plainText=(html?: string)=>html const plainText=(html?: string)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'') ? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : ''; .replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{ function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false}; if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description); const words=plainText(e.description);
if(words){ if(words){
@@ -164,7 +164,7 @@ function entryName(e): {text: string, derived: boolean}{
/// Selecting an item shows it in the pane below, rather than expanding the row. /// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone. /// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){ function swapRow(e: Entry){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`); const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); } if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
} }
@@ -172,9 +172,9 @@ function swapRow(e){
/// Read and unread as this page last set them, and when the server had it. A list asked for /// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until /// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries). /// the next refresh took it off again (loadEntries).
const readWrites=new Map(); const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=e=>e.feed_id+'\n'+e.guid; const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e,read){ function setRead(e: Entry,read: boolean){
e.read=read; e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
@@ -184,7 +184,7 @@ function setRead(e,read){
/// Opening an item is reading it. The row is redrawn where it stands rather than the list /// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab. /// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){ function markRead(e: Entry){
if(e.read) return; if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
} }
@@ -193,7 +193,7 @@ function markRead(e){
/// in the list until the reader closes or another item is picked from the list. Dropped as each /// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone. /// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>(); const turned=new Set<string>();
function dropTurned(keep){ function dropTurned(keep: string|null){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep); const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear(); turned.clear();
if(!gone.length) return; if(!gone.length) return;
@@ -201,7 +201,7 @@ function dropTurned(keep){
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove(); for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
} }
function selectEntry(e,turning=false){ function selectEntry(e: Entry,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh // On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while. // next happens to come along, which left a few read ones in the list for a while.
if(S.filter==='unread' && S.sel && S.sel!==e.guid){ if(S.filter==='unread' && S.sel && S.sel!==e.guid){
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
} }
/// Drag the divider between the item list and the item text. /// Drag the divider between the item list and the item text.
function dragSplit(pane){ function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane); const grab=$('#grab',pane);
if(!grab) return; if(!grab) return;
const move=ev=>{ const move=(ev: MouseEvent)=>{
const box=pane.getBoundingClientRect(); const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100)); const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%'); pane.style.setProperty('--listh',pct.toFixed(1)+'%');
@@ -231,7 +231,7 @@ function dragSplit(pane){
document.body.style.userSelect=''; document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{} try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
}; };
grab.onmousedown=ev=>{ grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault(); ev.preventDefault();
document.body.style.userSelect='none'; document.body.style.userSelect='none';
document.addEventListener('mousemove',move); document.addEventListener('mousemove',move);
@@ -243,7 +243,7 @@ function dragSplit(pane){
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick // ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens. // the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel); const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){ function syncTools(e: Entry|null){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e; $('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. Each // The same icons as the item's own buttons beside its title, so the two never disagree. Each
@@ -260,7 +260,7 @@ function syncTools(e){
} }
/// The item's text in the pane below the list, and its files in the pane beside it. /// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){ function showDetail(e: Entry){
const box=$('#detail'), files=$('#files'); if(!box) return; const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead. // A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length); if(files) files.hidden=!(e&&e.enclosures.length);
@@ -286,7 +286,7 @@ function showDetail(e){
/// What the reader shows for an item. Its own function because a phone also draws the next or /// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts). /// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){ function detailHtml(e: Entry){
const encs=e.enclosures.map(encBox).join(''); const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead. // A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone // Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -317,7 +317,7 @@ function detailHtml(e){
} }
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it. /// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){ function encBox(x: Enclosure){
const size=x.length?mb(x.length):''; const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act. // One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id); const f=S.feeds.find(y=>y.id===x.feed_id);
@@ -369,7 +369,7 @@ function encBox(x){
</div>`; </div>`;
} }
async function epAction(a: string, e, el, encId?: number){ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read. // Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); }; const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0]; const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];

View File

@@ -1,11 +1,11 @@
/* ---------------- player ---------------- */ /* ---------------- player ---------------- */
const audio=$('#audio'); const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean, const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it /// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself. /// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`; const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it /// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails /// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{ return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192). // Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())}); hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true); hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } }); report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio); hls.loadSource(url); hls.attachMedia(audio);
}); });
@@ -49,7 +49,7 @@ function markPlayed(){
} }
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one. /// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){ if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true); toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return; return;
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false; player.marked=false;
paintDead(); paintDead();
} }
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true)); const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries(); renderEntries();
} }
function mediaSession(e,f){ function mediaSession(e: Entry,f: Feed|undefined){
if(!('mediaSession' in navigator)) return; if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({ navigator.mediaSession.metadata=new MediaMetadata({
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'', title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[], artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
}); });
const h={play:()=>audio.play(),pause:()=>audio.pause(), const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} } for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
} }
@@ -130,8 +130,8 @@ for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButt
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files /// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played. /// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){ function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); const on=(guid: string,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{ const paint=(b: HTMLElement,now: boolean,idle: string)=>{
const label=now?'Pause':idle; const label=now?'Pause':idle;
if(b.title===label) return; if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play; b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
@@ -160,9 +160,9 @@ audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30; $('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $('#seek').oninput=(e: Event & {target: HTMLInputElement})=>{const d=audio.duration;if(d)audio.currentTime=d*Number(e.target.value)/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)}; $('#rate').onchange=(e: Event & {target: HTMLSelectElement})=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)}; $('#vol').oninput=(e: Event & {target: HTMLInputElement})=>{audio.volume=Number(e.target.value)/100;localStorage.setItem('ipx.vol',e.target.value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the /// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour. /// icon's shape and aria-pressed, never a colour.
function paintDead(){ function paintDead(){
@@ -195,7 +195,7 @@ async function switchVersion(){
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{}); secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false; player.moved=false;
} }
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null); const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc); const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false; if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused; const was=!audio.paused;
@@ -230,22 +230,22 @@ document.addEventListener('keydown',ev=>{
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a // Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's // letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open. // own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',l:':listening'}; const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
let gAt=0; let gAt=0;
function stepEntry(by){ function stepEntry(by: number){
if(VIEWS[S.feed]?.url||!S.entries.length) return; if(VIEWS[S.feed]?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel); const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e,true); selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
} }
function stepFeed(by){ function stepFeed(by: number){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id; const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return; if(!id) return;
selectFeed(id); selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
} }
const KEYS={ const KEYS: Record<string,()=>void>={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1), J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while // The toolbar's own buttons, so a key does exactly what the click does, and nothing while
@@ -259,7 +259,7 @@ const KEYS={
'?':()=>keysModal(), '?':()=>keysModal(),
g:()=>{ gAt=Date.now(); }, g:()=>{ gAt=Date.now(); },
}; };
function typed(ev){ function typed(ev: KeyboardEvent){
// The second key of a g pair counts only if it follows within a second and a half. // The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0; const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key]; const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
@@ -268,7 +268,7 @@ function typed(ev){
} }
/// What ? shows: every key, grouped as Feedly's own list is. /// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){ function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c); const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
const rows=[ const rows=[
['Go to'], ['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'], [g('a'),'All Subscriptions'],[g('d'),'Directory'],

View File

@@ -113,3 +113,41 @@ interface State {
me: Me | null; me: Me | null;
sort: {col: string, dir: 'asc' | 'desc'}; sort: {col: string, dir: 'asc' | 'desc'};
} }
/// An account, `/api/users` (web.rs list_users).
interface Account {
id: number;
name: string;
admin: boolean;
/// Has a password; none signs in through the proxy.
password: boolean;
created: number | null;
last_login: number | null;
}
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
interface LogLine {
seq: number;
ts: number;
level: string;
target: string;
msg: string;
}
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
/// (directory lib.rs Instance, Blocked).
interface DirAdmin {
instances: {name: string, feeds: number, subscribers: number}[];
blocked: {id: string, url: string | null, title: string | null, at: number}[];
}
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
/// its files.
interface ListedItem {
guid: string;
title: string | null;
link: string | null;
published: number | null;
duration: number | null;
description: string | null;
}

View File

@@ -5,13 +5,14 @@ const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)]; const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know. /// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e); const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const ESCAPED: Record<string,string> = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com // Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the // License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes // ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag. // the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`; const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={ const ICON: Record<string,string>={
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
@@ -66,9 +67,9 @@ for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.datas
/// becomes how much shorter it is once the copy is ready (the dead_air event). /// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>'; const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
/// What DeadAir took off an episode, as the list says it. /// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; } function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s". /// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){ function spanText(secs: number){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60); const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`; return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
} }
@@ -107,12 +108,12 @@ async function api(url: string, opts?: RequestInit): Promise<any>{
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
// whole 502 page (#219). // whole 502 page (#219).
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim()); if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
return r.status===204?null:r.json().catch(()=>null); return r.status===204?null:r.json().catch((): null=>null);
} }
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN // navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy. // address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){ async function copyText(text: string,btn?: HTMLElement){
const flash=ok=>{ const flash=(ok: boolean)=>{
if(!btn) return; if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text. // The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML; const was=btn.innerHTML;
@@ -146,7 +147,7 @@ async function copyText(text,btn){
/// ///
/// ponytail: a guess from the address. A private feed whose key has another name is shared /// ponytail: a guess from the address. A private feed whose key has another name is shared
/// without asking; add its pattern here when one turns up. /// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){ async function share(title: string, url: string, btn?: HTMLElement){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url) if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
&& !confirm('This address looks like it includes your own access to the feed, and anyone you ' && !confirm('This address looks like it includes your own access to the feed, and anyone you '
+'send it to could use it as you.\n\nShare it anyway?')) return; +'send it to could use it as you.\n\nShare it anyway?')) return;
@@ -166,12 +167,12 @@ function toast(msg: string, bad?: boolean){
$('#toasts').appendChild(t); $('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200); setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
} }
const clock = s => { const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0)); s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60; const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`; return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
}; };
const ago = t => { const ago = (t: number|null) => {
if(!t) return 'never'; if(!t) return 'never';
const d=(Date.now()/1000)-t; const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago'; if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
@@ -179,16 +180,16 @@ const ago = t => {
if(d<2592000) return Math.round(d/86400)+'d ago'; if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
}; };
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):''; const dateOf = (t: number|null) => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB". // A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB' const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB'; : n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?'; const initials = (s: string|null) => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`; const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match. // An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))']; const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; }; const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){ function tileHTML(name: string,cls?: string){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`; return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
} }
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and /// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
@@ -203,7 +204,7 @@ function artHTML(url: string | null, name: string, cls?: string){
: tileHTML(name,cls); : tileHTML(name,cls);
} }
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. /// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){ function folderArt(f: Feed,kids: Feed[]){
const art=kids.filter(c=>c.image).slice(0,4); const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id); if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole. // Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
@@ -212,7 +213,7 @@ function folderArt(f,kids){
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; } function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */ /* ---------------- state ---------------- */
const S: State = { const S: State = {
@@ -230,19 +231,19 @@ const S: State = {
const LIMIT = 50; const LIMIT = 50;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". /// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){ function splitEvery(m: number){
if(!m) return {n:1, u:'h'}; if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'}; return {n:m, u:'m'};
} }
function unitOptions(sel){ function unitOptions(sel: string){
return UNITS.map(([v,l]) => return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join(''); `<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
} }
function everyText(m){ function everyText(m: number){
if(!m) return '\u2014'; if(!m) return '\u2014';
const {n,u} = splitEvery(m); const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; const name = {m:'min', h:'hour', d:'day', w:'week'}[u];