On the Unread tab, a swipe back goes to the item just read (#50)

selectEntry took each read item out of the list the moment you moved on
from it, so the item was not there for the back swipe (or k) to reach: it
went to the one before, or to the list if the item had been first.

Items read while turning from one to the next (a swipe, j and k) now stay
in the list until the reader closes or another item is picked from the
list, and a background refresh keeps them as it keeps the open one.
Picking a row still drops the item left behind at once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 15:36:29 +00:00
parent 9f56436033
commit e16dace9c0
4 changed files with 57 additions and 10 deletions

View File

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

View File

@@ -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();

View File

@@ -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<string>();
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='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;

View File

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