aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js6
-rw-r--r--src/features/hats.js337
-rw-r--r--src/features/index.js3
-rw-r--r--src/features/messages.js7
-rw-r--r--src/i18n.js23
-rw-r--r--src/recipient-search.js20
-rw-r--r--src/style.css14
7 files changed, 394 insertions, 16 deletions
diff --git a/src/app.js b/src/app.js
index 3754773..ecb25bc 100644
--- a/src/app.js
+++ b/src/app.js
@@ -441,7 +441,7 @@ function render() {
// ui field that decides which page is on screen.
const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity,
ui.profilePk, ui.settingsPage, ui.addrScan, ui.arkExitPage, ui.txDetail, ui.giftMode, ui.claimStep, ui.nameEditOpen,
- ui.noteThread && ui.noteThread.focusId, !!ui.userSearch, !!ui.zapSetup].join('|');
+ ui.noteThread && ui.noteThread.focusId, !!ui.userSearch, !!ui.zapSetup, !!ui.hatShop].join('|');
if (uiChanged('nav', navKey)) _navAt = performance.now();
applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1);
morphChildren(root, [screen, footer()]);
@@ -491,7 +491,7 @@ wallet.subscribe(scheduleRender);
// buttons (and Android/system back) move between screens we've actually viewed.
// We snapshot only the navigation-relevant `ui` fields, so incidental re-renders
// (typing, polling, balance updates) don't create history entries.
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -3482,7 +3482,7 @@ function recipientRow(s, r, i) {
const syncSuggest = () => {
if (!suggest) return;
const show = sendSearch.rows && sendSearch.rows.length && searchable(r.address);
- suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient) : []));
+ suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient, (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
suggest.style.display = show ? '' : 'none';
};
if (i === 0) sendSearch.sync = syncSuggest;
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) => `<svg viewBox="0 0 64 44" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
+const HATS = [
+ {
+ id: 'beanie', nameKey: 'hatBeanie', sat: 21, w: 96, b: 56, r: -8,
+ art: svg(`<circle cx="32" cy="9" r="5.5" fill="#f3e6c9"/>
+ <path d="M11 34 Q11 12 32 12 Q53 12 53 34 Z" fill="#e0554a"/>
+ <path d="M20 14.5 Q26 25 25 32 M32 12.5 Q32 23 32 32 M44 14.5 Q38 25 39 32" stroke="#c9443b" stroke-width="2" fill="none"/>
+ <rect x="8" y="31" width="48" height="10" rx="5" fill="#c9443b"/>
+ <path d="M15 33 V39.5 M23 32.5 V40.5 M31 32.5 V40.5 M39 32.5 V40.5 M47 33 V39.5" stroke="#b23a32" stroke-width="2"/>`),
+ },
+ {
+ id: 'party', nameKey: 'hatParty', sat: 2100, w: 66, b: 66, r: 11,
+ art: svg(`<path d="M32 5 L46 39 Q32 44 18 39 Z" fill="#2fb3a5"/>
+ <path d="M26.5 18.5 Q32 20.5 37.5 18.5 L40 24.5 Q32 27 24 24.5 Z" fill="#ff7bac"/>
+ <path d="M21.5 30.5 Q32 34 42.5 30.5 L44.5 35.5 Q32 40 19.5 35.5 Z" fill="#ffd166"/>
+ <circle cx="32" cy="5" r="4.2" fill="#ffd166"/>`),
+ },
+ {
+ id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 58, r: -6,
+ art: svg(`<path d="M9 33 Q9 10 29 10 Q49 10 49 33 Z" fill="#3579c2"/>
+ <path d="M17 33 Q17 14 29 14 Q41 14 41 33 Z" fill="#f2f5f8"/>
+ <text x="29" y="28" font-size="14" font-weight="700" text-anchor="middle" fill="#f7931a" font-family="system-ui,sans-serif">₿</text>
+ <circle cx="29" cy="9.5" r="2.4" fill="#2a5f9c"/>
+ <path d="M47 28.5 Q60 27 62 32.5 Q60 37 52 36.5 L46 34.5 Z" fill="#2a5f9c"/>
+ <rect x="9" y="32" width="39" height="5" rx="2.5" fill="#2a5f9c"/>`),
+ },
+ {
+ id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 58, r: -7,
+ art: svg(`<path d="M21 26 Q21 5 32 5 Q43 5 43 26 Z" fill="#a97844"/>
+ <path d="M32 5.5 Q29.5 14 31 24" stroke="#8a5f33" stroke-width="1.8" fill="none"/>
+ <rect x="20" y="20.5" width="24" height="5" rx="2" fill="#6f4a26"/>
+ <path d="M6 27 Q5 18.5 11 24.5 L53 24.5 Q59 18.5 58 27 Q57 31.5 47 34.5 Q32 38.5 17 34.5 Q7 31.5 6 27 Z" fill="#a97844"/>
+ <path d="M8 28 Q17 32.5 32 32.5 Q47 32.5 56 28" stroke="#8a5f33" stroke-width="1.5" fill="none"/>`),
+ },
+ {
+ id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 60, r: -8,
+ art: svg(`<path d="M18 29 L20 11 Q32 6 44 11 L46 29 Z" fill="#4c525b" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>
+ <path d="M20 11 Q32 15.5 44 11" stroke="#3a3f47" stroke-width="2" fill="none"/>
+ <path d="M17.4 23 L46.6 23 L47 29 L17 29 Z" fill="#23262c"/>
+ <path d="M6 31 Q32 25 58 31 Q58 37 32 37.5 Q6 37 6 31 Z" fill="#3a3f47" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>`),
+ },
+ {
+ id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 60, r: -7,
+ art: svg(`<path d="M15 29 Q15 7 32 7 Q49 7 49 29 Z" fill="#2b2e35" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
+ <path d="M22 11 Q27 8.5 33 9.5" stroke="#464b55" stroke-width="2" fill="none" stroke-linecap="round"/>
+ <path d="M14.6 24 L49.4 24 L49.6 29 L14.4 29 Z" fill="#17191d"/>
+ <path d="M7 31 Q7 26.5 13 29 L51 29 Q57 26.5 57 31 Q57 36 32 36 Q7 36 7 31 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
+ },
+ {
+ id: 'wizard', nameKey: 'hatWizard', sat: 210000, w: 112, b: 62, r: -4,
+ art: svg(`<path d="M25 35 Q33 21 36.5 11 Q37.5 5.5 44 3 Q41 9 40 13 Q44 25 50 35 Z" fill="#6d4bb8"/>
+ <circle cx="44.5" cy="3.5" r="2.6" fill="#ffd166"/>
+ <ellipse cx="37" cy="36" rx="21" ry="5" fill="#59389f"/>
+ <path d="M33 19 l1.2 2.6 2.6 1.2 -2.6 1.2 -1.2 2.6 -1.2 -2.6 -2.6 -1.2 2.6 -1.2 Z" fill="#ffd166"/>
+ <path d="M41.5 26 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" fill="#ffe08a"/>`),
+ },
+ {
+ id: 'top', nameKey: 'hatTop', sat: 2100000, w: 96, b: 62, r: -7,
+ art: svg(`<path d="M17 5 Q32 1.5 47 5 L45.5 28 L18.5 28 Z" fill="#26282f" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
+ <path d="M18.7 22 L45.3 22 L45.6 28 L18.4 28 Z" fill="#b6382e"/>
+ <path d="M7 30.5 Q7 26.5 14 29 L50 29 Q57 26.5 57 30.5 Q57 36 32 36 Q7 36 7 30.5 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
+ },
+ {
+ id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 62, r: 0,
+ art: svg(`<path d="M10 38 L7 13 L20 25 L32 6 L44 25 L57 13 L54 38 Z" fill="#f2b32a"/>
+ <circle cx="7" cy="12" r="3" fill="#ffd97a"/><circle cx="32" cy="6" r="3" fill="#ffd97a"/><circle cx="57" cy="12" r="3" fill="#ffd97a"/>
+ <rect x="9" y="34" width="46" height="8" rx="2.5" fill="#d9930f"/>
+ <circle cx="18" cy="38" r="2.2" fill="#e04a56"/><circle cx="32" cy="38" r="2.2" fill="#3a7bd5"/><circle cx="46" cy="38" r="2.2" fill="#3fae6a"/>`),
+ },
+];
+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
diff --git a/src/i18n.js b/src/i18n.js
index 1402aa6..df2e62a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -212,6 +212,29 @@ const STR = {
profAbout: 'About',
profPicture: 'Picture URL',
profSaved: 'Profile published',
+ hats: 'Hats',
+ hatShopTitle: 'Hat shop',
+ hatShopBlurb: 'Support coinos and wear the proof: your hat shows above your avatar everywhere you appear.',
+ hatShopOffline: 'The hat stand is unreachable right now.',
+ hatBeanie: 'Beanie',
+ hatParty: 'Party hat',
+ hatTrucker: 'Trucker cap',
+ hatCowboy: 'Cowboy hat',
+ hatFedora: 'Fedora',
+ hatBowler: 'Bowler',
+ hatWizard: 'Wizard hat',
+ hatTop: 'Top hat',
+ hatCrown: 'The Crown',
+ hatCrownWho: "Adam's. Sorry.",
+ hatCrownTaken: 'not for sale',
+ hatOwned: 'In your hat rack',
+ hatWear: 'Wear',
+ hatWearing: 'Wearing ✓',
+ hatConfirmTap: 'Tap to confirm',
+ hatYours: '🎩 The {name} is yours!',
+ hatNoFunds: 'You need {sats} sats in Spending for that hat',
+ hatNeedSpending: 'Open your Spending balance first',
+ hatMainnetOnly: 'Hats are sold on mainnet only',
profNotesTitle: 'Latest posts',
profNotesNone: 'No recent posts found.',
profReplyTag: 'reply',
diff --git a/src/recipient-search.js b/src/recipient-search.js
index 5b7758b..bd1767b 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -220,15 +220,17 @@ export function makeSearcher(onUpdate) {
// Candidate rows (avatar + name + address/npub), styled with the chat list
// classes both pages already ship.
-export function resultRows(h, rows, onPick) {
- return rows.map((r) =>
- h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) },
- r.picture
- // background, not <img>: paints synchronously from cache, so rows
- // re-rendered per keystroke don't flash their avatars
- ? h('div', { class: 'chat-avatar ava-img', style: `background-image:url(${JSON.stringify(r.picture)})` })
- : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'),
+export function resultRows(h, rows, onPick, wrapAvatar) {
+ return rows.map((r) => {
+ const ava = r.picture
+ // background, not <img>: paints synchronously from cache, so rows
+ // re-rendered per keystroke don't flash their avatars
+ ? h('div', { class: 'chat-avatar ava-img', style: `background-image:url(${JSON.stringify(r.picture)})` })
+ : fallbackAvatar(h, r.pk, r.name, 'chat-avatar');
+ return h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) },
+ (wrapAvatar && wrapAvatar(r.pk, ava)) || ava,
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 12)),
- h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…'))));
+ h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…')));
+ });
}
diff --git a/src/style.css b/src/style.css
index 8716d81..5c41a26 100644
--- a/src/style.css
+++ b/src/style.css
@@ -479,6 +479,20 @@ hr.divider { margin: 16px 0; }
.menu-backdrop { position: fixed; inset: 0; z-index: 40; }
.avatar-menu { position: absolute; right: 0; top: calc(100% + 8px); z-index: 41; min-width: 170px; }
.chat-avatar.fallback.loading { background: var(--surface2); }
+
+/* hats: a supporter hat perched above any avatar. The wrapper takes the
+ avatar's place in the layout; the hat overflows upward, clicks fall
+ through it. */
+.hat-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
+.ava-hat { position: absolute; left: 50%; z-index: 1; pointer-events: none; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .28)); }
+.ava-hat svg { display: block; width: 100%; height: auto; }
+.hat-row { display: flex; gap: 12px; align-items: center; padding: 9px 2px; }
+.hat-row + .hat-row { border-top: 1px solid var(--line); }
+.hat-demo { position: relative; display: inline-flex; width: 34px; height: 34px; flex-shrink: 0; margin-top: 12px; }
+.hat-demo-head { width: 100%; height: 100%; border-radius: 50%; background: var(--accent-soft); }
+.hat-btn { white-space: nowrap; flex-shrink: 0; }
+.hat-btn.wearing { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
+.hat-btn.confirm { border-color: var(--green); color: var(--green); }
.wallet-row-btn { font-size: 19px; line-height: 1; padding: 8px 12px; }
.chat-avatar.header-ava.fallback { align-items: center; justify-content: center; display: flex; color: var(--muted); }