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:
2026-10-25 02:00:14 +00:00
parent 2c7e23c346
commit eae0ef5061
5 changed files with 39 additions and 32 deletions

View File

@@ -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{