Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/abcpay-web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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())
);
Expand All @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions apps/abcpay-web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,7 @@ import { RestorePage } from './pages/RestorePage';
export function App() {
return (
<div className="min-h-screen pb-28">
<PullToReload />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/scan" element={<ScanPage />} />
Expand Down
252 changes: 252 additions & 0 deletions apps/abcpay-web/src/components/PullToReload.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
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<boolean> {
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<void> {
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<PtrPhase>('idle');
const startYRef = useRef<number | null>(null);
const pullPxRef = useRef(0);
const trackingRef = useRef(false);
const phaseRef = useRef<PtrPhase>('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 (
<div
className={`ptr-indicator${active ? ' is-visible' : ''}${softArmed ? ' is-armed' : ''}${
hardArmed ? ' is-force' : ''
}${spinning ? ' is-reloading' : ''}${phase === 'uptodate' ? ' is-done' : ''}`}
style={{ '--ptr-pull': `${pullPx}px`, '--ptr-progress': String(progress) } as CSSProperties}
aria-hidden={!active}
>
<div className="ptr-indicator-inner">
<svg className="ptr-spinner" viewBox="0 0 24 24" aria-hidden="true">
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeDasharray="40"
strokeDashoffset={40 - 40 * (spinning ? 0.65 : progress)}
/>
</svg>
<span className="ptr-label">{ptrLabel(phase, pullPx)}</span>
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions apps/abcpay-web/src/components/TokenIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<img
src={src}
alt=""
className={`rounded-full shrink-0 object-cover bg-black/20 ${className}`}
loading="lazy"
decoding="async"
onError={() => setSrc(TOKEN_ICON_FALLBACK)}
/>
);
}
7 changes: 7 additions & 0 deletions apps/abcpay-web/src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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]);
Expand Down
Loading
Loading