Page type-check: strict throughout, a typed $, null checks (#236)
$<T> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.done<asked)) e.read=w.read;
|
||||
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
|
||||
}
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||
@@ -32,7 +32,7 @@ async function loadEntries(append?: boolean){
|
||||
}
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.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&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair_pending&&DEAD_PENDING,
|
||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||
@@ -97,13 +97,13 @@ function epEl(e: Entry){
|
||||
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||
</div>`;
|
||||
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 <audio> element pointed at a JPEG
|
||||
/// is just a broken player.
|
||||
function isPlayable(x: Enclosure){
|
||||
function isPlayable(x: Enclosure|undefined){
|
||||
if(!x || !x.path) return false;
|
||||
const m=(x.mime||'').toLowerCase();
|
||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||
@@ -138,7 +138,7 @@ function kindIcon(enc: Enclosure){
|
||||
}
|
||||
|
||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||
const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||
const feedName=(id: string|null|undefined): 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,
|
||||
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||
@@ -147,7 +147,7 @@ const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.t
|
||||
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
||||
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
||||
/// read "2010)Recorded".
|
||||
const plainText=(html?: string)=>html
|
||||
const plainText=(html?: string|null)=>html
|
||||
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||
.replace(/\s+/g,' ').trim() : '';
|
||||
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
@@ -157,9 +157,9 @@ function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||
return {text:cut, derived:true};
|
||||
}
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
|
||||
}
|
||||
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
@@ -244,23 +244,23 @@ function dragSplit(pane: HTMLElement){
|
||||
// the first. Key rows by feed as well if that ever happens.
|
||||
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||
function syncTools(e: Entry|null){
|
||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
|
||||
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||
// side said opposite things (#74). The shape carries it, not the colour.
|
||||
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
if(e){
|
||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
}
|
||||
}
|
||||
|
||||
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||
function showDetail(e: Entry){
|
||||
function showDetail(e: Entry|null){
|
||||
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||
// A pane that can only say "No files" gives its width to the list instead.
|
||||
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||
@@ -278,9 +278,9 @@ function showDetail(e: Entry){
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$('#dback').onclick=()=>showDetail(null);
|
||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||
syncPlayButtons();
|
||||
}
|
||||
|
||||
@@ -328,8 +328,9 @@ function encBox(x: Enclosure){
|
||||
: 'Delete file';
|
||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||
// Played without its dead air, so saved without it too (#186).
|
||||
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
const dead=(x.deadair??0)>0;
|
||||
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||
if(x.path && !isPlayable(x)){
|
||||
@@ -376,7 +377,7 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
if(a==='get'){
|
||||
|
||||
Reference in New Issue
Block a user