diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efa432..9ce1ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or + back to the list. The items read on the way leave the Unread tab once you close the reader. + ## [0.9.0] - 2026-09-28 ### Added diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index be974c4..3129b22 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1255,6 +1255,34 @@ test.describe('touch gestures on a phone', () => { await expect(page.locator('body')).not.toHaveClass(/reading/); }); + test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => { + await page.locator('#burger').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 }); + // Earlier tests read these; both have to be unread to be on the Unread tab. + await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false)))); + await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); + await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 }); + const titles = await page.locator('.ep .t').allTextContents(); + await page.locator('.ep').first().click(); + const shown = page.locator('#detail .dt'); + await expect(shown).toHaveText(titles[0]); + + await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item + await expect(shown).toHaveText(titles[1]); + // The first is read now, and used to be gone from the list already, so this went back to + // the list instead. + await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); + await expect(shown).toHaveText(titles[0]); + await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); + await expect(shown).toHaveText(titles[1]); + // Out of the reader, the ones read on the way leave the Unread tab as before. + await page.locator('#dback').click(); + await expect(page.locator('body')).not.toHaveClass(/reading/); + await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0); + }); + test('pulling the list down from its top checks the feed for new items', async ({ page }) => { await page.locator('#burger').click(); await page.locator('.feed', { hasText: 'Test Show' }).click(); diff --git a/web/src/items.ts b/web/src/items.ts index c6ed23d..b7e6aed 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -20,10 +20,12 @@ async function loadEntries(append?: boolean){ // 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. - if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel); - if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){ - const open=S.entries.find(e=>e.guid===S.sel); - if(open) entries=[open,...entries]; + // The items turned past on the way to it are kept too, so a swipe back still finds them. + const kept=e=>e.guid===S.sel||turned.has(e.guid); + 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)); + entries=[...gone,...entries]; } S.entries = entries; renderEntries(); @@ -161,13 +163,24 @@ function markRead(e){ setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); } -function selectEntry(e){ +/// 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(); +function dropTurned(keep){ + const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==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(); +} + +function selectEntry(e,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. - const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel); - if(prev&&prev.read){ - S.entries=S.entries.filter(x=>x!==prev); S.total--; - $(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove(); + if(S.filter==='unread' && S.sel && S.sel!==e.guid){ + turned.add(S.sel); + if(!turning) dropTurned(e.guid); } S.sel=e.guid; markRead(e); @@ -224,6 +237,7 @@ function showDetail(e){ document.body.classList.toggle('reading',!!e); syncTools(e); if(!e){ + dropTurned(S.sel); box.innerHTML='

Pick an item to read it.

'; if(files) files.innerHTML='

No files

'; return; diff --git a/web/src/player.ts b/web/src/player.ts index 96961bf..20a9c58 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -141,7 +141,7 @@ function stepEntry(by){ if(VIEWS[S.feed]?.url||!S.entries.length) return; const i=S.entries.findIndex(x=>x.guid===S.sel); const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; - selectEntry(e); + selectEntry(e,true); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); } function stepFeed(by){