diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 910877b..9356965 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Two items from different feeds that share an id, as a blog's RSS and Atom feeds' do, are told apart: in All Subscriptions the second can be opened and marked read, where the first was marked whichever was clicked (#361). + ### Changed - An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360). diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index d542fcf..6c9b0e9 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -362,7 +362,7 @@ function drawListening(box: HTMLElement,rows: Entry[]){ /// One row's time left, progress and play button, taken from the player when it is the one in it. function paintListenRow(el: HTMLElement & {entry?: Entry}){ - const e=el.entry!, now=player.guid===e.guid&&player.feed===e.feed_id; + const e=el.entry!, now=inPlayer(e); // Zero until the player has sought to where you left off; the saved position stands till then. if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); // The player's own length first: a feed's can be minutes out. @@ -378,14 +378,14 @@ function paintListenRow(el: HTMLElement & {entry?: Entry}){ /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. function syncListening(){ for(const el of $$('#listening .childrow')) - if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); + if(el.entry&&(el.classList.contains('now')||inPlayer(el.entry))) paintListenRow(el); } /// 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: Entry){ // Closed without saving first, or the player's next save would put it straight back. - if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } + if(inPlayer(e)){ player.guid=null; $('#pclose').click(); } try{ await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, {method:'POST',body:JSON.stringify({secs:0})}); diff --git a/web/src/gestures.ts b/web/src/gestures.ts index 89a04a6..1fa4234 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -88,7 +88,7 @@ document.addEventListener('touchmove', ev => { }else if(Math.abs(touch.dx) > 10){ // The reader follows the finger, so it is plain before letting go whether this will move on; // with nothing that way it only gives a little. - const i = S.entries.findIndex(x => x.guid === S.sel); + const i = S.entries.findIndex(x => entryKey(x) === S.sel); const end = touch.dx < 0 && i >= S.entries.length - 1; slide(end ? touch.dx / 3 : touch.dx, 0); } @@ -100,7 +100,7 @@ document.addEventListener('touchmove', ev => { /// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its /// copy of the reader's markup repeats the reader's ids. function beside(dx: number){ - const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)]; + const i = S.entries.findIndex(x => entryKey(x) === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)]; const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim'); let p = $('#dpeek'); if(!p){ @@ -160,7 +160,7 @@ document.addEventListener('touchend', () => { return; } // Across, mostly sideways, and not a slow drag while selecting text. - const i = S.entries.findIndex(x => x.guid === S.sel); + const i = S.entries.findIndex(x => entryKey(x) === S.sel); if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800 || (g.dx < 0 && i >= S.entries.length - 1)) return slide(0); if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1)); diff --git a/web/src/items.ts b/web/src/items.ts index c35d69e..3a7cfec 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -1,4 +1,10 @@ /* ---------------- 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 @@ -99,7 +105,7 @@ async function loadEntries(append?: boolean, quiet?: boolean){ function showList(r: ListPage, append: boolean, asked: number){ S.total=r.total; for(const e of r.entries){ - const w=readWrites.get(readKey(e)); + const w=readWrites.get(entryKey(e)); if(w && !(w.done!==undefined&&w.donee.guid===S.sel||turned.has(e.guid); + 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=>n.guid===e.guid)); + const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>entryKey(n)===entryKey(e))); entries=[...gone,...entries]; } S.entries = entries; @@ -157,8 +163,8 @@ function epEl(e: Entry){ const playable=isPlayable(enc); const others=e.enclosures.length-1; const el=document.createElement('div'); - el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':''); - el.dataset.guid=e.guid; + 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); @@ -166,7 +172,7 @@ function epEl(e: Entry){ // the row alone was a click target only a mouse could reach (#298). el.innerHTML=` + inPlayer(e)?EQ:(e.read?'':'')}
@@ -262,7 +268,7 @@ function entryName(e: Partial): {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: Entry){ - const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`); + const row=$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"]`); if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); } } @@ -270,12 +276,11 @@ function swapRow(e: Entry){ /// 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: Entry)=>e.feed_id+'\n'+e.guid; 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(readKey(e),w); + 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(); }); @@ -285,7 +290,7 @@ function setRead(e: Entry,read: boolean){ /// 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(readKey(e)); toast(why(err),true); }); + 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 @@ -293,23 +298,23 @@ function markRead(e: Entry){ /// was left, a swipe back had nothing to go back to: the item just read was already gone. const turned=new Set(); function dropTurned(keep: string|null){ - const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep); + 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-guid="${CSS.escape(x.guid)}"]`)?.remove(); + 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!==e.guid){ + if(S.filter==='unread' && S.sel && S.sel!==entryKey(e)){ turned.add(S.sel); - if(!turning) dropTurned(e.guid); + if(!turning) dropTurned(entryKey(e)); } - S.sel=e.guid; + S.sel=entryKey(e); markRead(e); - $$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid)); + $$('#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; @@ -339,9 +344,7 @@ function dragSplit(pane: HTMLElement){ } /// Which item the toolbar's play, read and keep buttons act on. -// 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); +const cur=()=>S.entries.find(x=>entryKey(x)===S.sel); function syncTools(e: Entry|null){ $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $('#tbRead').disabled=$('#tbFlag').disabled=!e; @@ -489,7 +492,7 @@ function encBox(x: Enclosure){ 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 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{ diff --git a/web/src/player.ts b/web/src/player.ts index e4473d0..bbeebbc 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -64,7 +64,7 @@ function play(e: Entry,enc=e.enclosures.find(isPlayable)){ } // The same file carries on where it was; another of the item's files starts from its top. So // does the other version of the same file, from the place the entry gives in its clock. - const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc); + const resuming = inPlayer(e) && player.enc===enc.id && player.src===mediaSrc(enc); // Every play button is a pause button for what is playing, as the player bar's is. if(resuming && !audio.paused){ audio.pause(); return; } let wait: Promise|null=null; @@ -140,16 +140,16 @@ 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: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); + const on=(key: string|null|undefined,enc?: number)=>!audio.paused&&player.feed+'\n'+player.guid===key&&(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; }; - for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep')!.dataset.guid!),'Play'); + for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep')!.dataset.key),'Play'); for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]')) paint(b,on(S.sel,Number(b.dataset.enc)),'Play'); - const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item'); + const e=cur(); paint($('#tbPlay'),!!e&&on(entryKey(e)),'Play the selected item'); } for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); window.addEventListener('beforeunload',savePos); @@ -244,10 +244,10 @@ const GO: Record={a:':all',d:':directory',l:':listening'}; let gAt=0; function stepEntry(by: number){ if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return; - const i=S.entries.findIndex(x=>x.guid===S.sel); + const i=S.entries.findIndex(x=>entryKey(x)===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'}); + $(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"]`)?.scrollIntoView({block:'nearest'}); } function stepFeed(by: number){ const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;