// 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 = ''; 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; }