Page type-check: noImplicitAny, every parameter and variable typed (#235)
290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts (feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run differently in form only: the seek and volume inputs divide Number(value) where they divided the string, and esc's table has a name. Null checks are #236. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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?`<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=>{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){
|
||||
function isPlayable(x: Enclosure){
|
||||
if(!x || !x.path) return false;
|
||||
const m=(x.mime||'').toLowerCase();
|
||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||
@@ -114,7 +114,7 @@ function isPlayable(x){
|
||||
}
|
||||
|
||||
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||
function kindOf(enc){
|
||||
function kindOf(enc: {mime?: string|null, url?: string}){
|
||||
const m=(enc.mime||'').toLowerCase();
|
||||
if(m.startsWith('image/')) return 'image';
|
||||
if(m.startsWith('video/')) return 'video';
|
||||
@@ -128,9 +128,9 @@ function kindOf(enc){
|
||||
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
||||
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
||||
/// either way keeps the column lined up; the words are in its tooltip.
|
||||
function kindIcon(enc){
|
||||
function kindIcon(enc: Enclosure){
|
||||
const k=kindOf(enc);
|
||||
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
|
||||
const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
|
||||
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||
: ['',k];
|
||||
@@ -138,7 +138,7 @@ function kindIcon(enc){
|
||||
}
|
||||
|
||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||
const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||
|
||||
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||
@@ -150,7 +150,7 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
|
||||
const plainText=(html?: string)=>html
|
||||
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||
.replace(/\s+/g,' ').trim() : '';
|
||||
function entryName(e): {text: string, derived: boolean}{
|
||||
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
||||
const words=plainText(e.description);
|
||||
if(words){
|
||||
@@ -164,7 +164,7 @@ function entryName(e): {text: string, derived: boolean}{
|
||||
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||
function swapRow(e){
|
||||
function swapRow(e: Entry){
|
||||
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||
}
|
||||
@@ -172,9 +172,9 @@ function swapRow(e){
|
||||
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
||||
/// before then answers with the old state: it put the dot back on an item just read, until
|
||||
/// the next refresh took it off again (loadEntries).
|
||||
const readWrites=new Map();
|
||||
const readKey=e=>e.feed_id+'\n'+e.guid;
|
||||
function setRead(e,read){
|
||||
const readWrites=new Map<string, {read: boolean, done?: number}>();
|
||||
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
||||
function setRead(e: Entry,read: boolean){
|
||||
e.read=read;
|
||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||
@@ -184,7 +184,7 @@ function setRead(e,read){
|
||||
|
||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e){
|
||||
function markRead(e: Entry){
|
||||
if(e.read) return;
|
||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||
}
|
||||
@@ -193,7 +193,7 @@ function markRead(e){
|
||||
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
||||
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
||||
const turned=new Set<string>();
|
||||
function dropTurned(keep){
|
||||
function dropTurned(keep: string|null){
|
||||
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
||||
turned.clear();
|
||||
if(!gone.length) return;
|
||||
@@ -201,7 +201,7 @@ function dropTurned(keep){
|
||||
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
||||
}
|
||||
|
||||
function selectEntry(e,turning=false){
|
||||
function selectEntry(e: Entry,turning=false){
|
||||
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||
// next happens to come along, which left a few read ones in the list for a while.
|
||||
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
||||
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
|
||||
}
|
||||
|
||||
/// Drag the divider between the item list and the item text.
|
||||
function dragSplit(pane){
|
||||
function dragSplit(pane: HTMLElement){
|
||||
const grab=$('#grab',pane);
|
||||
if(!grab) return;
|
||||
const move=ev=>{
|
||||
const move=(ev: MouseEvent)=>{
|
||||
const box=pane.getBoundingClientRect();
|
||||
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||
@@ -231,7 +231,7 @@ function dragSplit(pane){
|
||||
document.body.style.userSelect='';
|
||||
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||
};
|
||||
grab.onmousedown=ev=>{
|
||||
grab.onmousedown=(ev: MouseEvent)=>{
|
||||
ev.preventDefault();
|
||||
document.body.style.userSelect='none';
|
||||
document.addEventListener('mousemove',move);
|
||||
@@ -243,7 +243,7 @@ function dragSplit(pane){
|
||||
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
||||
// 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){
|
||||
function syncTools(e: Entry|null){
|
||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||
@@ -260,7 +260,7 @@ function syncTools(e){
|
||||
}
|
||||
|
||||
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||
function showDetail(e){
|
||||
function showDetail(e: Entry){
|
||||
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);
|
||||
@@ -286,7 +286,7 @@ function showDetail(e){
|
||||
|
||||
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||
function detailHtml(e){
|
||||
function detailHtml(e: Entry){
|
||||
const encs=e.enclosures.map(encBox).join('');
|
||||
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||
@@ -317,7 +317,7 @@ function detailHtml(e){
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
function encBox(x){
|
||||
function encBox(x: Enclosure){
|
||||
const size=x.length?mb(x.length):'';
|
||||
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||
@@ -369,7 +369,7 @@ function encBox(x){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function epAction(a: string, e, el, encId?: number){
|
||||
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
|
||||
// Swap the row in place and refresh the text below when it is the one being read.
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user