diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index d8b97e3..3ea5fd4 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384). - `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369). - Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376). diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 0fdaac5..12a430f 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1413,8 +1413,11 @@ mod tests { trace: None, span: None, start: None, + state: Some("3/50 of 120".into()), }; - for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] { + for ev in + ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown", "swipe", "scroll_stuck"] + { assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}"); } // A tap's span takes a trace's ids and a time the server can believe (#376). @@ -2783,12 +2786,14 @@ struct ClientEvent { trace: Option, span: Option, start: Option, + /// For `swipe` and `scroll_stuck` (#384): where the reader stood, and what could hold it. + state: Option, } async fn client_event(user: crate::db::User, Json(e): Json) -> StatusCode { // From the browser: only the kinds the page sends, and nothing long enough to fill the log. let cut = |s: &Option| s.as_deref().map(|s| s.chars().take(500).collect::()); - let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter)); + let (msg, src, feed, filter, state) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter), cut(&e.state)); let (ev, line, enclosure, deadair, ms, user) = (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str()); match ev { @@ -2815,6 +2820,17 @@ async fn client_event(user: crate::db::User, Json(e): Json) -> Stat target: "ipx::client", ev, ms, feed, filter, user, "{user}'s page: list_shown {} ms", ms.unwrap_or(0) ), + // A swipe in the reader and what came of it, so one that did nothing is seen (#384): a + // swipe sends no request when it only bounces, and #383's stopped at the 50th unseen. + "swipe" => tracing::info!( + target: "ipx::client", ev, msg, ms, feed, filter, state, user, + "{user}'s page: swipe {} at {}", msg.as_deref().unwrap_or(""), state.as_deref().unwrap_or("") + ), + // A drag up or down on an item with room to scroll that did not move it (#384). + "scroll_stuck" => tracing::warn!( + target: "ipx::client", ev, feed, state, user, + "{user}'s page: scroll_stuck: {}", state.as_deref().unwrap_or("") + ), _ => return StatusCode::BAD_REQUEST, } // The timings in /metrics as well, so weeks of them are percentiles without reading each line diff --git a/grafana/dashboard.py b/grafana/dashboard.py index f5595e5..3ae8df3 100644 --- a/grafana/dashboard.py +++ b/grafana/dashboard.py @@ -198,6 +198,14 @@ ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)], 12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}]) y += 8 +# Swipes in the reader and what came of them, and a reader that would not scroll (#384). +SWIPE = SEL + ' |= "\\"ev\\":\\"swipe\\"" | json | __error__=""' +STUCK = SEL + ' |= "\\"ev\\":\\"scroll_stuck\\"" | json | __error__=""' +ts("Swipes in the reader", [loki(f'sum by (msg) (count_over_time({SWIPE} [$__interval]))', legend="{{msg}}"), + loki(f'sum(count_over_time({STUCK} [$__interval]))', ref="B", legend="would not scroll")], + 0, w=24, bars=True, stack=True, + desc="next, prev and list turned the item; end bounced off the last one loaded; short was too short or slow to count. Would not scroll is a drag with room to move the reader that left it where it was: its line in the log says what lay over it.") +y += 8 # ---- ipx: the API it answers behind ipx-web row("ipx: API") diff --git a/tests/page-unit.js b/tests/page-unit.js index 7a4a598..48c0956 100644 --- a/tests/page-unit.js +++ b/tests/page-unit.js @@ -273,3 +273,13 @@ test('the next page starts after the unread ones on the Unread tab, after all of assert.equal(run('nextOffset()'), 3); assert.equal(run('hasMore()'), false); }); + +test('a reader is stuck only when a drag up or down had room to move it and did not (#384)', () => { + assert.equal(run('stuckDrag(-200, 5, 0, 0, 1000)'), true); // up from the top, more below, did not move + assert.equal(run('stuckDrag(-200, 5, 0, 180, 1000)'), false); // it moved + assert.equal(run('stuckDrag(200, 5, 0, 0, 1000)'), false); // down at the top: nowhere to go + assert.equal(run('stuckDrag(-200, 5, 1000, 1000, 1000)'), false);// up at the bottom: nowhere to go + assert.equal(run('stuckDrag(-200, 5, 0, 0, 0)'), false); // nothing to scroll + assert.equal(run('stuckDrag(-20, 0, 0, 0, 1000)'), false); // too short to mean it + assert.equal(run('stuckDrag(-100, 150, 0, 0, 1000)'), false); // a swipe, not a scroll +}); diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a2aefe0..afecf4a 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1731,6 +1731,9 @@ test.describe('touch gestures on a phone', () => { } test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => { + // Each swipe says what came of it, and ipx takes it (#384); a scroll is not one. + const swipes = []; + page.on('request', r => { if (r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'swipe') swipes.push(r); }); await page.locator('#burger').click(); await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.tabs button', { hasText: 'All' }).first().click(); @@ -1761,6 +1764,9 @@ test.describe('touch gestures on a phone', () => { await expect(shown).toHaveText(titles[0]); await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list await expect(page.locator('body')).not.toHaveClass(/reading/); + await expect.poll(() => swipes.length).toBe(6); + expect(swipes.map(r => r.postDataJSON().msg)).toEqual(['short', 'short', 'next', 'end', 'prev', 'list']); + for (const r of swipes) expect((await r.response())?.status()).toBe(204); }); test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => { diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index a46ba13..7917d95 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The page tells ipx what came of each swipe in the reader, and when a drag up or down would not scroll it, so a reader that stops responding can be found in the log (#384). - Caddy's own metrics, its requests and their times and the 502s ipx never sees, for Prometheus on a port of their own, `METRICS_BIND` (#371). - A tap or a key is its trace's root span: the page sends ipx when it began and when its last answer came, so Tempo shows how long it took beside the requests it made (#376). - Fonts of your choosing in Settings, beside the theme, kept by each browser as the theme is: one for the interface and one for reading an item's title and text, which is the interface's until chosen, with a little more room between the lines in a serif. Inter, your system's, the reading fonts Atkinson Hyperlegible, Lexend, Literata, Source Serif and OpenDyslexic, your device's serif, or monospace. The default keeps each theme's face as it was (#359). diff --git a/web/src/gestures.ts b/web/src/gestures.ts index 1fa4234..4349ea0 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -159,12 +159,57 @@ document.addEventListener('touchend', () => { if(g.dy >= PULL) refreshFeed(); return; } - // Across, mostly sideways, and not a slow drag while selecting text. const i = S.entries.findIndex(x => entryKey(x) === S.sel); - if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800 - || (g.dx < 0 && i >= S.entries.length - 1)) return slide(0); - if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1)); - if(i > 0) return slideOn(g.dx, () => stepEntry(-1)); + // What came of it, where the reader stood (#384): a swipe that only bounces sends no request, + // and #383's stop at the 50th item was seen by nobody but the person swiping. + const swiped = (what: string) => report('swipe', {msg: what, ms: Date.now() - g.t, feed: S.feed, filter: S.filter, + state: `${i + 1}/${S.entries.length} of ${S.total}${hasMore() ? ', more to load' : ''}`}); + // Across, mostly sideways, and not a slow drag while selecting text. A tap is not a swipe at all. + if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800){ + if(Math.abs(g.dx) > 30) swiped('short'); + return slide(0); + } + if(g.dx < 0 && i >= S.entries.length - 1){ swiped('end'); return slide(0); } + if(g.dx < 0){ swiped('next'); return slideOn(g.dx, () => stepEntry(1)); } + if(i > 0){ swiped('prev'); return slideOn(g.dx, () => stepEntry(-1)); } + swiped('list'); slideOn(g.dx, () => showDetail(null)); }); document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; }); + +/// A reader that will not scroll (#384): a drag up or down over an item with room to move that +/// way, after which it has not moved. Watched by where the finger is, not by what it landed on, +/// so something lying over the reader is caught too, and named. Reported with what could hold it, +/// since the cause of the one in #383 was never found. +let drag: {x: number, y: number, top: number, on: string} | null = null, shownAt = Date.now(); +document.addEventListener('visibilitychange', () => { if(!document.hidden) shownAt = Date.now(); }); +const named = (el: Element | null) => !el ? 'nothing' + : el.id ? '#' + el.id : el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.split(' ')[0] : ''); +/// A drag of dy down (dx across) that left the reader at the same scrollTop, from `before`, with +/// `room` to scroll in all. Only one with somewhere to go: up with more below, down with more above. +const stuckDrag = (dy: number, dx: number, before: number, after: number, room: number) => + Math.abs(dy) >= 40 && Math.abs(dy) >= 2 * Math.abs(dx) && room >= 2 && after === before + && (dy < 0 ? before < room - 1 : before > 0); +document.addEventListener('touchstart', ev => { + const d = $('#detail'), t = ev.touches[0]; + drag = null; + if(ev.touches.length !== 1 || !d || !S.sel) return; + const r = d.getBoundingClientRect(); + if(t.clientX < r.left || t.clientX > r.right || t.clientY < r.top || t.clientY > r.bottom) return; + drag = {x: t.clientX, y: t.clientY, top: d.scrollTop, on: named(ev.target as Element)}; +}, {passive: true}); +document.addEventListener('touchend', ev => { + const d = $('#detail'), t = ev.changedTouches[0], g = drag; + drag = null; + if(!g || !d || !t) return; + const dy = t.clientY - g.y, room = d.scrollHeight - d.clientHeight; + if(!stuckDrag(dy, t.clientX - g.x, g.top, d.scrollTop, room)) return; + const inert = ([...document.body.children] as HTMLElement[]).filter(el => el.inert).map(named).join(' '); + report('scroll_stuck', {feed: S.feed, state: [ + `top ${g.top} of ${room}`, `drag ${Math.round(dy)}px`, `started on ${g.on}`, + `under the finger ${named(document.elementFromPoint(t.clientX, t.clientY))}`, + `inert ${inert || 'none'}`, `dialog ${$('#modal')?.classList.contains('on') ? 'open' : 'closed'}`, + `transform ${d.style.transform || 'none'}`, `peek ${$('#dpeek') ? 'left behind' : 'gone'}`, + `overflow ${getComputedStyle(d).overflowY}`, `reading ${document.body.classList.contains('reading')}`, + `shown ${Math.round((Date.now() - shownAt) / 1000)}s ago`, `${S.entries.length} loaded`].join(', ')}); +});