The page tells two feeds' items with one guid apart (#361)
Rows, the selection, the items turned past and the player were matched by guid alone, so in All Subscriptions two feeds giving a post the same guid (a blog's RSS and Atom) were one item to the page: clicking the second selected and marked the first, and the second never read. They are matched by feed and guid now (entryKey, the key readWrites already used); data-guid stays on the row for the tests, data-key is matched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.done<asked)) e.read=w.read;
|
||||
}
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
@@ -107,10 +113,10 @@ function showList(r: ListPage, append: boolean, asked: number){
|
||||
// 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)=>e.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=`
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
|
||||
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">
|
||||
@@ -262,7 +268,7 @@ function entryName(e: Partial<Entry>): {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<string, {read: boolean, done?: number}>();
|
||||
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<string>();
|
||||
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){
|
||||
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#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{
|
||||
|
||||
Reference in New Issue
Block a user