// 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) => ``;
const HATS = [
{
id: 'beanie', nameKey: 'hatBeanie', sat: 21, w: 92, b: 78, r: -8,
art: svg(`
`),
},
{
id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 68, r: -6,
art: svg(`
₿
`),
},
{
id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 68, r: -7,
art: svg(`
`),
},
{
id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 70, r: -8,
art: svg(`
`),
},
{
id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 70, r: -7,
art: svg(`
`),
},
{
id: 'top', nameKey: 'hatTop', sat: 210000, w: 96, b: 72, r: -7,
art: svg(`
`),
},
{
id: 'wizard', nameKey: 'hatWizard', sat: 2100000, w: 112, b: 72, r: -4, dx: -8,
art: svg(`
`),
},
{
id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 72, r: -7,
art: svg(`
`),
},
];
const hatById = (id) => HATS.find((x) => x.id === id) || null;
const posStyle = (hat) => `width:${hat.w}%;bottom:${hat.b}%;transform:translateX(${-50 + (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 ------------------
const TTL = 6 * 3600_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);
};
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();
for (const pk of pks) worn.set(net + ':' + pk, { hat: (j.hats || {})[pk] || null, t: now });
saveStore();
if ([...worn.entries()].some(([pk, v]) => v.hat && v.t === now)) 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 > TTL) 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;
}
async function post(path, payload, signer) {
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;
}
// ---- 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; }
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(() => {
if (ui.hatShop && !ui.hatShopData) { ui.hatShopData = { owned: [], equipped: null, offline: true }; render(); }
});
}
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 fee headroom: the ASP charges the quoted route fee (~1%)
if (spendable < price + Math.ceil(price * 0.02) + 2) {
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' },
// the crown is not merchandise — it appears only in the
// collection of the head that owns it, never as a listing
...HATS.filter((hat) => hat.id !== 'crown' || ((d && d.owned) || []).includes('crown'))
.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.
wrapAvatar(pk, node) {
const hat = hatById(hatFor(pk));
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();
},
};
}