Files
ipx/web/src/items.ts
rays 6d64cba374 A kept episode's row says On this device (#413)
Only the keep button in the files pane said an episode was kept, so finding
what was on the device meant opening items one by one. The row's line says
it, and keeping or removing one redraws the rows whose state changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:36:12 +00:00

558 lines
30 KiB
TypeScript

/* ---------------- items ---------------- */
/// An item is its feed and its guid: two feeds can give one post the same guid, as a blog's RSS
/// and Atom feeds do, and keyed by guid alone, All Subscriptions marked the first of the two
/// whichever was clicked (#361).
const entryKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
/// Whether this item is the one in the player.
const inPlayer=(e: Entry)=>player.guid===e.guid&&player.feed===e.feed_id;
let loadSeq=0;
type ListPage={total: number, entries: Entry[]};
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
/// 50 items, about 100 KB as JSON.
const lists=new Map<string,ListPage>();
const LISTS_KEPT=40;
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
const fetching=new Map<string,Promise<ListPage>>();
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
function listUrl(feed: string, filter=S.filter, offset=0){
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const sg=signalId(feed);
return feed===':all' ? `/api/entries?${p}`
: sg!==null ? `/api/signals/${sg}/entries?${p}`
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
}
function keepList(key: string, r: ListPage){
lists.delete(key); lists.set(key,r);
if(r) savePictures(r.entries);
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
}
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
/// reaches a feed or a filter. Not one kept already or on its way.
function prefetch(feed: string, filter=S.filter){
// Currently Listening and the Directory draw from their own address.
const url=VIEWS[feed]?.url;
if(url) return prefetchPage(url);
const key=listKey(feed,filter);
if(lists.has(key)||fetching.has(key)) return;
const got: Promise<ListPage>=api(listUrl(feed,filter));
fetching.set(key,got);
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
}
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
const pages=new Map<string,any>();
const pageFetching=new Map<string,Promise<any>>();
function fetchPage(url: string): Promise<any>{
const on=pageFetching.get(url); if(on) return on;
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
pageFetching.set(url,got);
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
return got;
}
const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); };
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
/// are fetched again when next wanted.
function forgetLists(feed: string){
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
const group=S.feeds.find(f=>f.id===feed)?.group;
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k);
}
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
/// and the feeds with something unread, most first.
function preloadLists(){
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
const next=()=>{
const f=want.shift(); if(!f) return;
prefetch(f);
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
if(on) on.then(next,next); else next();
};
const go=()=>{ next(); next(); };
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
}
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
async function loadEntries(append?: boolean, quiet?: boolean){
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return;
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
// From asking to the list drawn: what someone waits for after a click, under that click's trace
// (#246). Once, when the list first shows: from what is kept, at once.
let timed=quiet;
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
const kept=append ? undefined : lists.get(key);
let spin=0;
if(kept){ showList(kept,false,asked); shown(); }
// Nothing to show yet: a spinner, if the answer is not back in a blink.
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
let r: ListPage;
try{
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,append?S.offset:0)));
}finally{ clearTimeout(spin); }
// 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.
if(seq!==loadSeq) return;
if(!append) keepList(key,r);
// The kept one was this already: drawn again it only flickers.
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
showList(r,!!append,asked);
shown();
}
/// A list's page into S and onto the screen.
function showList(r: ListPage, append: boolean, asked: number){
S.total=r.total;
// Reading an item takes it off the server's Unread list and off nextOffset() alike, so what is
// left there stays as this answer says; S.total against nextOffset() said more once read (#385).
S.more=(append?S.offset:0)+r.entries.length<r.total;
for(const e of r.entries){
const w=readWrites.get(entryKey(e));
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
}
// An item new since the last page shifts the next one down by it, repeating one already here.
if(append) savePictures(r.entries);
let entries = append ? S.entries.concat(r.entries.filter(e=>!S.entries.some(x=>entryKey(x)===entryKey(e)))) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread
// 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: Entry)=>entryKey(e)===S.sel||turned.has(entryKey(e));
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=>entryKey(n)===entryKey(e)));
entries=[...gone,...entries];
}
// Reloaded for a new version (#388): the item that was open, open again, where it was in the list.
const back=append ? null : takeReopen();
if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries];
S.entries = entries;
renderEntries();
if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); }
}
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
function listLoading(box=$('#eps')){
if(!box) return;
box.innerHTML='<p class="listload" role="status">Loading…</p>';
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
// An OPML's own row counts nothing: its shows do.
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
// A new Signal is empty while Jev reads, which would otherwise look broken.
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. New items are read as your feeds are checked.</p>';
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
syncPlayButtons();
if(pane) pane.scrollTop=top;
if(hasMore()){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset=nextOffset();loadEntries(true)};
box.appendChild(b);
}
}
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.
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
const has=!!(enc&&enc.path);
const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===entryKey(e)?' sel':'');
el.dataset.guid=e.guid; el.dataset.key=entryKey(e);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
const name=entryName(e);
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
// the row alone was a click target only a mouse could reach (#298).
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
inPlayer(e)?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING,
// Kept on this device to play offline (#413), which only the files pane said.
e.enclosures.some(isKept)&&`<span class="kept">${ICON.kept}On this device</span>`,
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>
</div>
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
</div>
<span class="size">${enc?mb(enc.length):''}</span>
<span class="date">${dateOf(e.published)}</span>
<div class="rowacts">
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
${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)});
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|undefined){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
if(m) return false;
// No declared type: fall back to the file's extension.
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
.test((x.path||x.url||'').split('?')[0]);
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
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';
if(m.startsWith('audio/')) return 'audio';
if(m.includes('pdf')) return 'pdf';
if(m.includes('torrent')) return 'torrent';
const ext=(enc.url||'').split('?')[0].split('.').pop();
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
}
/// 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: Enclosure){
const k=kindOf(enc);
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];
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
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
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
/// 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|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}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description);
if(words){
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,'');
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, 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.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e: Entry){
const row=$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
}
/// 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<string, {read: boolean, done?: number}>();
function setRead(e: Entry,read: boolean){
e.read=read;
// Kept lists of its feed hold their own copy of it, still as it was.
forgetLists(e.feed_id);
const w: {read: boolean, done?: number}={read}; readWrites.set(entryKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
}
/// 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: Entry){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(entryKey(e)); toast(why(err),true); });
}
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
/// 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: string|null){
const gone=S.entries.filter(x=>turned.has(entryKey(x))&&x.read&&entryKey(x)!==keep);
turned.clear();
if(!gone.length) return;
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
for(const x of gone) $(`#eps .ep[data-key="${CSS.escape(entryKey(x))}"]`)?.remove();
}
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!==entryKey(e)){
turned.add(S.sel);
if(!turning) dropTurned(entryKey(e));
}
S.sel=entryKey(e);
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.key===entryKey(e)));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
if(turning) loadAhead();
}
/// Where the next page starts. On the Unread tab the items read here are no longer unread on the
/// server, so counting them, as a flat 50 a page did, skipped as many unread ones.
function nextOffset(){ return S.filter==='unread' ? S.entries.filter(e=>!e.read).length : S.entries.length; }
const hasMore=()=>S.more;
/// Turning near the end of what is loaded fetches the next page behind: only the list's Load more
/// button did, and a swipe or j stopped dead at the 50th item until a reload (#383).
let loadingAhead=false;
function loadAhead(){
const i=S.entries.findIndex(x=>entryKey(x)===S.sel);
if(loadingAhead || S.entries.length-1-i>5 || !hasMore()) return;
loadingAhead=true; S.offset=nextOffset();
loadEntries(true).catch(()=>{}).finally(()=>{ loadingAhead=false; });
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane);
if(!grab) return;
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)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// Which item the toolbar's play, read and keep buttons act on.
const cur=()=>S.entries.find(x=>entryKey(x)===S.sel);
function syncTools(e: Entry|null){
$<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.
$<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){
$<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|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);
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
dropTurned(S.sel);
box.innerHTML='';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down.
const encs=e.enclosures.map(encBox).join('');
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
const view=box.querySelector<HTMLSelectElement>('.tview');
if(view) view.onchange=()=>{
textView=view.value;
try{ localStorage.setItem('ipx.textview',textView); }catch{}
box.querySelector('.dbody')!.innerHTML=textOf(e);
};
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):undefined));
syncPlayButtons();
}
/// 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: 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
// it looked as if a downloaded file could not be deleted at all (issue #21).
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
// description was sanitized server-side with ammonia before it ever reached here
return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
${/* A post without a title starts with its text: its own first words as a heading above
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
</div>
${narrow?encs:''}
<div class="dbody">${textOf(e)}</div>`;
}
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
/// next (#285): the publisher's HTML, or the Markdown made HTML.
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown']];
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
function textOf(e: Entry){
// Cleaned server-side with ammonia, as the description is.
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
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);
const shared=f&&f.subscribers>1;
// Icons, with the words in the tooltip and for screen readers.
const delLabel=shared
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: '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 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)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn}
${shareBtn}
${delBtn}
</div>`;
}
if(x.path){
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
// started the bar as well, so the same file played twice at once.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${keepBtn(x)}
${saveBtn}
${shareBtn}
${delBtn}
</div>`;
}
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
${shareBtn}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
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===entryKey(e)) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
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'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+entryName(e).text);
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);
const shared=f&&f.subscribers>1;
if(!confirm(shared
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
+`and will not be downloaded again automatically.`
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
try{
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(why(err))) throw err;
if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(why(err),true); }
}