From 60007d4e6a11d33f2895a9fe26969a779da0f7be Mon Sep 17 00:00:00 2001 From: rays Date: Wed, 14 Oct 2026 17:50:33 +0000 Subject: [PATCH] Page type-check: strict throughout, a typed $, null checks (#236) $ returns the element type it is given and assumes the element is there: 122 lookups take their type from the tag their id is on in the markup. A handler reads its target through a cast, since a typed element will not take a handler whose event names a narrower target. strictNullChecks found no bug the page had; its 94 places were values the code already handles in ways the type-check could not follow (data- attributes the page writes, a map entry set the line before, Share drawn only for an item with a link), each now said so. isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it is also false for a file that is not audio or video. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++- tsconfig.json | 11 +--- web/src/admin.ts | 48 +++++++------- web/src/dialogs.ts | 148 ++++++++++++++++++++++---------------------- web/src/events.ts | 2 +- web/src/feedpage.ts | 20 +++--- web/src/feeds.ts | 18 +++--- web/src/gestures.ts | 2 +- web/src/items.ts | 47 +++++++------- web/src/login.ts | 4 +- web/src/player.ts | 54 ++++++++-------- web/src/theme.ts | 10 +-- web/src/util.ts | 12 ++-- 13 files changed, 191 insertions(+), 193 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1df60cd..8136714 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,9 +154,11 @@ node tests/page-smoke.js node --test tests/page-unit.js ``` -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 +The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a +type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes +with them, and a value that may be null has to be checked before use. `$` returns the element +type it is given (`$('#nurl')`) and assumes the element is there; one that may +not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second 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 function, `String.replace` silently matched nothing, and the whole UI died with a diff --git a/tsconfig.json b/tsconfig.json index 7b0a708..af7a0d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,14 +5,9 @@ "target": "es2022", "lib": ["es2022", "dom", "dom.iterable"], "noEmit": true, - // strict, a flag at a time (#235); strictNullChecks still to come, with a typed $ (#236). - "strict": false, - "noImplicitAny": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "noImplicitThis": true, - "alwaysStrict": true, - "useUnknownInCatchVariables": true, + // Strict throughout (#235, #236). $ assumes the element is there: the page check proves the + // ones wired at load exist, and a lookup that may find nothing says so with ?. + "strict": true, "skipLibCheck": true }, "include": ["web/src/*.ts"] diff --git a/web/src/admin.ts b/web/src/admin.ts index 99dac6e..e1d64e4 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -56,15 +56,15 @@ async function drawServer(){
${esc(g.download_dir)}
`; - $('#gsave').onclick = async () => { + $('#gsave').onclick = async () => { try{ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ - schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, - max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), - media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean), - max_total_gb: Number($('#gquota').value) || 0, - max_age_days: Number($('#gage').value) || 0, - art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); + schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, + max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), + media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean), + max_total_gb: Number($('#gquota').value) || 0, + max_age_days: Number($('#gage').value) || 0, + art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); toast('Settings saved'); }catch(e){ toast(why(e), true); } }; @@ -101,13 +101,13 @@ async function drawDirectorySettings(){ drawDirectorySettings(); }catch(e){ toast(why(e), true); } }; - $('#dsave').onclick = () => { - const patch: Record = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked}; + $('#dsave').onclick = () => { + const patch: Record = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked}; // Blank leaves the token as it is; Clear is the way to remove it. - if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim(); + if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim(); save(patch, 'Directory settings saved'); }; - const clear = $('#dclear'); + const clear = $('#dclear'); if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared'); drawDirectoryStatus(); } @@ -161,12 +161,12 @@ async function drawDirectoryAdmin(){ const row = b.closest('.urow'); try{ if(b.dataset.a === 'drop'){ - const name = row.dataset.name; + const name = row!.dataset.name!; if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return; await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'}); toast(`Dropped ${name}'s snapshot`); }else if(b.dataset.a === 'unblock'){ - await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'}); + await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'}); toast('Unblocked. It comes back with the next snapshot that has it'); } drawDirectoryAdmin(); @@ -205,18 +205,18 @@ async function drawAccounts(){ drawAccounts(); // on a refusal, this puts the checkbox back where the server left it }; for(const row of $$('#accounts [data-id]')){ - const u = users.find(x => String(x.id) === row.dataset.id); - $('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) => - change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); + const u = users.find(x => String(x.id) === row.dataset.id)!; + $('[data-a="admin"]', row).onchange = (e: Event) => + change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})}); $('[data-a="rm"]', row).onclick = () => { if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) change(u, {method: 'DELETE'}); }; } - $('#uadd').onclick = async () => { + $('#uadd').onclick = async () => { try{ await api('/api/users', {method: 'POST', body: JSON.stringify({ - name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); + name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); toast('Added'); drawAccounts(); }catch(e){ toast(why(e), true); } // keep what was typed }; @@ -259,14 +259,14 @@ function drawLogView(){ The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes what it captures.`; for(const b of $$('#logtabs button')) b.onclick = () => { - logTab = b.dataset.t; + logTab = b.dataset.t!; for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); drawLog(); }; - $('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); }; - $('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); }; - $('#logcopy').onclick = () => copyText(visibleLog().map(l => - `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); + $('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); }; + $('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); }; + $('#logcopy').onclick = () => copyText(visibleLog().map(l => + `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); pollLog(); if(!logTimer) logTimer = setInterval(pollLog, 2000); } @@ -294,7 +294,7 @@ function visibleLog(){ } function drawLog(){ const box = $('#logbox'); if(!box) return; - const follow = $('#logfollow')?.checked; + const follow = $('#logfollow')?.checked; const rows = visibleLog(); box.innerHTML = rows.length ? rows.map(l => { const t = new Date(l.ts * 1000).toLocaleTimeString(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index f0589fd..23f72d3 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -11,7 +11,7 @@ function closeModal(){ $('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); }; -$('#addFeed').onclick=()=>{ +$('#addFeed').onclick=()=>{ openModal(`

Add a feed

A Patreon token on its own adds every show from that creator.
@@ -20,17 +20,17 @@ $('#addFeed').onclick=()=>{
`); - $('#nurl').focus(); - $('#nsave').onclick=async()=>{ - const url=$('#nurl').value.trim(); if(!url) return; - $('#nsave').disabled=true; $('#nsave').title='Adding…'; + $('#nurl').focus(); + $('#nsave').onclick=async()=>{ + const url=$('#nurl').value.trim(); if(!url) return; + $('#nsave').disabled=true; $('#nsave').title='Adding…'; try{ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ - url, allow_explicit:$('#nexp').checked, - keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})}); + url, allow_explicit:$('#nexp').checked, + keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})}); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${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; } }; }; @@ -154,18 +154,18 @@ function drawDirectory(box=$('#directory'), focus?: string){ html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid; }else if(!dirCat){ chart=top(rows); - const cats=new Map(); - for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); } + const cats=new Map(); + for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); } // Other, the feeds with no category, goes last. const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b)); html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`) +(chart.length?sec('Most subscribed',board):'') - +sec('Categories',`
${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}
`, + +sec('Categories',`
${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}
`, ``); }else{ const inCat=rows.filter(of(dirCat)); - const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort(); - if(!subs.includes(dirSub)) dirSub=null; + const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort(); + if(!dirSub||!subs.includes(dirSub)) dirSub=null; if(dirCat!==EVERY) chart=top(inCat); tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v); @@ -180,23 +180,23 @@ function drawDirectory(box=$('#directory'), focus?: string){ const c=$('#dchart',box); 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); - if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); + if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); box.onclick=(ev: MouseEvent)=>{ const b=(ev.target as Element).closest('[data-go],[data-kind],[data-sub],[data-back]'); if(!b) return; // Back from a feed's page, onto the tile or row it was opened from. - if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); } + if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); } if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); } if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); } // Going anywhere ends a search. - if(S.q){ S.q=''; $('#epSearch').value=''; } + if(S.q){ S.q=''; $('#epSearch').value=''; } const from=dirCat; dirCat=b.dataset.go||null; dirSub=null; dirShow=null; $('#content').scrollTop=0; // Into a page, onto its way back; back out, onto the tile you went in by. drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined); }; - const s=$('#dirsort',box); + const s=$('#dirsort',box); if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); }; if(focus) $(focus,box)?.focus(); } @@ -268,17 +268,17 @@ function catTile(c: string,name: string,count: string,rows: DirFeed[]){ async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){ const box=$('#content'); box.classList.add('plain'); - $('#tbRemove').disabled=true; + $('#tbRemove').disabled=true; syncTools(null); if(v===VIEWS[':directory']){ - $('#epSearch').placeholder='Find a feed…'; + $('#epSearch').placeholder='Find a feed…'; box.innerHTML='

Loading…

'; $('#count').textContent=v.title; - const n=await renderDirectory(v.url,$('#directory')); - if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; + const n=await renderDirectory(v.url!,$('#directory')); + if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; return; } - $('#epSearch').placeholder='Search items…'; + $('#epSearch').placeholder='Search items…'; box.innerHTML=`
${v.icon}
@@ -287,8 +287,8 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb

Loading…

`; $('#count').textContent=v.title; // The search box looks through these too, title and text, as it does a feed's items (#127). - const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box)); - if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; + const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box)); + if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; } /// Currently Listening: episodes you started and have not finished, across every feed you @@ -320,7 +320,7 @@ async function renderListening(url: string,box: HTMLElement){ /// One row's time left, progress and play button, taken from the player when it is the one in it. 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. if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); // The player's own length first: a feed's can be minutes out. @@ -335,7 +335,7 @@ function paintListenRow(el: HTMLElement & {entry?: Entry}){ } /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. function syncListening(){ - for(const el of $$('#listening .childrow')) + for(const el of $$('#listening .childrow')) if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); } @@ -343,7 +343,7 @@ function syncListening(){ /// episode with a saved position short of the end, so the position is what has to go. async function forget(e: Entry){ // 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{ await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, {method:'POST',body:JSON.stringify({secs:0})}); @@ -352,15 +352,15 @@ async function forget(e: Entry){ } // The toolbar acts on whatever is selected: the feed on the left, the item in the table. -$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; -$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; -$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; -$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; +$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; +$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; +$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; +$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; let searchT: number; -$('#epSearch').oninput=(ev: Event & {target: HTMLInputElement})=>{ clearTimeout(searchT); +$('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT); // The Directory looks through what it already has; Currently Listening and the rest ask the // server (#126, #127). - searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; + searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0; if(S.feed===':directory'){ dirShow=null; drawDirectory(); } else if(S.feed===':listening') renderListed(VIEWS[':listening']); else loadEntries(); },250); }; @@ -429,27 +429,27 @@ async function prefsModal(){
${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 admin page.':'Only an admin changes this.'}
`); - $('#stheme').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(e.target.value,undefined,true); - $('#smode').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(undefined,e.target.value,true); - $('#gopml').onclick=opmlModal; + $('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true); + $('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true); + $('#gopml').onclick=opmlModal; drawTokens(); - $('#tokadd').onclick=async()=>{ - const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; } + $('#tokadd').onclick=async()=>{ + const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; } try{ const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})}); - $('#tokname').value=''; + $('#tokname').value=''; await drawTokens(); // Shown this once: only its hash is kept, so a lost token is revoked and made again. $('#tokens').insertAdjacentHTML('afterbegin',`
Your new token, ${esc(t.name)}. Copy it now: it is not shown again.
`); - $('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy')); - $('#tokval').select(); + $('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy')); + $('#tokval').select(); }catch(err){ toast(why(err),true); } }; - $('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{ - const blocked=splitWords(e.target.value); + $('#sblock').onchange=async (e: Event)=>{ + const blocked=splitWords((e.target as HTMLInputElement).value); try{ await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})}); if(S.me) S.me.blocked=blocked; @@ -487,14 +487,14 @@ function chipsHTML(words: string[], input: string){ } /// The words in a chip box, with whatever is typed and not yet a chip. 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: HTMLElement){ - const input=$('.chipin',box); + const input=$('.chipin',box); // The hint only in an empty box: beside chips it reads as one more. const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; }; const add=()=>{ - const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase()); + const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase()); for(const w of splitWords(input.value)){ if(have.includes(w.toLowerCase())) continue; input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase()); @@ -509,7 +509,7 @@ function wireChips(box: HTMLElement){ input.onblur=add; box.onclick=(e: MouseEvent)=>{ const x=(e.target as Element).closest('.chipx'); - if(x){ x.parentElement.remove(); hint(); } + if(x){ x.parentElement!.remove(); hint(); } input.focus(); }; } @@ -524,7 +524,7 @@ function settingsModal(f: Feed, newUrl?: string){ const name=f.title||f.id; // Blank follows the server's default; 0 is every item (#166). let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest'; - let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3); + let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3); const whose=isGroup ? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.` : parent @@ -576,7 +576,7 @@ function settingsModal(f: Feed, newUrl?: string){ // What only matters while it downloads on its own folds away, out of reach, when it does not. const deps=$('#sdeps'), inner=$('.fdepsin',deps); inner.inert=!f.auto_download; - $('#sauto').onchange=(e: Event & {target: HTMLInputElement})=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; }; + $('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; }; // 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). @@ -605,13 +605,13 @@ function settingsModal(f: Feed, newUrl?: string){ } if(!words) return; $('#swcap').innerHTML=caption(); - const n=$('#smax'); + const n=$('#smax'); if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); }; }; - 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. - $('#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); }; + $('#sdead').onchange=(e: Event)=>{ $('#sdpause').disabled=!(e.target as HTMLInputElement).checked; }; + const pause=$('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); }; pause.oninput=pauseText; pauseText(); draw(true); // The feed's own newest items, each as tall as it is long, so the strip reads as this show. @@ -634,8 +634,8 @@ function settingsModal(f: Feed, newUrl?: string){ if(x<=b.getBoundingClientRect().right) break; } newest=Math.max(1,n); - if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } - else { $('#smax').value=String(newest); draw(false); } + if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } + else { $('#smax').value=String(newest); draw(false); } }; strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; @@ -643,24 +643,24 @@ function settingsModal(f: Feed, newUrl?: string){ wireChips($('#skw')); if($('#sblock')) wireChips($('#sblock')); - $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); + $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); // Opened to follow a feed that moved: the new address is what there is to look at. - if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); } + if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); } - $('#ssave').onclick=async()=>{ + $('#ssave').onclick=async()=>{ try{ const patch: Record={ keywords:chipWords($('#skw')), max_new_per_check:mode==='default'?null:mode==='all'?0:newest, - auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked, - deadair_pause:Number($('#sdpause').value)}; + auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked, + deadair_pause:Number($('#sdpause').value)}; if($('#sblock')) patch.blocked=chipWords($('#sblock')); // Sent only when it changed: anyone may mark a feed private, but only an admin unmark it. - const priv=$('#sprivate'); + const priv=$('#sprivate'); if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked; // The address is the same for everyone: an admin's to change, for a feed that moved, and the // API refuses it from anyone else. The folder and category are not this dialog's at all. - if(admin) patch.url=$('#surl').value.trim(); + if(admin) patch.url=$('#surl').value.trim(); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)}); closeModal(); toast('Saved. It applies from the next check.'); await loadFeeds(true); renderFeed(); loadEntries(); @@ -675,8 +675,8 @@ function downloadLatestModal(f: Feed){ Queued immediately, ignoring the per-scan limit.
`); - $('#dgo').onclick=async()=>{ - const n=Number($('#dcount').value)||5; + $('#dgo').onclick=async()=>{ + const n=Number($('#dcount').value)||5; try{ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`, {method:'POST',body:JSON.stringify({count:n})}); @@ -692,7 +692,7 @@ function removeFeed(f: Feed){ Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.

`); - $('#rgo').onclick=async()=>{ + $('#rgo').onclick=async()=>{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); closeModal(); toast('Unsubscribed'); S.feed=null; await loadFeeds(); if(!S.feeds.length) renderFeed(); @@ -711,11 +711,11 @@ function opmlModal(){
`); - $('#oimp').onclick=async()=>{ + $('#oimp').onclick=async()=>{ // A chosen file is read here and sent as text, so the server never stores it. Clearing // the picker lets go of it on this side too, whether it was refused or imported. - const pick=$('#opmlFile'), file=pick.files[0]; - const xml=file ? await file.text() : $('#opmlText').value; + const pick=$('#opmlFile'), file=pick.files![0]; + const xml=file ? await file.text() : $('#opmlText').value; const letGo=()=>{ pick.value=''; }; // A quick look before sending anything. The server parses it properly and has the last word. if(!/]/i.test(xml)){ @@ -734,11 +734,11 @@ async function scanAll(){ try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } catch(e){ spinEnd(); throw e; } } -$('#scanAll').onclick=scanAll; -$('#prefs').onclick=prefsModal; +$('#scanAll').onclick=scanAll; +$('#prefs').onclick=prefsModal; // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // the proxy still vouches for them. /api/me says where, when that is the case. -$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; }; +$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; }; api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); @@ -750,18 +750,18 @@ api('/api/me').then(u=>{ // A browser with no theme of its own takes the one the account kept from before, once. if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true); }).catch(()=>{}); -$('#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 // folder. Handled keys stop here, or the player's own Space and arrows would act on them too. $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{ const row=(ev.target as Element).closest('[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(); else if(row.classList.contains('group')&& (ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id); else return; ev.preventDefault(); ev.stopPropagation(); }; -$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); +$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $('#scrim').onclick=()=>nav(false); diff --git a/web/src/events.ts b/web/src/events.ts index 33ec0ea..7aa81d1 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -26,7 +26,7 @@ function connect(){ // Only the row actually downloading. Without the enclosure id this used to paint // every pending bar at once, so adding a feed looked like it was fetching the lot. const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"] i`); - if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); } + if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); } $('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`); } else if(ev.ev==='download_done'){ diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index 967393c..601dd08 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -3,16 +3,16 @@ function renderFeed(){ paintScanning(); // another feed's page may be the one open now const box=$('#content'); box.classList.remove('plain'); - const v=VIEWS[S.feed]; + const v=VIEWS[S.feed ?? '']; if(v&&v.url) return renderListed(v); const f=v?null:S.feeds.find(x=>x.id===S.feed); - $('#tbRemove').disabled=!f; + $('#tbRemove').disabled=!f; syncTools(null); if(!v&&!f){ box.innerHTML='

Add a feed to get started.

'; $('#count').textContent=''; return; } const name=f?(f.title||f.id):v.title; - $('#epSearch').placeholder=`Search ${name}…`; + $('#epSearch').placeholder=`Search ${name}…`; const kids=f?S.feeds.filter(c=>c.group===f.id):[]; - if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; } + if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; } const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0); box.innerHTML = (f ? `
@@ -61,7 +61,7 @@ function renderFeed(){ if(f) pane.className='one'; pane.innerHTML='
'+sortHead()+ '
'; - $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); + $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!)); box.appendChild(pane); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%'); dragSplit(pane); @@ -69,9 +69,9 @@ function renderFeed(){ S.sel=null; showDetail(null); - $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); + $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!)); $$('#content .tabs button').forEach(b=>b.onclick=()=>{ - S.filter=b.dataset.f; S.offset=0; + S.filter=b.dataset.f!; S.offset=0; try{ localStorage.setItem('ipx.filter',S.filter); }catch{} renderFeed(); loadEntries(); }); @@ -132,10 +132,10 @@ function renderGroup(f: Feed,kids: Feed[]){ ${esc(f.url)}
`; - $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); + $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f)); wireFailBanner($('#content'),f); const draw=()=>{ - const q=($('#kidSearch').value||'').trim().toLowerCase(); + const q=($('#kidSearch').value||'').trim().toLowerCase(); const box=$('#kidlist'); box.innerHTML=''; const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst); if(!rows.length){ box.innerHTML='

Nothing matches.

'; return; } @@ -154,7 +154,7 @@ function renderGroup(f: Feed,kids: Feed[]){ box.appendChild(el); } }; - $('#kidSearch').oninput=draw; + $('#kidSearch').oninput=draw; draw(); } diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 6706cd8..5f8a829 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -19,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){ // brand new account) leaves S.feed alone, so the empty state's own message shows instead. if(!keepSel && S.feeds.length){ const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed)); - selectFeed(known ? S.feed : ':all'); + selectFeed(known ? S.feed! : ':all'); } } // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the @@ -45,9 +45,9 @@ function setScanning(id: string, on: boolean){ paintScanning(); } function paintScanning(){ - const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER; + const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER; for(const row of $$('#feedlist .feed')){ - const id=row.dataset.id; + const id=row.dataset.id!; const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id)); row.classList.toggle('scanning',on); if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); @@ -73,21 +73,21 @@ function spinEnd(done?: {only: string|null, forced: boolean}){ } function renderFeeds(){ - const q=$('#feedFilter').value.trim().toLowerCase(); + const q=$('#feedFilter').value.trim().toLowerCase(); const list=$('#feedlist'); const top=list.scrollTop; // Every row is replaced, so put the keyboard back on the row, or the triangle, it was on. const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]'); const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]); const done=()=>{ list.scrollTop=top; - const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`); - if(row) (back[1]&&$('.chev',row)||row).focus(); + const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`); + if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus(); }; // The images already drawn, to go into the rows that replace theirs. Made anew, every icon in // the list went blank and loaded again, once a frame through a check of every feed, as each // feed's new row came in (#134). Matched by their markup, so a changed one is made anew. const drawn=new Map(); - for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); } + for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); } list.innerHTML=''; const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); const places=document.createElement('div'); @@ -168,7 +168,7 @@ function renderFeeds(){ done(); } 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{} renderFeeds(); renderFeed(); loadEntries(); } @@ -185,6 +185,6 @@ function failBannerHTML(f: Feed){ } function wireFailBanner(box: ParentNode,f: Feed){ const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f); - const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url); + const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined); } diff --git a/web/src/gestures.ts b/web/src/gestures.ts index c71cacf..9c8cd34 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => { // back button is never swallowed with it. if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault(); } - else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0}; + else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0}; }, {passive: false}); document.addEventListener('touchmove', ev => { diff --git a/web/src/items.ts b/web/src/items.ts index cc9d49a..92bf112 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -14,7 +14,7 @@ async function loadEntries(append?: boolean){ S.total=r.total; for(const e of r.entries){ const w=readWrites.get(readKey(e)); - if(w && !(w.donex.id===S.feed), v=VIEWS[S.feed]; + const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? '']; const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); $('#count').textContent= `${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; @@ -78,7 +78,7 @@ function epEl(e: Entry){ num&&`${num}`, left&&`${left}`, others>0&&`+${others} more file${others===1?'':'s'}`, - enc&&enc.deadair>0&&`${shorter(enc.deadair)}`, + enc&&enc.deadair&&enc.deadair>0&&`${shorter(enc.deadair)}`, enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, ].filter(Boolean).join('')} @@ -97,13 +97,13 @@ function epEl(e: Entry){ ${has?``:''} `; el.onclick=()=>selectEntry(e); - $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{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; } /// 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