From f23a0488854a58bea8c0cb2ddc8e1c424a6828f4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 16 Aug 2026 00:38:49 -0700 Subject: Hats: put your money where your head is — supporter hats perch above the avatar everywhere it appears, from 21-sat beanie to 2.1M-sat top hat; the crown stays on the founder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz --- src/features/hats.js | 337 +++++++++++++++++++++++++++++++++++++++++++++++ src/features/index.js | 3 +- src/features/messages.js | 7 +- 3 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 src/features/hats.js (limited to 'src/features') diff --git a/src/features/hats.js b/src/features/hats.js new file mode 100644 index 0000000..5f12ed0 --- /dev/null +++ b/src/features/hats.js @@ -0,0 +1,337 @@ +// 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 '../nostr-login.js'; +import { getNetwork } from '../api.js'; + +const REGISTRAR = 'https://names.coinos.io'; +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: 96, b: 56, r: -8, + art: svg(` + + + + `), + }, + { + id: 'party', nameKey: 'hatParty', sat: 2100, w: 66, b: 66, r: 11, + art: svg(` + + + `), + }, + { + id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 58, r: -6, + art: svg(` + + ₿ + + + `), + }, + { + id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 58, r: -7, + art: svg(` + + + + `), + }, + { + id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 60, r: -8, + art: svg(` + + + `), + }, + { + id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 60, r: -7, + art: svg(` + + + `), + }, + { + id: 'wizard', nameKey: 'hatWizard', sat: 210000, w: 112, b: 62, r: -4, + art: svg(` + + + + `), + }, + { + id: 'top', nameKey: 'hatTop', sat: 2100000, w: 96, b: 62, r: -7, + art: svg(` + + `), + }, + { + id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 62, r: 0, + art: svg(` + + + `), + }, +]; +const hatById = (id) => HATS.find((x) => x.id === id) || null; +const posStyle = (hat) => `width:${hat.w}%;bottom:${hat.b}%;transform:translateX(-50%) 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 r = await fetch(`${REGISTRAR}/hats?pks=${pks.join(',')}`); + 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(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(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.hatShopData = null; + ui.hatConfirm = null; + render(); + const me = ctx.shownPubkey(); + if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; } + fetch(`${REGISTRAR}/hats/${me}`).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(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 (getNetwork() !== 'mainnet') { 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) throw new Error(t('msgNoIdentity')); + const inv = await post('/hats/invoice', { hat: hat.id }, id.signer); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.sat }); + const rec = await post('/hats/claim', { paymentHash: inv.paymentHash, hat: hat.id }, id.signer); + ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped }; + worn.set(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) throw new Error(t('msgNoIdentity')); + const rec = await post('/hats/equip', { hat: hatId }, id.signer); + ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped }; + worn.set(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)), + hat.id === 'crown' + ? h('div', { class: 'muted small' }, t('hatCrownWho')) + : ownedHere ? h('div', { class: 'muted small' }, t('hatOwned')) : 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' }, ...HATS.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-block', onClick: openShop }, '🎩 ' + t('hats')); + }, + screenView() { + if (ui.screen !== 'wallet' || !ui.hatShop) return null; + return shopScreen(); + }, + }; +} diff --git a/src/features/index.js b/src/features/index.js index 6a8bb9f..5ecb7c0 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -11,6 +11,7 @@ import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; import { syncFeature } from './sync.js'; import { nwcFeature } from './nwc.js'; +import { hatsFeature } from './hats.js'; import { messagesFeature } from './messages.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and @@ -21,5 +22,5 @@ import { messagesFeature } from './messages.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx), messagesFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), hatsFeature(ctx), syncFeature(ctx), messagesFeature(ctx)]; } diff --git a/src/features/messages.js b/src/features/messages.js index 070c54e..db4ad93 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1051,7 +1051,7 @@ export function messagesFeature(ctx) { node.classList.add('clickable'); node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); } - return node; + return hook('wrapAvatar', pk, node) || node; }; // ---- profiles: view + own kind-0 editor --------------------------------- @@ -1568,6 +1568,7 @@ export function messagesFeature(ctx) { field(t('profPicture'), 'picture', 'https://…'), h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile }, ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')), + hook('hatShopEntry'), logoutBtn()) : mine ? logoutBtn() // the editor renders above once the kind 0 loads @@ -1633,7 +1634,7 @@ export function messagesFeature(ctx) { s.rows && s.rows.length ? h('div', { class: 'list' }, resultRows(h, s.rows, (r) => { if (r.pk) { openProfile(r.pk); render(); } - })) + }, (pk, node) => hook('wrapAvatar', pk, node))) : s.rows && s.q.trim().length >= 2 ? h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchNoResults')) : h('div', { class: 'small faint', style: 'text-align:center;padding:6px' }, t('searchUsersEmpty'))), @@ -1897,7 +1898,7 @@ export function messagesFeature(ctx) { : dmSearch.busy ? h('div', { class: 'row gap6', style: 'align-items:center;padding:4px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgSearching'))) : dmSearch.rows.length - ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk))) + ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node))) : h('div', { class: 'small muted' }, t('msgNoMatches')))); } // A long DM history must not bury the communities below it: past a -- cgit v1.2.3