aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-30 23:10:28 -0700
committerAdam Soltys <[email protected]>2026-08-30 23:10:28 -0700
commitf517cb23fa37b15922c8ad333952df09847ce8b6 (patch)
tree9ae377287e641d76be62de0dcdde76c59631e08b
parentb775d2b29bcc2964cbc374771f86ce39d83cb8b7 (diff)
downloadcoinosv3-f517cb23fa37b15922c8ad333952df09847ce8b6.tar.gz
coinosv3-f517cb23fa37b15922c8ad333952df09847ce8b6.zip
A first-tap Safari hiccup stops costing a hat sale: the shop's registrar posts retry once on network-shaped failures (Safari's bare 'Load failed' included — fresh NIP-98 header per attempt, /hats/claim already idempotent, a real refusal never re-posts), and the shop listing quietly retries once before declaring itself offline
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
-rw-r--r--src/features/hats.js32
1 files changed, 23 insertions, 9 deletions
diff --git a/src/features/hats.js b/src/features/hats.js
index 2b4a4d6..37997f4 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -185,16 +185,27 @@ export function hatsFeature(ctx) {
return pk ? { pubkey: pk, signer: { signEvent: (e) => wallet.nostrSign(e) } } : null;
}
- async function post(path, payload, signer) {
+ // Safari reports a dropped request as the bare "Load failed", and a fresh
+ // PWA's first tap is prime territory for exactly one of those. Retry a
+ // NETWORK-shaped failure once, transparently (a fresh NIP-98 header each
+ // attempt); a real refusal from the registrar never re-posts. Safe for
+ // both posts: /hats/invoice mints anew, /hats/claim is idempotent.
+ const NETWORKISH = /load failed|failed to fetch|network|timed? ?out/i;
+ async function post(path, payload, signer, attempt = 0) {
const url = `${REGISTRAR}${path}`;
const body = JSON.stringify(payload);
- const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing');
- const r = await withTimeout(fetch(url, {
- method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body,
- }), 12000, 'registrar');
- const j = await r.json().catch(() => ({}));
- if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
- return j;
+ try {
+ const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing');
+ const r = await withTimeout(fetch(url, {
+ method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body,
+ }), 12000, 'registrar');
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ return j;
+ } catch (e) {
+ if (attempt < 1 && NETWORKISH.test(e.message || '')) return post(path, payload, signer, attempt + 1);
+ throw e;
+ }
}
// ---- the shop -----------------------------------------------------------
@@ -210,7 +221,7 @@ export function hatsFeature(ctx) {
render();
const me = ctx.shownPubkey();
if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; }
- fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => {
+ const attempt = (retriesLeft) => fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => {
if (!ui.hatShop) return;
ui.hatShopData = { owned: j.owned || [], equipped: j.equipped || null, prices: j.prices || null };
const now = Date.now();
@@ -218,8 +229,11 @@ export function hatsFeature(ctx) {
saveStore();
render();
}).catch(() => {
+ // one quiet retry before showing the offline shop — see post()
+ if (retriesLeft > 0) return new Promise((r) => setTimeout(r, 1200)).then(() => attempt(retriesLeft - 1));
if (ui.hatShop && !ui.hatShopData) { ui.hatShopData = { owned: [], equipped: null, offline: true }; render(); }
});
+ attempt(1);
}
const closeShop = () => { ui.hatShop = null; ui.hatShopData = null; ui.hatConfirm = null; render(); };