aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js2
-rw-r--r--src/pull-refresh.js97
-rw-r--r--src/style.css11
3 files changed, 110 insertions, 0 deletions
diff --git a/src/app.js b/src/app.js
index 5b57f37..33a47c1 100644
--- a/src/app.js
+++ b/src/app.js
@@ -21,6 +21,7 @@ import { STAGING } from './build-flags.js';
import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { cacheReady, cacheStore } from './cache-store.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
+import { initPullRefresh } from './pull-refresh.js';
import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js';
import {
fmtBtc,
@@ -7042,6 +7043,7 @@ loadLocale(getLang()).finally(async () => {
_restorePending = false;
if (!opened || !activeAccount()) render();
bootPhase('ui');
+ initPullRefresh(); // the app and the Beta have no browser pull-to-refresh
setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
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;
+}
diff --git a/src/style.css b/src/style.css
index 8a28377..dfe9b9d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -2087,3 +2087,14 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
font-style: italic; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere;
}
.gift-note-input { width: 100%; min-height: 0; font-family: var(--sans); }
+
+/* pull to refresh, where the browser has none (src/pull-refresh.js) */
+.ptr {
+ position: fixed; top: env(safe-area-inset-top, 0px); left: 50%; z-index: 2147483000;
+ width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
+ background: var(--paper); color: var(--muted); box-shadow: 0 2px 10px rgba(0, 0, 0, .18);
+ transform: translate(-50%, -48px); opacity: 0; pointer-events: none;
+}
+.ptr.ready { color: var(--ink); }
+.ptr.spin svg { animation: ptr-spin .8s linear infinite; }
+@keyframes ptr-spin { to { transform: rotate(360deg); } }