aboutsummaryrefslogtreecommitdiffstats
path: root/src/pull-refresh.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/pull-refresh.js')
-rw-r--r--src/pull-refresh.js97
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;
+}