diff options
| author | Adam Soltys <[email protected]> | 2026-11-06 23:31:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-06 23:31:43 -0700 |
| commit | 259a95abdec170b7bb6def107b451dde1454293d (patch) | |
| tree | 619f340638ee1812c126513f6bf21abbbd789453 /src/pull-refresh.js | |
| parent | 4c8754a843335bfa4d84a989fb92f33bc6c26367 (diff) | |
| download | coinosv3-259a95abdec170b7bb6def107b451dde1454293d.tar.gz coinosv3-259a95abdec170b7bb6def107b451dde1454293d.zip | |
Pull down to refresh works in the Android app, the Beta and an installed PWA. "Pull down to refresh isn't working for me in the apk" (Adam, 2026-11-06): measured on his Pixel, the same pull reloads a Chrome tab but not the app — Chrome turns its pull-to-refresh off whenever a page runs full screen, which the TWA (sticky-immersive since 1.14) always does, and a WebView or a standalone PWA has none at all. src/pull-refresh.js takes over where the page reports display-mode standalone or fullscreen, or runs in our WebView (window.CoinosHost), and switches the browser's off there (overscroll-behavior-y: contain) so the two never both fire; a browser tab keeps the browser's. A pull counts only at the top of the page, vertical (sideways swipes stay the tab navigation's), and not from inside something scrolled down (a chat, a picker) or over the page (dialogs, the media viewer, fields); damped like the browser's, a round arrow follows the finger and spins once the pull is far enough, and release reloads (through the network, as a refresh does). On the phone, the local build served into the app: a pull at the top reloads, one while scrolled down only scrolls, a short one does nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6Ts5Tktega8VbLg31q
Diffstat (limited to 'src/pull-refresh.js')
| -rw-r--r-- | src/pull-refresh.js | 97 |
1 files changed, 97 insertions, 0 deletions
diff --git a/src/pull-refresh.js b/src/pull-refresh.js new file mode 100644 index 0000000..e05fa9a --- /dev/null +++ b/src/pull-refresh.js @@ -0,0 +1,97 @@ +// Pull down at the top of the page to reload it — where the browser doesn't. +// +// Chrome's own pull-to-refresh is off whenever the page runs full screen, +// which the Android app (a TWA in immersive mode) always is, and a WebView +// (the Beta and F-Droid builds) and an installed PWA have none at all: "pull +// down to refresh isn't working for me in the apk" (Adam, 2026-11-06). In +// those, this one takes over, and the browser's is switched off so the two +// never both fire; a plain browser tab keeps the browser's. +// +// A pull counts only when the page is at its top and the finger isn't in +// something scrolled down (a chat, a picker) or something over the page (a +// dialog, the media viewer): those keep their own drags. It's vertical only — +// sideways swipes belong to the tab navigation. + +const PULL = 80; // px of (damped) pull that reloads on release +const MAX = 120; + +export function appLike() { + try { + if (typeof window !== 'undefined' && window.CoinosHost) return true; + return matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches; + } catch { return false; } +} + +// something under the finger that is scrolled away from its top, or a layer +// over the page, owns this drag +function blocked(el) { + for (let n = el; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { + if (n.scrollTop > 0) return true; + if (n.matches && n.matches('.lightbox, .confirm-pop-backdrop, .msg-sheet, [role=dialog], dialog, input, textarea, select, [contenteditable=true]')) return true; + } + return false; +} + +export function initPullRefresh({ reload = () => location.reload() } = {}) { + if (typeof document === 'undefined' || !appLike()) return false; + document.documentElement.style.overscrollBehaviorY = 'contain'; // the browser's off: this one instead + + const ind = document.createElement('div'); + ind.className = 'ptr'; + ind.setAttribute('aria-hidden', 'true'); + ind.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; + document.body.appendChild(ind); + + let y0 = null, x0 = 0, dy = 0, active = false, done = false; + const show = (d) => { + const p = Math.min(1, d / PULL); + ind.style.transform = `translate(-50%, ${Math.min(d, MAX) - 48}px) rotate(${p * 270}deg)`; + ind.style.opacity = String(Math.min(1, p * 1.2)); + ind.classList.toggle('ready', d >= PULL); + }; + const reset = () => { + ind.classList.remove('ready'); + ind.style.transition = 'transform .2s ease, opacity .2s ease'; + ind.style.transform = 'translate(-50%, -48px)'; + ind.style.opacity = '0'; + setTimeout(() => { ind.style.transition = ''; }, 220); + }; + + addEventListener('touchstart', (e) => { + if (done || e.touches.length !== 1) { y0 = null; return; } + const top = (document.scrollingElement || document.documentElement).scrollTop <= 0; + if (!top || blocked(e.target)) { y0 = null; return; } + y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; dy = 0; active = false; + }, { passive: true }); + + addEventListener('touchmove', (e) => { + if (y0 == null || done) return; + const t = e.touches[0]; + const ry = t.clientY - y0, rx = t.clientX - x0; + if (!active) { + // decide once: a clear downward pull, not a sideways swipe or a scroll up + if (Math.abs(ry) < 8 && Math.abs(rx) < 8) return; + if (ry <= 0 || Math.abs(rx) > ry) { y0 = null; return; } + active = true; + } + dy = ry * 0.5; // damped, like the browser's + show(dy); + }, { passive: true }); + + const end = () => { + if (y0 == null) return; + y0 = null; + if (active && dy >= PULL) { + done = true; + ind.classList.add('spin'); + ind.style.transform = 'translate(-50%, 24px)'; + reload(); + return; + } + if (active) reset(); + active = false; + }; + addEventListener('touchend', end, { passive: true }); + addEventListener('touchcancel', () => { if (y0 != null) { y0 = null; if (active) reset(); active = false; } }, { passive: true }); + return true; +} |
