A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the feed list but not the open page, so a check that cleared an error left the error showing, with the old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page. Only the stat line and the error banner are redrawn: the whole page would close the open item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1906,6 +1906,17 @@ test('a refresh button turns from the press to the end of the scan it asked for'
|
|||||||
expect(await spin('#scanAll .i')).toBe('none');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an open feed's page follows its row: an error a later check clears goes", async ({ page }) => {
|
||||||
|
// The header was drawn once, on opening, and kept a cleared error until reopened (#316).
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
const head = page.locator('.fhead .meta');
|
||||||
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: 'connecting: timed out', failing: null }));
|
||||||
|
await expect(head.locator('.fail')).toHaveText('connecting: timed out');
|
||||||
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: null, entries: 99 }));
|
||||||
|
await expect(head.locator('.fail')).toHaveCount(0);
|
||||||
|
await expect(head.locator('.stat')).toContainText('99 items');
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('with motion reduced', () => {
|
test.describe('with motion reduced', () => {
|
||||||
test.use({ reducedMotion: 'reduce' });
|
test.use({ reducedMotion: 'reduce' });
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Scrolling to the end of a dialog or the phone's feed list no longer scrolls the page behind it (#312).
|
- Scrolling to the end of a dialog or the phone's feed list no longer scrolls the page behind it (#312).
|
||||||
- Cover artwork has a faint edge, so a dark cover stands off a dark theme (#313).
|
- Cover artwork has a faint edge, so a dark cover stands off a dark theme (#313).
|
||||||
- The tab bar's corners follow the tabs inside it (#314), and the Directory's covers fan out smoothly (#315).
|
- The tab bar's corners follow the tabs inside it (#314), and the Directory's covers fan out smoothly (#315).
|
||||||
|
- A feed's page drops an error once a later check succeeds, and its item count and when it was last checked stay current, instead of keeping what they were when you opened it (#316).
|
||||||
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
|
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
|
||||||
|
|
||||||
## [1.2.0] - 2026-10-19
|
## [1.2.0] - 2026-10-19
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
/* ---------------- feed page ---------------- */
|
/* ---------------- feed page ---------------- */
|
||||||
|
function statHTML(f: Feed){
|
||||||
|
return `<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||||
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||||
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>`;
|
||||||
|
}
|
||||||
|
/// The open feed's counts, last check and error, from a row that came after its page was drawn:
|
||||||
|
/// the page kept an error a later check had cleared (#316). Not the whole page, which would close
|
||||||
|
/// the open item.
|
||||||
|
function patchFeedHead(f: Feed){
|
||||||
|
const meta=document.querySelector<HTMLElement>('#content .fhead .meta'); if(!meta) return;
|
||||||
|
meta.querySelector('.fail')?.remove();
|
||||||
|
const stat=meta.querySelector('.stat'); if(!stat) return;
|
||||||
|
// An OPML's header has a stat line of its own.
|
||||||
|
if(!S.feeds.some(c=>c.group===f.id)) stat.outerHTML=statHTML(f);
|
||||||
|
meta.querySelector('.stat')!.insertAdjacentHTML('afterend',failBannerHTML(f));
|
||||||
|
wireFailBanner(meta,f);
|
||||||
|
}
|
||||||
function renderFeed(){
|
function renderFeed(){
|
||||||
paintScanning(); // another feed's page may be the one open now
|
paintScanning(); // another feed's page may be the one open now
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
@@ -20,9 +37,7 @@ function renderFeed(){
|
|||||||
${artHTML(f.image,name)}
|
${artHTML(f.image,name)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(name)}</h2>
|
<h2>${esc(name)}</h2>
|
||||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
${statHTML(f)}
|
||||||
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
|
||||||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
|
||||||
${failBannerHTML(f)}
|
${failBannerHTML(f)}
|
||||||
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ let rowsQueued=0;
|
|||||||
function patchFeed(row: Feed){
|
function patchFeed(row: Feed){
|
||||||
const i=S.feeds.findIndex(f=>f.id===row.id);
|
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||||
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||||
|
if(row.id===S.feed) patchFeedHead(row);
|
||||||
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +201,10 @@ function selectFeed(id: string){
|
|||||||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||||
function failBannerHTML(f: Feed){
|
function failBannerHTML(f: Feed){
|
||||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
if(f.failing) return `<div class="sub fail" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
if(f.last_error) return `<div class="sub fail" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
function wireFailBanner(box: ParentNode,f: Feed){
|
function wireFailBanner(box: ParentNode,f: Feed){
|
||||||
|
|||||||
Reference in New Issue
Block a user