From ec2b9e87b8051fb0d01df6b325b7433bc6ee36ad Mon Sep 17 00:00:00 2001 From: rays Date: Wed, 14 Oct 2026 15:57:05 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 4 +- tsconfig.json | 3 +- web/src/admin.ts | 22 ++++---- web/src/dialogs.ts | 122 ++++++++++++++++++++++---------------------- web/src/events.ts | 8 +-- web/src/feedpage.ts | 10 ++-- web/src/feeds.ts | 22 ++++---- web/src/items.ts | 50 +++++++++--------- web/src/player.ts | 38 +++++++------- web/src/types.ts | 38 ++++++++++++++ web/src/util.ts | 47 ++++++++--------- 11 files changed, 203 insertions(+), 161 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ed861e..1df60cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,9 @@ node tests/page-smoke.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 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 aa46f77..7b0a708 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,9 @@ "target": "es2022", "lib": ["es2022", "dom", "dom.iterable"], "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, + "noImplicitAny": true, "strictFunctionTypes": true, "strictBindCallApply": true, "noImplicitThis": true, diff --git a/web/src/admin.ts b/web/src/admin.ts index bebb4a0..99dac6e 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -61,7 +61,7 @@ async function drawServer(){ 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 => t.trim()).filter(Boolean), + 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)})}); @@ -137,7 +137,7 @@ async function drawDirectoryStatus(){ /// sharing with it, and the feeds removed from it. async function drawDirectoryAdmin(){ const box = $('#dadmin'); - let a; + let a: DirAdmin; try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `

${esc(why(e))}

`; return; } if(!box.isConnected) return; box.innerHTML = `

Servers

@@ -155,7 +155,7 @@ async function drawDirectoryAdmin(){ Removed ${dateOf(b.at)} `).join('') : '

Nothing is blocked.

'}`; - box.onclick = async ev => { + box.onclick = async (ev: MouseEvent) => { const b = (ev.target as Element).closest('[data-a]'); if(!b) return; const row = b.closest('.urow'); @@ -177,7 +177,7 @@ async function drawDirectoryAdmin(){ /* ---------------- accounts ---------------- */ async function drawAccounts(){ const box = $('#accounts'); - const users = await api('/api/users') || []; + const users: Account[] = await api('/api/users') || []; box.innerHTML = `

Accounts

${users.map(u => `
${esc(u.name)} @@ -195,7 +195,7 @@ async function drawAccounts(){ At least 8 characters. Leave the password empty for someone who signs in through the proxy. New people start with no feeds.
`; - const change = async (u, opts) => { + const change = async (u: Account, opts: RequestInit) => { try{ await api(`/api/users/${u.id}`, opts); // 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]')){ 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})}); $('[data-a="rm"]', row).onclick = () => { if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) @@ -223,16 +223,16 @@ async function drawAccounts(){ } /* ---------------- 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 // command in and every event out, whatever sent it. -const LOG_TABS = { +const LOG_TABS: Record boolean)|null> = { all: null, daemon: t => t === 'ipx::io', scan: t => t === 'ipx::scan', web: t => t === 'ipx::http', }; -const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; +const LEVELS: Record = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; function drawLogView(){ logSeq = 0; logLines = []; @@ -263,8 +263,8 @@ function drawLogView(){ for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); drawLog(); }; - $('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; - $('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); 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')); pollLog(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index e4bb214..f0589fd 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -9,7 +9,7 @@ function closeModal(){ $('#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=()=>{ openModal(`

Add a feed

@@ -27,7 +27,7 @@ $('#addFeed').onclick=()=>{ try{ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ 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}`); await loadFeeds(true); selectFeed(r.id); }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 /// 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). -function listedFeed(p,cls,rank?: number){ +function listedFeed(p: DirFeed,cls: string,rank?: number){ const el=document.createElement('div'); el.className=cls+(p.subscribed?' mine':''); 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; } el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; // 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; } -async function subscribeListed(p){ +async function subscribeListed(p: DirFeed){ try{ const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); 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 // 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. -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 // Apple's or one an admin typed, neither of which starts with a colon. const EVERY=':every', OTHER=':other'; -const NAME=p=>(p.title||p.id); -const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); -const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b); +const NAME=(p: {title: string|null, id: string})=>(p.title||p.id); +const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); +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. -const DIR_SORTS={ +const DIR_SORTS: Recordnumber]>={ subscribers:['Most subscribers',bySubs], 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 KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'}; +const KINDS: Recordboolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; +const KIND_WORD: Record={All:'feed',Podcasts:'podcast',Blogs:'blog'}; /// 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 /// 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. -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. dirCat=null; dirSub=null; dirShow=null; dirError=''; @@ -117,24 +117,24 @@ function drawDirectory(box=$('#directory'), focus?: string){ const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs); if(!both) dirKind='All'; const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind]; - const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; - const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c); + const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; + 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. if(dirCat&&!rows.some(of(dirCat))) dirCat=null; - const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; - const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); - const btn=(k,v,label,on)=>``; + const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; + const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); + const btn=(k: string,v: string,label: string,on: boolean)=>``; // back: where its button goes, '' for the front; none on the front itself. - const head=(title,sub,back?)=>`
${back==null?'': + const head=(title: string,sub: string,back?: string)=>`
${back==null?'': ``}

${esc(title)}

${both?`
${ Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}
`:''}

${esc(sub)}

`; - const sec=(title,body,extra='')=>`
${title?`

${esc(title)}

${extra}
`:''}${body}
`; + const sec=(title: string,body: string,extra='')=>`
${title?`

${esc(title)}

${extra}
`:''}${body}
`; const tools=(pills='')=>`
${pills||''}
`; const q=S.q.trim(), grid='
', board='
'; - let html, chart=[], tiles=null; + let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null; const show=dirShow&&dirRows.find(p=>p.id===dirShow); if(show){ const n=show.subscribers; @@ -168,7 +168,7 @@ function drawDirectory(box=$('#directory'), focus?: string){ if(!subs.includes(dirSub)) dirSub=null; if(dirCat!==EVERY) chart=top(inCat); tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); - const pill=(v,label,n)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v); + const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v); html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'') +(chart.length?sec('Most subscribed',board):'') // 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; if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); } 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); 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('[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. @@ -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 /// 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. -async function fillShow(box,p){ +async function fillShow(box: HTMLElement,p: DirFeed){ 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)}`); } catch(e){ list.innerHTML=`

${esc(why(e))}

`; return; } // 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 /// 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: // 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(); @@ -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. -async function renderListed(v){ +async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){ const box=$('#content'); box.classList.add('plain'); $('#tbRemove').disabled=true; @@ -294,14 +294,14 @@ async function renderListed(v){ /// 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 /// where you left off, not another way to browse. The one in the player pauses instead. -async function renderListening(url,box){ - let rows=[]; +async function renderListening(url: string,box: HTMLElement){ + let rows: Entry[]=[]; try{ rows=(await api(url)).entries||[]; }catch{} box.innerHTML=rows.length?'':S.q?`

Nothing you are listening to mentions “${esc(S.q)}”.

` :'

Nothing in progress. Episodes you start and do not finish show up here.

'; for(const e of rows){ // 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.entry=e; 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. -function paintListenRow(el){ +function paintListenRow(el: HTMLElement & {entry?: Entry}){ 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); @@ -341,7 +341,7 @@ function syncListening(){ /// 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. -async function forget(e){ +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(); } 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); }; $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; -let searchT; -$('#epSearch').oninput=ev=>{ clearTimeout(searchT); +let searchT: number; +$('#epSearch').oninput=(ev: Event & {target: HTMLInputElement})=>{ 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; @@ -368,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT); window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); 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); try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} renderFeeds(); } let globalMax = 3; -function due(ts){ +function due(ts: number){ const d = ts - Date.now()/1000; if(d <= 0) return 'due now'; 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 /// 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. -function deadSavedText(me){ +function deadSavedText(me: Me|null){ 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.` :'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(){
${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=>setTheme(e.target.value,undefined,true); - $('#smode').onchange=e=>setTheme(undefined,e.target.value,true); + $('#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; drawTokens(); $('#tokadd').onclick=async()=>{ @@ -448,7 +448,7 @@ async function prefsModal(){ $('#tokval').select(); }catch(err){ toast(why(err),true); } }; - $('#sblock').onchange=async e=>{ + $('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{ const blocked=splitWords(e.target.value); try{ 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. async function drawTokens(){ 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=>`
${esc(t.name)} made ${dateOf(t.created)}, last used ${ago(t.last_used)}
`).join(''); @@ -486,10 +486,10 @@ function chipsHTML(words: string[], input: string){ 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. -function chipWords(box){ +function chipWords(box: ParentNode){ return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)]; } -function wireChips(box){ +function wireChips(box: HTMLElement){ 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; }; @@ -501,14 +501,14 @@ function wireChips(box){ } input.value=''; hint(); }; - input.onkeydown=e=>{ + input.onkeydown=(e: KeyboardEvent)=>{ if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); } else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); } }; // Typed and left without Enter is still meant: Save takes it either way. input.onblur=add; - box.onclick=e=>{ - const x=e.target.closest('.chipx'); + box.onclick=(e: MouseEvent)=>{ + const x=(e.target as Element).closest('.chipx'); if(x){ x.parentElement.remove(); hint(); } 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 /// 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 isGroup=kids>0; 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. const deps=$('#sdeps'), inner=$('.fdepsin',deps); 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 // 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 every item${each}, the whole back catalogue.`; const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest; - let bars=[]; + let bars: HTMLElement[]=[]; const draw=(words: boolean)=>{ // The newest items with a file, as hold_back counts them: one whose files a filter all // 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); }; // 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); }; 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. 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 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 h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60; return ``; @@ -637,8 +637,8 @@ function settingsModal(f, newUrl?: string){ if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } else { $('#smax').value=String(newest); draw(false); } }; - strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; - strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; + strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; + strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; }).catch(()=>{}); wireChips($('#skw')); @@ -668,7 +668,7 @@ function settingsModal(f, newUrl?: string){ }; } -function downloadLatestModal(f){ +function downloadLatestModal(f: Feed){ openModal(`

Download latest items

@@ -685,7 +685,7 @@ function downloadLatestModal(f){ }; } -function removeFeed(f){ +function removeFeed(f: Feed){ openModal(`

Unsubscribe?

Removes ${esc(f.title||f.id)} from your feeds. Anyone else reading it keeps it, along with their own read state. @@ -753,8 +753,8 @@ api('/api/me').then(u=>{ $('#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=>{ - const row=ev.target.closest('[data-id]'); if(!row) return; +$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{ + const row=(ev.target as Element).closest('[data-id]'); if(!row) return; const id=row.dataset.id; if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click(); else if(row.classList.contains('group')&& diff --git a/web/src/events.ts b/web/src/events.ts index 575fb4b..33ec0ea 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -1,10 +1,10 @@ /* ---------------- live events ---------------- */ -let sse; +let sse: EventSource; /// The feed list and the open list, taken again from the server. The events keep both current, /// and one missed is a count wrong until a reload: reading an item sends none to your other /// pages, so one read on another device stayed unread here, and an iPhone stops the stream while /// the app is in the background, when everything is missed (#168). -const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ +const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ loadFeeds(true).catch(()=>{}); if(S.feed) loadEntries().catch(()=>{}); },300); }; })(); @@ -14,11 +14,11 @@ function connect(){ sse=new EventSource('/api/events'); // Back after a drop: whatever was sent meanwhile went to nobody. sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; - const soon=(fn,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). 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. - const mine=id=>S.feeds.some(f=>f.id===id); + const mine=(id: string)=>S.feeds.some(f=>f.id===id); sse.onmessage=m=>{ let ev; try{ ev=JSON.parse(m.data) }catch{ return } if(ev.ev==='progress'){ diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index 386b8a3..967393c 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -89,7 +89,7 @@ function sortHead(){ `${on?`${ICON.caret}`:''}`; }).join('')+'

'; } -function sortBy(col){ +function sortBy(col: string){ const first=['published','size','kept'].includes(col)?'desc':'asc'; 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{} @@ -99,9 +99,9 @@ function sortBy(col){ /// 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. // 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 saved=kids.reduce((n,c)=>n+c.downloaded,0); const gone=kids.filter(c=>c.orphaned).length; @@ -158,7 +158,7 @@ function renderGroup(f,kids){ draw(); } -async function feedAction(a,f){ +async function feedAction(a: string,f: Feed){ if(a==='scan'){ spinStart(f.id); 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); } /// 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==='readall'){ const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); diff --git a/web/src/feeds.ts b/web/src/feeds.ts index ea53cd3..6706cd8 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -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 /// listed). A scan sends dozens; the list is redrawn once a frame, not once each. let rowsQueued=0; -function patchFeed(row){ +function patchFeed(row: Feed){ const i=S.feeds.findIndex(f=>f.id===row.id); if(i<0) S.feeds.push(row); else S.feeds[i]=row; if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); @@ -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 // 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: // an id starting with ':' can never be a feed's, since feed ids are slugs. -const VIEWS={ +const VIEWS: Record={ // The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124). ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'}, ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', @@ -108,9 +108,9 @@ function renderFeeds(){ // Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // go first: a folder with its feeds under it, a feed from inside one lifted out of it. const byId=Object.fromEntries(shown.map(f=>[f.id,f])); - const inside=f=>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 order=[]; + const order: [Feed, number][]=[]; for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ order.push([f,0]); // A subscription can hold dozens of feeds, so a folder starts closed. Searching @@ -127,14 +127,14 @@ function renderFeeds(){ // A subscription holds no entries itself, so its counts are the sum of what is inside -- // taken from every feed it holds, not just the ones a filter left showing. const mine=S.feeds.filter(c=>c.group===f.id); - const sum=k=>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 ? [sum('unread'),sum('entries'),sum('downloaded')] : [f.unread,f.entries,f.downloaded]; // A group's own row has no error of its own worth mentioning if the OPML itself // reads fine; it is failing when any feed inside it is. The feed's own page says what // went wrong (failBannerHTML); the list only has to make it findable. - const bad=c=>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 nbad=mine.filter(bad).length; const el=document.createElement('div'); @@ -160,14 +160,14 @@ function renderFeeds(){ (f.orphaned?'Gone':'')+ `${unread>999?'999+':unread}`; 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); } list.appendChild(el); } paintScanning(); done(); } -function selectFeed(id){ +function selectFeed(id: string){ S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; try{ localStorage.setItem('ipx.feed',id); }catch{} 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 /// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in /// src/feed.rs). Anything else still shows the raw error, as before. -function failBannerHTML(f){ +function failBannerHTML(f: Feed){ if(f.failing) return `
${esc(f.failing.reason)} ${ f.failing.new_url?` `:''}
`; if(f.last_error) return `
${esc(f.last_error)}
`; return ''; } -function wireFailBanner(box,f){ +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); } diff --git a/web/src/items.ts b/web/src/items.ts index cd5e650..cc9d49a 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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}); if(S.q) p.set('q',S.q); 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}`); // 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. @@ -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. // 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. - 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(!append && S.sel){ const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid)); @@ -53,7 +53,7 @@ function renderEntries(){ 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 -- // the playable one, else anything already downloaded, else the first -- and says how // many others there are; the pane below lists them all. @@ -97,13 +97,13 @@ function epEl(e){ ${has?``:''}
`; 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; } /// 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
`; } -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. 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]; diff --git a/web/src/player.ts b/web/src/player.ts index 8fe7a9c..a3b0bee 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -1,11 +1,11 @@ /* ---------------- player ---------------- */ 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}; /// 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. -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 /// 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|null{ return ready.then(()=>{ // 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.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}); } }); 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. -function play(e,enc=e.enclosures.find(isPlayable)){ +function play(e: Entry,enc=e.enclosures.find(isPlayable)){ if(!isPlayable(enc)){ toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true); return; @@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){ player.marked=false; 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(); renderEntries(); } -function mediaSession(e,f){ +function mediaSession(e: Entry,f: Feed|undefined){ if(!('mediaSession' in navigator)) return; navigator.mediaSession.metadata=new MediaMetadata({ 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'}]:[], }); - const h={play:()=>audio.play(),pause:()=>audio.pause(), + const h: Recordvoid>={play:()=>audio.play(),pause:()=>audio.pause(), seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; 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 /// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played. function syncPlayButtons(){ - const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); - const paint=(b,now,idle)=>{ + const on=(guid: string,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); + const paint=(b: HTMLElement,now: boolean,idle: string)=>{ const label=now?'Pause':idle; if(b.title===label) return; 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(); $('#pback').onclick=()=>audio.currentTime-=15; $('#pfwd').onclick=()=>audio.currentTime+=30; -$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; -$('#rate').onchange=e=>{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)}; +$('#seek').oninput=(e: Event & {target: HTMLInputElement})=>{const d=audio.duration;if(d)audio.currentTime=d*Number(e.target.value)/1000}; +$('#rate').onchange=(e: Event & {target: HTMLSelectElement})=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',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 /// icon's shape and aria-pressed, never a colour. function paintDead(){ @@ -195,7 +195,7 @@ async function switchVersion(){ secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{}); 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); if(!enc||mediaSrc(enc)===player.src) return false; 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 // 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. -const GO={a:':all',d:':directory',l:':listening'}; +const GO: Record={a:':all',d:':directory',l:':listening'}; let gAt=0; -function stepEntry(by){ +function stepEntry(by: number){ if(VIEWS[S.feed]?.url||!S.entries.length) return; 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))]; selectEntry(e,true); $(`#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; if(!id) return; selectFeed(id); $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); } -const KEYS={ +const KEYS: Recordvoid>={ j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), J:()=>stepFeed(1), K:()=>stepFeed(-1), // The toolbar's own buttons, so a key does exactly what the click does, and nothing while @@ -259,7 +259,7 @@ const KEYS={ '?':()=>keysModal(), 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. 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]; @@ -268,7 +268,7 @@ function typed(ev){ } /// What ? shows: every key, grouped as Feedly's own list is. function keysModal(){ - const k=s=>`${esc(s)}`, g=c=>k('g')+' '+k(c); + const k=(s: string)=>`${esc(s)}`, g=(c: string)=>k('g')+' '+k(c); const rows=[ ['Go to'], [g('a'),'All Subscriptions'],[g('d'),'Directory'], diff --git a/web/src/types.ts b/web/src/types.ts index 8159d33..c1f7f83 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -113,3 +113,41 @@ interface State { me: Me | null; 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; +} diff --git a/web/src/util.ts b/web/src/util.ts index ba0d927..e6bf96b 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -5,13 +5,14 @@ const $ = (s: string, r: ParentNode = document): any => r.querySelector(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. const why = (e: unknown): string => e instanceof Error ? e.message : String(e); -const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const ESCAPED: Record = {'&':'&','<':'<','>':'>','"':'"',"'":'''}; +const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]); // 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 // 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/