// Hats — cosmetic supporter hats, worn above the avatar everywhere it shows: // chat rows, DMs, profile pages, notes, search results, the header. // // Ownership lives on the names registrar (a kind-0 field would be // self-asserted — anyone could wear anything). Buying is a plain Lightning // invoice minted by the registrar and paid headlessly from the Spending // balance; the registrar grants on settle, and the sats simply stay with // coinos. That's the whole point: a hat is proof you chipped in. // // Display is a batched lookup (`GET /hats?pks=…`) cached alongside nothing // else — one request covers a whole room, answers persist for a few hours. // The crown is Adam's and is not for sale. import { t } from '../i18n.js'; import { fmtSats } from '../format.js'; import { nip98Header } from '../nip98.js'; import { getNetwork } from '../api.js'; const REGISTRAR = 'https://names.coinos.io'; // Hats exist per network: mainnet hats are the real supporter hats; mutinynet // runs a parallel play-money economy so staging can exercise the whole flow. const hatNet = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); const ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; // One drawing per hat, tuned to perch on a circle: `w` scales against the // avatar's width, `b` is where the hat's bottom sits (as % of avatar height, // from the bottom), `r` gives it the jaunty angle. const svg = (inner) => `${inner}`; const HATS = [ { id: 'beanie', nameKey: 'hatBeanie', sat: 21, w: 92, b: 79, r: -8, art: svg(` `), }, { id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 69, r: -6, art: svg(` ₿ `), }, { id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 69, r: -7, art: svg(` `), }, { id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 71, r: -8, art: svg(` `), }, { id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 71, r: -7, art: svg(` `), }, { id: 'top', nameKey: 'hatTop', sat: 210000, w: 96, b: 73, r: -7, art: svg(` `), }, { id: 'wizard', nameKey: 'hatWizard', sat: 2100000, w: 112, b: 83, r: -4, dx: -8, art: svg(` `), }, { // Not for sale: gifted by the operator to beta testers (see the // registrar's /hats/grant). Appears in the shop only once owned. id: 'construction', nameKey: 'hatConstruction', sat: null, w: 108, b: 71, r: -6, art: svg(` `), }, { id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 83, r: -7, art: svg(` `), }, ]; const hatById = (id) => HATS.find((x) => x.id === id) || null; // -58, not -50: every hat rides a couple of pixels left of center — // the art leans right and dead-center read as drifting off the head. const posStyle = (hat) => `width:${hat.w}%;bottom:${hat.b}%;transform:translateX(${-58 + (hat.dx || 0)}%) rotate(${hat.r}deg)`; export function hatsFeature(ctx) { const { h, ui, render, wallet, hook, toast, brandHeader } = ctx; // ---- who wears what: batched lookup + persistent cache ------------------ // Serve from cache instantly, but ask the registrar again after a couple // of minutes — six hours of staleness meant a hat swapped on one device // kept its old look on every other for the rest of the day. One batched // request per quiet period is what the /hats endpoint is for. const REVALIDATE = 2 * 60_000; const worn = new Map(); // pk -> { hat: id|null, t } let loaded = false; const loadStore = () => { if (loaded) return; loaded = true; try { for (const [pk, v] of Object.entries(wallet.loadFeatureState('hats', {}).worn || {})) worn.set(pk, v); } catch {} }; let saveT = 0; const saveStore = () => { clearTimeout(saveT); saveT = setTimeout(() => { try { const keep = [...worn.entries()].sort((a, b) => b[1].t - a[1].t).slice(0, 300); wallet.saveFeatureState('hats', { worn: Object.fromEntries(keep) }); } catch {} }, 800); }; // ---- boot stamps --------------------------------------------------------- // This feature is a DEFERRED chunk — for the beat between first paint and // the chunk landing, avatars render bare and hats popped in late. The core // bundle can't hold the hat art, so wrapAvatar leaves a STAMP per head // (art + percent geometry, size-independent) in localStorage, and the // avatar factory replays stamps until this chunk takes over. let stamps = null, stampT = 0; const stampStore = () => { if (stamps === null) { try { stamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { stamps = {}; } } return stamps; }; const stamp = (pk, hat) => { const s = stampStore(); if (!hat) { // a bare head clears its stamp, or a removed hat would ghost on boot if (s[pk]) { delete s[pk]; saveStamps(); } return; } const next = { s: posStyle(hat), a: hat.art, t: Date.now() }; if (!s[pk] || s[pk].a !== next.a || s[pk].s !== next.s) { s[pk] = next; const keys = Object.keys(s); if (keys.length > 40) { keys.sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); for (const k of keys.slice(0, keys.length - 40)) delete s[k]; } saveStamps(); } else s[pk].t = Date.now(); }; const saveStamps = () => { clearTimeout(stampT); stampT = setTimeout(() => { try { localStorage.setItem('hat-stamps', JSON.stringify(stamps)); } catch {} }, 500); }; const pending = new Set(); let fetchT = 0, failedAt = 0; const queueFetch = (pk) => { if (Date.now() - failedAt < 60_000) return; pending.add(pk); if (!fetchT) fetchT = setTimeout(flushFetch, 400); }; async function flushFetch() { fetchT = 0; const pks = [...pending].slice(0, 100); pending.clear(); if (!pks.length) return; try { const net = hatNet(); const r = await fetch(`${REGISTRAR}/hats?pks=${pks.join(',')}&net=${net}`); const j = await r.json(); if (!r.ok) throw new Error('hats lookup failed'); const now = Date.now(); let changed = false; for (const pk of pks) { const key = net + ':' + pk; const next = (j.hats || {})[pk] || null; // a hat coming OFF is a change too — the old check only repainted // when a fresh entry had a hat, so removals lingered on screen if ((worn.get(key) || {}).hat !== next) changed = true; worn.set(key, { hat: next, t: now }); } saveStore(); if (changed) render(); } catch { failedAt = Date.now(); } } // Stale-while-revalidate: answer from cache immediately, refresh behind. function hatFor(pk) { if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null; loadStore(); const v = worn.get(hatNet() + ':' + pk); if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk); return v ? v.hat : null; } // ---- registrar calls ---------------------------------------------------- const withTimeout = (p, ms, what) => Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms)), ]); // Hats belong to the shown identity (login npub when there is one, else // the wallet key) — the same face the hat will sit on. async function identity() { const id = hook('nostrLoginIdentity'); if (id) { const signer = id.signer || (await hook('nostrLoginResume')); return signer ? { pubkey: id.pubkey, signer } : null; } const pk = wallet.nostrPubkey && wallet.nostrPubkey(); return pk ? { pubkey: pk, signer: { signEvent: (e) => wallet.nostrSign(e) } } : null; } // 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); 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 ----------------------------------------------------------- function openShop() { ui.hatShop = true; ui.hatConfirm = null; loadShop(); } function loadShop() { ui.hatShopData = null; render(); const me = ctx.shownPubkey(); if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; } 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(); worn.set(hatNet() + ':' + me, { hat: j.equipped || null, t: now }); 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(); }; async function buy(hat) { const price = priceOf(hat); if (!['mainnet', 'mutinynet'].includes(getNetwork())) { toast(t('hatMainnetOnly')); return; } if (!hook('arkReady')) { toast(t('hatNeedSpending')); return; } const spendable = hook('arkSpendableSat') || 0; // Routing headroom, honestly sized: the hat invoice lives on the ASP's // own node, so the quoted route fee is ~0–2 sats. The old 2% guard // demanded 42k sats of phantom headroom on the wizard hat and told a // wallet holding 2,133,786 that it couldn't afford 2,100,000. if (spendable < price + Math.max(10, Math.ceil(price * 0.002))) { toast(t('hatNoFunds', { sats: fmtSats(price) })); return; } ui.hatBuying = hat.id; render(); try { const id = await identity(); if (!id) { if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity')); ui.hatBuying = null; ui.hatConfirm = null; render(); return; } const inv = await post('/hats/invoice', { hat: hat.id, net: hatNet() }, id.signer); await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.sat }); const rec = await post('/hats/claim', { paymentHash: inv.paymentHash, hat: hat.id, net: hatNet() }, id.signer); ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped }; worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() }); saveStore(); toast(t('hatYours', { name: t(hat.nameKey) })); } catch (e) { toast(e.message || String(e)); } finally { ui.hatBuying = null; ui.hatConfirm = null; render(); } } async function equip(hatId) { ui.hatBuying = hatId || 'none'; render(); try { const id = await identity(); if (!id) { if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity')); ui.hatBuying = null; render(); return; } const rec = await post('/hats/equip', { hat: hatId, net: hatNet() }, id.signer); ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped }; worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() }); saveStore(); } catch (e) { toast(e.message || String(e)); } finally { ui.hatBuying = null; render(); } } const priceOf = (hat) => { const d = ui.hatShopData; return (d && d.prices && d.prices[hat.id]) || hat.sat; }; const demo = (hat) => h('span', { class: 'hat-demo' }, h('span', { class: 'hat-demo-head' }), h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art })); function shopRow(hat, d, me) { const ownedHere = d && d.owned.includes(hat.id); const wearing = d && d.equipped === hat.id; const busy = ui.hatBuying === hat.id; let action; if (hat.id === 'crown' && me !== ADMIN_PK && !ownedHere) { action = h('span', { class: 'small faint' }, t('hatCrownTaken')); } else if (busy) { action = h('span', { class: 'spinner sm' }); } else if (wearing) { action = h('button', { class: 'btn-sm hat-btn wearing', onClick: () => equip(null) }, t('hatWearing')); } else if (ownedHere) { action = h('button', { class: 'btn-sm hat-btn', onClick: () => equip(hat.id) }, t('hatWear')); } else if (ui.hatConfirm === hat.id) { action = h('button', { class: 'btn-sm hat-btn confirm', onClick: () => buy(hat) }, t('hatConfirmTap')); } else { action = h('button', { class: 'btn-sm hat-btn', disabled: !!ui.hatBuying, onClick: () => { ui.hatConfirm = hat.id; render(); setTimeout(() => { if (ui.hatConfirm === hat.id) { ui.hatConfirm = null; render(); } }, 5000); }, }, fmtSats(priceOf(hat)) + ' sats'); } return h('div', { class: 'hat-row' }, demo(hat), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name' }, t(hat.nameKey)), ownedHere ? h('div', { class: 'muted small' }, t('hatOwned')) : hat.id === 'crown' ? h('div', { class: 'muted small' }, t('hatCrownWho')) : null), action); } function shopScreen() { const d = ui.hatShopData; const me = ctx.shownPubkey(); return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, '🎩 ' + t('hatShopTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('hatShopBlurb')), d == null ? h('div', { style: 'text-align:center;padding:16px' }, h('span', { class: 'spinner' })) : h('div', { class: 'col', style: 'gap:2px' }, // unpriced hats (the crown, the beta hard hat) are not // merchandise — they appear only in the collection of a head // that owns them, never as a listing ...HATS.filter((hat) => (priceOf(hat) || 0) > 0 || ((d && d.owned) || []).includes(hat.id)) .map((hat) => shopRow(hat, d, me))), d && d.offline ? h('div', { class: 'small faint', style: 'text-align:center' }, t('hatShopOffline')) : null), h('button', { class: 'btn-ghost btn-block', onClick: closeShop }, t('back'))); } // ------------------------------------------------------------------------- return { id: 'hats', // The avatar factory (and the search rows) offer every avatar up for // decoration; a bare head comes back null and is used as-is. // Present the moment this chunk lands — the avatar factory stops // replaying boot stamps once a real answer (hat or bare) is available. hatsReady() { return true; }, wrapAvatar(pk, node) { const hat = hatById(hatFor(pk)); stamp(pk, hat); // keep the boot stamp current (or clear it) if (!hat) return null; return h('span', { class: 'hat-wrap' }, node, h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art })); }, // Own-profile editor entry: the door to the shop. hatShopEntry() { return h('button', { class: 'btn-sm', style: 'margin-top:6px;align-self:flex-start', onClick: openShop, }, '🎩 ' + t('hatShopSupport')); }, screenView() { if (ui.screen !== 'wallet' || !ui.hatShop) return null; // A restored session brings the shop screen back WITHOUT its data // (hatShop rides the nav snapshot; the fetch result doesn't) — refetch // instead of rendering a collection nobody loaded. undefined = never // fetched; null = fetch in flight. if (ui.hatShopData === undefined) setTimeout(loadShop, 0); return shopScreen(); }, }; }