A spinner after pulling to refresh (#68)

Letting go of a pull removed its note at once and showed nothing else;
the sidebar's scanning spinner is hidden on a phone. With no sign the
check had started, people pulled again, and again. A "Checking for new
items" pill with a spinner now sits under the top bar until the request
is sent and two seconds have passed, and a pull meanwhile does nothing.
It lives outside #list, which a feed's render rebuilds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 20:23:31 +00:00
parent 5be629427a
commit 7490a3a9ac
4 changed files with 30 additions and 1 deletions

View File

@@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
a second pull meanwhile does nothing, instead of no sign at all that the check started.
- Settings no longer lists the server's download folder, which only an admin can change, on - Settings no longer lists the server's download folder, which only an admin can change, on
the admin page. the admin page.
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or - On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or

View File

@@ -1325,6 +1325,15 @@ test.describe('touch gestures on a phone', () => {
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 }); await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true }); expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
// and a spinner says the check started, for a couple of seconds.
await expect(page.locator('#pullspin')).toBeVisible();
// A second pull while it is up checks nothing more.
let again = 0;
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
await expect(page.locator('#pullspin')).toHaveCount(1);
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
expect(again).toBe(0);
}); });
}); });

View File

@@ -689,6 +689,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
reloads the page, from starting on top of it. */ reloads the page, from starting on top of it. */
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden; #pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)} font-size:12px;color:var(--faint)}
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
#pullspin::before{content:"";width:12px;height:12px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
/* The selected item's files, beside the list, as the original's Files pane was. */ /* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)} #files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */ /* Nothing selected, or an item with no files: the list takes the width. */

View File

@@ -36,13 +36,25 @@ function pullShow(dy: number){
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items'; tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
} }
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
let refreshing = false;
function refreshFeed(){ function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed); const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return; if(!f && S.feed !== ':all') return;
if(refreshing) return;
refreshing = true;
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
const spin = document.createElement('div');
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press. // New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(e.message, true));
loadEntries(); loadEntries();
// Up for two seconds at least, so it is seen even when the server answers at once.
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
} }
document.addEventListener('touchstart', ev => { document.addEventListener('touchstart', ev => {