diff --git a/apps/abcpay-web/public/sw.js b/apps/abcpay-web/public/sw.js index 076670d..bca9a59 100644 --- a/apps/abcpay-web/public/sw.js +++ b/apps/abcpay-web/public/sw.js @@ -1,5 +1,7 @@ /* AbcPay Wallet Service Worker — PWA installability + Push & System Notifications */ const CACHE_NAME = 'abcpay-shell-v2'; +const TOKEN_ICONS_CACHE = 'abcpay-token-icons-v1'; +const TOKEN_ICONS_ORIGIN = 'https://icons.etokens.cash'; const PRECACHE_URLS = [ '/manifest.webmanifest', '/abcpay-logo.svg', @@ -28,7 +30,11 @@ self.addEventListener('activate', event => { caches .keys() .then(keys => - Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))) + Promise.all( + keys + .filter(key => key !== CACHE_NAME && key !== TOKEN_ICONS_CACHE) + .map(key => caches.delete(key)) + ) ) .then(() => self.clients.claim()) ); @@ -39,6 +45,27 @@ self.addEventListener('fetch', event => { if (req.method !== 'GET') return; const url = new URL(req.url); + + // Cashtab/eTokens icons — cache-first so token images survive reloads offline. + if (url.origin === TOKEN_ICONS_ORIGIN && /\.png$/i.test(url.pathname)) { + event.respondWith( + caches.open(TOKEN_ICONS_CACHE).then(async cache => { + const cached = await cache.match(req); + if (cached) return cached; + const res = await fetch(req); + if (res.ok) { + try { + await cache.put(req, res.clone()); + } catch { + // ignore quota failures + } + } + return res; + }) + ); + return; + } + if (url.origin !== self.location.origin) return; if (url.pathname.startsWith('/cws/api') || url.pathname.startsWith('/bws/api')) return; diff --git a/apps/abcpay-web/src/App.tsx b/apps/abcpay-web/src/App.tsx index 797547e..f893834 100644 --- a/apps/abcpay-web/src/App.tsx +++ b/apps/abcpay-web/src/App.tsx @@ -1,4 +1,5 @@ import { Routes, Route } from 'react-router-dom'; +import { PullToReload } from './components/PullToReload'; import { TabBar } from './components/ui'; import { HomePage } from './pages/HomePage'; import { ScanPage } from './pages/ScanPage'; @@ -15,6 +16,7 @@ import { RestorePage } from './pages/RestorePage'; export function App() { return (
+ } /> } /> diff --git a/apps/abcpay-web/src/components/PullToReload.tsx b/apps/abcpay-web/src/components/PullToReload.tsx new file mode 100644 index 0000000..92e89cd --- /dev/null +++ b/apps/abcpay-web/src/components/PullToReload.tsx @@ -0,0 +1,252 @@ +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useWallets } from '../context/WalletContext'; + +const SOFT_THRESHOLD_PX = 64; +const HARD_THRESHOLD_PX = 118; +const PULL_MAX_PX = 140; +const PULL_RESISTANCE = 0.42; +const UPDATE_CHECK_MS = 2000; + +type PtrPhase = 'idle' | 'pulling' | 'checking' | 'refreshing' | 'uptodate' | 'reloading'; + +function pageScrollTop(): number { + return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => window.setTimeout(resolve, ms)); +} + +function currentBundleName(): string | null { + for (const script of document.scripts) { + const match = script.src.match(/\/assets\/(index-[A-Za-z0-9_-]+\.js)/); + if (match) return match[1]; + } + return null; +} + +/** Fast no-cache HTML peek — true when the deployed JS bundle hash changed. */ +async function hasNewWebBuild(): Promise { + const current = currentBundleName(); + if (!current) return false; + try { + const res = await fetch(`/?_ptr=${Date.now()}`, { + cache: 'no-store', + headers: { Accept: 'text/html' } + }); + if (!res.ok) return false; + const html = await res.text(); + const match = html.match(/\/assets\/(index-[A-Za-z0-9_-]+\.js)/); + if (!match) return false; + return match[1] !== current; + } catch { + return false; + } +} + +async function requestServiceWorkerUpdate(): Promise { + try { + const reg = await navigator.serviceWorker?.getRegistration(); + await reg?.update(); + } catch { + // ignore + } +} + +function forceReload(): void { + // Kick SW update in the background; don't wait — user asked for a full reload. + void requestServiceWorkerUpdate(); + window.location.reload(); +} + +function ptrLabel(phase: PtrPhase, pullPx: number): string { + if (phase === 'checking') return 'Checking for updates…'; + if (phase === 'refreshing') return 'Refreshing balances…'; + if (phase === 'uptodate') return 'Up to date'; + if (phase === 'reloading') return 'Reloading…'; + if (pullPx >= HARD_THRESHOLD_PX) return 'Release to force reload'; + if (pullPx >= SOFT_THRESHOLD_PX) return 'Release to check for updates'; + return 'Pull to check'; +} + +/** + * Two-stage pull-to-reload for iOS standalone PWAs (no native Safari PTR): + * - Short pull: peek for a new web build; reload only if one exists, else soft-refresh balances + * - Long pull: force a full page reload immediately + */ +export function PullToReload() { + const { refreshBalances } = useWallets(); + const [pullPx, setPullPx] = useState(0); + const [phase, setPhase] = useState('idle'); + const startYRef = useRef(null); + const pullPxRef = useRef(0); + const trackingRef = useRef(false); + const phaseRef = useRef('idle'); + const refreshBalancesRef = useRef(refreshBalances); + refreshBalancesRef.current = refreshBalances; + + useEffect(() => { + pullPxRef.current = pullPx; + }, [pullPx]); + + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + useEffect(() => { + const busy = () => { + const p = phaseRef.current; + return p === 'checking' || p === 'refreshing' || p === 'uptodate' || p === 'reloading'; + }; + + const resetPull = () => { + trackingRef.current = false; + startYRef.current = null; + pullPxRef.current = 0; + setPullPx(0); + }; + + const runSoftCheck = async () => { + setPhase('checking'); + setPullPx(SOFT_THRESHOLD_PX); + const hasUpdate = await Promise.race([ + hasNewWebBuild(), + sleep(UPDATE_CHECK_MS).then(() => false) + ]); + if (hasUpdate) { + setPhase('reloading'); + await requestServiceWorkerUpdate(); + window.location.reload(); + return; + } + setPhase('refreshing'); + try { + await refreshBalancesRef.current(); + } catch { + // Balance refresh is best-effort. + } + setPhase('uptodate'); + await sleep(700); + setPhase('idle'); + resetPull(); + }; + + const onTouchStart = (event: TouchEvent) => { + if (busy()) return; + if (event.touches.length !== 1) return; + if (pageScrollTop() > 1) { + resetPull(); + return; + } + startYRef.current = event.touches[0].clientY; + trackingRef.current = false; + setPhase('pulling'); + }; + + const onTouchMove = (event: TouchEvent) => { + if (busy()) return; + const startY = startYRef.current; + if (startY == null) return; + + if (pageScrollTop() > 1) { + resetPull(); + setPhase('idle'); + return; + } + + const dy = event.touches[0].clientY - startY; + if (dy <= 0) { + if (trackingRef.current) { + resetPull(); + setPhase('idle'); + } + return; + } + + const dist = Math.min(PULL_MAX_PX, dy * PULL_RESISTANCE); + trackingRef.current = true; + pullPxRef.current = dist; + setPullPx(dist); + setPhase('pulling'); + + if (event.cancelable) event.preventDefault(); + }; + + const onTouchEnd = () => { + if (busy()) return; + if (!trackingRef.current) { + resetPull(); + setPhase('idle'); + return; + } + const dist = pullPxRef.current; + if (dist >= HARD_THRESHOLD_PX) { + setPhase('reloading'); + setPullPx(HARD_THRESHOLD_PX); + forceReload(); + return; + } + if (dist >= SOFT_THRESHOLD_PX) { + trackingRef.current = false; + startYRef.current = null; + void runSoftCheck(); + return; + } + resetPull(); + setPhase('idle'); + }; + + const onTouchCancel = () => { + if (!busy()) { + resetPull(); + setPhase('idle'); + } + }; + + document.addEventListener('touchstart', onTouchStart, { passive: true }); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd, { passive: true }); + document.addEventListener('touchcancel', onTouchCancel, { passive: true }); + + return () => { + document.removeEventListener('touchstart', onTouchStart); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchCancel); + }; + }, []); + + const active = phase !== 'idle' || pullPx > 4; + const softArmed = pullPx >= SOFT_THRESHOLD_PX || phase === 'checking' || phase === 'refreshing'; + const hardArmed = pullPx >= HARD_THRESHOLD_PX || phase === 'reloading'; + const spinning = + phase === 'checking' || phase === 'refreshing' || phase === 'reloading'; + const progress = Math.min(1, pullPx / HARD_THRESHOLD_PX); + + return ( +
+
+ + {ptrLabel(phase, pullPx)} +
+
+ ); +} diff --git a/apps/abcpay-web/src/components/TokenIcon.tsx b/apps/abcpay-web/src/components/TokenIcon.tsx new file mode 100644 index 0000000..49813d1 --- /dev/null +++ b/apps/abcpay-web/src/components/TokenIcon.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { + TOKEN_ICON_FALLBACK, + loadTokenIcon, + type TokenIconSize +} from '../lib/token-icons'; + +export function TokenIcon({ + tokenId, + size = 64, + className = 'w-5 h-5' +}: { + tokenId: string; + size?: TokenIconSize; + className?: string; +}) { + const [src, setSrc] = useState(TOKEN_ICON_FALLBACK); + + useEffect(() => { + let cancelled = false; + setSrc(TOKEN_ICON_FALLBACK); + void loadTokenIcon(tokenId, size).then(url => { + if (!cancelled) setSrc(url); + }); + return () => { + cancelled = true; + }; + }, [tokenId, size]); + + return ( + setSrc(TOKEN_ICON_FALLBACK)} + /> + ); +} diff --git a/apps/abcpay-web/src/context/WalletContext.tsx b/apps/abcpay-web/src/context/WalletContext.tsx index 5b4a431..fceeb7b 100644 --- a/apps/abcpay-web/src/context/WalletContext.tsx +++ b/apps/abcpay-web/src/context/WalletContext.tsx @@ -17,6 +17,7 @@ import { import { getCredentials, saveCredentials } from '../lib/credentials-store'; import { getWalletBalance as getBalanceFromStored } from '../lib/bwc'; import { api, type AuthContext } from '../lib/api'; +import { prefetchTokenIcons } from '../lib/token-icons'; /** * One Account = one derivation (path + coin) under a Wallet (seed). @@ -451,6 +452,12 @@ export function WalletProvider({ children }: { children: ReactNode }) { localStorage.setItem(KNOWN_TOKENS_KEY, JSON.stringify(knownTokens)); }, [knownTokens]); + // Warm Cashtab/eTokens icons into Cache API whenever we learn new token ids. + useEffect(() => { + if (knownTokens.length === 0) return; + prefetchTokenIcons(knownTokens.map(token => token.tokenId), 64); + }, [knownTokens]); + useEffect(() => { localStorage.setItem(TOKENS_BY_ACCOUNT_KEY, JSON.stringify(tokensByAccount)); }, [tokensByAccount]); diff --git a/apps/abcpay-web/src/index.css b/apps/abcpay-web/src/index.css index dc2d276..caffa87 100644 --- a/apps/abcpay-web/src/index.css +++ b/apps/abcpay-web/src/index.css @@ -15,12 +15,19 @@ box-sizing: border-box; } +html { + /* Prefer our pull-to-reload over competing overscroll UX in the PWA shell. */ + overscroll-behavior-y: contain; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: var(--abcpay-bg); color: var(--abcpay-text); min-height: 100vh; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; } #root { @@ -35,6 +42,81 @@ body { padding-bottom: env(safe-area-inset-bottom, 0px); } +/* Pull-to-reload (iOS standalone PWA has no native Safari PTR). */ +.ptr-indicator { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 0.35rem); + left: 50%; + z-index: 60; + transform: translate3d(-50%, calc(-120% + var(--ptr-pull, 0px)), 0); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.ptr-indicator.is-visible { + opacity: 1; +} + +.ptr-indicator-inner { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.85rem; + border-radius: 999px; + background: color-mix(in srgb, #1a1a1a 78%, transparent); + border: 1px solid color-mix(in srgb, #ffffff 14%, transparent); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); + color: var(--abcpay-muted); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.ptr-indicator.is-armed .ptr-indicator-inner { + color: #ffffff; + border-color: color-mix(in srgb, var(--abcpay-accent) 55%, transparent); +} + +.ptr-indicator.is-force .ptr-indicator-inner { + color: #ffffff; + border-color: color-mix(in srgb, #f59e0b 65%, transparent); +} + +.ptr-indicator.is-done .ptr-indicator-inner { + color: #86efac; + border-color: color-mix(in srgb, #22c55e 45%, transparent); +} + +.ptr-spinner { + width: 1.05rem; + height: 1.05rem; + transform: rotate(calc(var(--ptr-progress, 0) * 270deg)); +} + +.ptr-indicator.is-reloading .ptr-spinner { + animation: ptr-spin 0.75s linear infinite; + transform: none; +} + +.ptr-label { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; +} + +@keyframes ptr-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ptr-indicator.is-reloading .ptr-spinner { + animation: none; + } +} + /* Floating liquid-glass tab bar (WLotus-style) */ .glass-nav { position: fixed; diff --git a/apps/abcpay-web/src/lib/token-icons.ts b/apps/abcpay-web/src/lib/token-icons.ts new file mode 100644 index 0000000..2520d9f --- /dev/null +++ b/apps/abcpay-web/src/lib/token-icons.ts @@ -0,0 +1,90 @@ +/** Cashtab / eTokens icon CDN (same host Cashtab uses via VITE_TOKEN_ICONS_URL). */ +export const TOKEN_ICONS_BASE_URL = 'https://icons.etokens.cash'; +export const TOKEN_ICONS_CACHE = 'abcpay-token-icons-v1'; +export const TOKEN_ICON_FALLBACK = '/currencies/eToken.svg'; + +export type TokenIconSize = 32 | 64 | 128 | 256 | 512; + +const memory = new Map(); +const inflight = new Map>(); + +export function tokenIconUrl(tokenId: string, size: TokenIconSize = 64): string { + const id = tokenId.trim().toLowerCase(); + return `${TOKEN_ICONS_BASE_URL}/${size}/${id}.png`; +} + +function cacheKey(tokenId: string, size: TokenIconSize): string { + return `${size}:${tokenId.trim().toLowerCase()}`; +} + +async function responseToObjectUrl(response: Response): Promise { + const blob = await response.blob(); + if (blob.size === 0) throw new Error('empty token icon'); + // Some browsers omit type on Cache API blobs; still treat PNG bytes as image. + if (blob.type && !blob.type.startsWith('image/')) throw new Error('invalid token icon'); + return URL.createObjectURL(blob); +} + +/** + * Resolve a displayable token icon URL, preferring Cache API → network → fallback. + * Successful fetches are stored in `abcpay-token-icons-v1` for later / offline use. + */ +export async function loadTokenIcon( + tokenId: string, + size: TokenIconSize = 64 +): Promise { + const id = tokenId.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(id)) return TOKEN_ICON_FALLBACK; + + const key = cacheKey(id, size); + const hit = memory.get(key); + if (hit) return hit; + + const pending = inflight.get(key); + if (pending) return pending; + + const task = (async () => { + const url = tokenIconUrl(id, size); + try { + if (typeof caches === 'undefined') { + memory.set(key, url); + return url; + } + const cache = await caches.open(TOKEN_ICONS_CACHE); + const cached = await cache.match(url); + if (cached) { + const objectUrl = await responseToObjectUrl(cached); + memory.set(key, objectUrl); + return objectUrl; + } + const res = await fetch(url, { mode: 'cors', credentials: 'omit' }); + if (!res.ok) throw new Error(`icon ${res.status}`); + try { + await cache.put(url, res.clone()); + } catch { + // Quota / private mode — still use the image this session. + } + const objectUrl = await responseToObjectUrl(res); + memory.set(key, objectUrl); + return objectUrl; + } catch { + memory.set(key, TOKEN_ICON_FALLBACK); + return TOKEN_ICON_FALLBACK; + } finally { + inflight.delete(key); + } + })(); + + inflight.set(key, task); + return task; +} + +/** Warm the icon cache for a set of token ids (e.g. after balance discovery). */ +export function prefetchTokenIcons( + tokenIds: Iterable, + size: TokenIconSize = 64 +): void { + for (const tokenId of tokenIds) { + void loadTokenIcon(tokenId, size); + } +} diff --git a/apps/abcpay-web/src/pages/HomePage.tsx b/apps/abcpay-web/src/pages/HomePage.tsx index d671b41..9ec7f7c 100644 --- a/apps/abcpay-web/src/pages/HomePage.tsx +++ b/apps/abcpay-web/src/pages/HomePage.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom'; import { COIN_CONFIGS } from '@bcpros/abcpay-models'; import { AbcPayLogo, CoinIcon, EyeToggle } from '../components/ui'; +import { TokenIcon } from '../components/TokenIcon'; import { formatFixed, hiddenTokenCount, visibleTokens, walletDisplayId } from '@bcpros/abcpay-wallet-core'; import { useWallets, accountNetworkLabel } from '../context/WalletContext'; import { formatTokenAtoms, tokenLabel } from '../lib/token-format'; @@ -143,11 +144,7 @@ export function HomePage() { className="flex items-center justify-between text-sm" > - + {tokenLabel(token)} diff --git a/apps/abcpay-web/src/pages/SettingsPage.tsx b/apps/abcpay-web/src/pages/SettingsPage.tsx index b4c8a66..c1f4b4d 100644 --- a/apps/abcpay-web/src/pages/SettingsPage.tsx +++ b/apps/abcpay-web/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { labelForAccount } from '../context/WalletContext'; import { useNotifications } from '../context/NotificationContext'; +import { TokenIcon } from '../components/TokenIcon'; import { API_URL } from '../lib/api'; import { tokenLabel } from '../lib/token-format'; @@ -124,10 +125,13 @@ export function SettingsPage() { key={token.tokenId} className="flex items-center justify-between gap-3 cursor-pointer" > - - {tokenLabel(token)} - - {token.tokenId.slice(0, 16)}… + + + + {tokenLabel(token)} + + {token.tokenId.slice(0, 16)}… + {token?.protocol ?? 'SLP'} token balance

-

- {token ? `${formatTokenAtoms(token.atoms, decimals)} ${label}` : 'Loading…'} -

- {token?.name && token.name !== label && ( -

{token.name}

- )} +
+ {tokenId && } +
+

+ {token ? `${formatTokenAtoms(token.atoms, decimals)} ${label}` : 'Loading…'} +

+ {token?.name && token.name !== label && ( +

{token.name}

+ )} +
+
diff --git a/apps/abcpay-web/src/pages/WalletDetailPage.tsx b/apps/abcpay-web/src/pages/WalletDetailPage.tsx index 8e7f585..74c6d99 100644 --- a/apps/abcpay-web/src/pages/WalletDetailPage.tsx +++ b/apps/abcpay-web/src/pages/WalletDetailPage.tsx @@ -8,6 +8,7 @@ import { useNotifications } from '../context/NotificationContext'; import { api } from '../lib/api'; import { intentFromProposal, mergeJoinRecordUntilComplete } from '../lib/proposal-verify'; import { broadcastProposal, signAndMaybeBroadcast } from '../lib/tx'; +import { TokenIcon } from '../components/TokenIcon'; import { formatTokenAtoms, tokenLabel } from '../lib/token-format'; import { formatAmount, hiddenTokenCount, visibleTokens, walletDisplayId } from '@bcpros/abcpay-wallet-core'; import { accountDisplayTitle, accountNetworkLabel, labelForAccount } from '../context/WalletContext'; @@ -176,7 +177,10 @@ export function WalletDetailPage() { )}
-

{tokenLabel(token)}

+
+ +

{tokenLabel(token)}

+
{token.name && token.name !== tokenLabel(token) && (

{token.name}

)}