diff options
| author | Adam Soltys <[email protected]> | 2026-09-10 14:02:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-10 14:02:58 -0700 |
| commit | 1c34bb343c47f6e200575bac967959656ae7becb (patch) | |
| tree | db3a5aa553d36f087fa00bab78bd310f90c2f2c8 /src | |
| parent | 521cccbb60546190a91ae126164a456cc3b749bd (diff) | |
| download | coinosv3-1c34bb343c47f6e200575bac967959656ae7becb.tar.gz coinosv3-1c34bb343c47f6e200575bac967959656ae7becb.zip | |
Zaps in chat, and a bolt + sats tally on chat messages, DMs and posts.
Chat and DM message sheets grow a ⚡ Zap item (not on your own messages),
riding the same one-tap flow as a post zap — Ark first, Lightning fallback,
the amount remembered from the first time. A chat message is a rumor with an
id like any event, so the zap e-tags that id; the public receipt reveals
nothing but the id.
Every post row, chat bubble and DM shows how much it's been zapped as a
little amber bolt + sats chip (1.2k / 21k / 1.5M), fed by the public receipts
that e-tag it: NIP-57 kind 9735 (amount from the bolt11, the zap request's
amount as fallback) and coinos' own Ark receipt, kind 9737 (network-checked).
Summed per id, deduped by receipt; underlined when one of ours is in it.
Ids are asked once each (batched per paint, 150 per REQ), and ONE live
subscription follows the ~200 ids most recently on screen so a zap landing
while you watch — yours included, rechecked at 4/12/30s — shows up without
a reload. The tally chip is itself a zap button on others' messages.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 129 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 5 |
3 files changed, 128 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 574c3c5..5535362 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -26,6 +26,7 @@ import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js'; import { saveInbox } from '../dm-inbox.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js'; import { getNetwork } from '../api.js'; +import { decodeBolt11 } from '../ark/lightning.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; @@ -750,6 +751,7 @@ export function messagesFeature(ctx) { close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !mine && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null, item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); @@ -1791,6 +1793,102 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + + // ---- zap tallies --------------------------------------------------------- + // How much a post or chat message has been zapped: the public receipts + // that e-tag it — NIP-57 kind 9735 (the recipient's LNURL server signs one + // once the invoice is paid) and coinos' own Ark zap receipt (kind 9737, + // the zapper publishes it beside the mailbox delivery). Summed per id, + // deduped by receipt, painted as a little bolt + sats. A chat message is a + // rumor with an id like any event, so a zap aimed at it e-tags that id — + // the receipt reveals nothing but the id itself. + const ZAP_KINDS = [9735, 9737]; + const zapTotals = new Map(); // id -> { sats, seen: Set<receipt id>, mine } + const zapAsked = new Set(); + let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = []; + const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])]; + const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + function receiptSats(ev) { + if (ev.kind === 9737) { + const net = tagOf(ev, 'network'); + if (net && net !== getNetwork()) return 0; + return Math.max(0, parseInt(tagOf(ev, 'amount'), 10) || 0); + } + const b11 = tagOf(ev, 'bolt11'); + if (b11) { try { const d = decodeBolt11(b11); if (d && d.amountSat) return d.amountSat; } catch {} } + // no readable invoice: the zap request inside says what was asked for + try { + const req = JSON.parse(tagOf(ev, 'description') || 'null'); + return Math.floor((parseInt(tagOf(req, 'amount'), 10) || 0) / 1000); + } catch { return 0; } + } + function zapperOf(ev) { + if (ev.kind === 9737) return ev.pubkey; + if (tagOf(ev, 'P')) return tagOf(ev, 'P'); + try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; } + } + function noteReceipt(ev) { + const sats = receiptSats(ev); + if (!sats) return; + const my = myPubkeys(); + const from = zapperOf(ev); + let changed = false; + for (const x of ev.tags) { + if (x[0] !== 'e' || !x[1]) continue; + const cur = zapTotals.get(x[1]) || zapTotals.set(x[1], { sats: 0, seen: new Set(), mine: false }).get(x[1]); + if (cur.seen.has(ev.id)) continue; + cur.seen.add(ev.id); + cur.sats += sats; + if (from && my.includes(from)) cur.mine = true; + changed = true; + } + if (changed) scheduleRepaint(); + } + // Ask once per id (batched per paint), and keep ONE live subscription on + // the ids most recently on screen, so a zap landing while you watch — + // yours included — shows up without a reload. + function watchZaps(ids) { + let fresh = false; + for (const id of ids) if (id && !zapAsked.has(id)) { zapAsked.add(id); zapQueue.add(id); fresh = true; } + if (!fresh || zapTimer) return; + zapTimer = setTimeout(() => { + zapTimer = null; + const batch = [...zapQueue]; zapQueue = new Set(); + const relays = zapRelays(); + for (let i = 0; i < batch.length; i += 150) { + queryOn(relays, { kinds: ZAP_KINDS, '#e': batch.slice(i, i + 150) }, 4000) + .then((evs) => evs.forEach(noteReceipt)).catch(() => {}); + } + zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200); + if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} } + zapLiveUnsub = subscribeOn(relays, { kinds: ZAP_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteReceipt); + }, 250); + } + // After OUR zap the receipt trails the payment by seconds — ask again. + function recheckZap(id) { + for (const ms of [4000, 12000, 30000]) setTimeout(() => { zapAsked.delete(id); watchZaps([id]); }, ms); + } + const fmtSats = (n) => n < 1000 ? String(n) + : n < 10000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' + : n < 1e6 ? Math.round(n / 1000) + 'k' + : (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; + const BOLT_SVG = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:block"><path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z"/></svg>'; + // The chip: a little bolt + the sats total; absent until the first receipt. + function zapChip(id, { onClick, cls = '' } = {}) { + const z = zapTotals.get(id); + if (!z || !z.sats) return null; + return h('span', { + class: 'zap-tally' + (z.mine ? ' on' : '') + (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''), + title: t('zapTallyTitle', { n: z.sats.toLocaleString() }), + onClick: onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined, + }, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(z.sats)); + } + // Zap a chat message or DM: same one-tap flow as a post (ark first, + // Lightning fallback, the amount remembered from the first time). + function zapMessage(pk, id) { + zapNote(pk, { id }); + recheckZap(id); + } const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } // NIP-65: where this author actually writes. Their notes and threads live // there first — our default relays are just the common ground. @@ -1918,6 +2016,9 @@ export function messagesFeature(ctx) { // the zap fires instantly (ark first, Lightning fallback) and reports by // toast, no form, no leaving the page. Without one, a small setup screen // asks once and remembers. + // Whether a ⚡ makes sense for this author from this wallet: not + // ourselves, and an instant path (Ark) or a Lightning fallback exists. + const canZapPk = (pk) => !!pk && !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); function zapNote(pk, ev) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; @@ -1966,6 +2067,7 @@ export function messagesFeature(ctx) { // dimmed, and no thread/reply/zap until its signed self takes over const pending = !!ev.pending; const openable = open && !pending; + if (!pending) watchZaps([ev.id]); return h('div', { class: 'row', style: 'gap:10px;align-items:flex-start;padding:10px 0' @@ -1988,7 +2090,8 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))), + (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)), + zapChip(ev.id, { onClick: canZap ? () => { zapNote(pk, ev); recheckZap(ev.id); } : null })), pending ? null : h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' }, // Reply on every post: opens (or re-targets) its thread and puts // the cursor in the reply box — no hunting for the row tap. @@ -2003,7 +2106,7 @@ export function messagesFeature(ctx) { setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120); }, }, '↩'), - canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); } }, '⚡') : null)), + canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); recheckZap(ev.id); } }, '⚡') : null)), h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)))); } @@ -3039,6 +3142,7 @@ export function messagesFeature(ctx) { if (!msgs.length) return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))]; let lastAuthor = null, lastT = 0; + watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); return msgs.map((m) => { const tms = eventMs(m.rumor); const mine = my.includes(m.author); @@ -3085,16 +3189,18 @@ export function messagesFeature(ctx) { mine ? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×') : null, - // reactions tuck inside the bubble, under the text (Telegram-style) - counts.size + // reactions tuck inside the bubble, under the text (Telegram-style); + // the zap total leads the row, and tapping it zaps again + ((zc) => counts.size || zc ? h('div', { class: 'chat-reacts' }, + zc, [...counts.entries()].map(([emoji, n]) => h('span', { class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), title: t('msgReact'), onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); }, }, emoji, n > 1 ? ' ' + n : ''))) - : null)) + : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: !mine && canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null })))) ); }); } @@ -3273,6 +3379,7 @@ export function messagesFeature(ctx) { close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !m.mine && canZapPk(m.rumor.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null, item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {} close(); @@ -3298,13 +3405,16 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), h('span', { class: 'chat-quote-text' }, String(src.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))); }; + watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); const dmChips = (m) => { const reacts = dmReacts.get(m.rumor.id); - if (!reacts || !reacts.size) return null; - const myReact = my.map((pk) => reacts.get(pk)).find(Boolean); + const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: !m.mine && canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null }); + if ((!reacts || !reacts.size) && !zc) return null; + const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); const counts = new Map(); - for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); + if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); return h('div', { class: 'chat-reacts' }, + zc, [...counts.entries()].map(([emoji, n]) => h('span', { class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), onClick: (e) => { e.stopPropagation(); sendDmReaction(peer, m, emoji); }, @@ -3550,6 +3660,9 @@ export function messagesFeature(ctx) { pendingWraps.clear(); dmStarted = false; listsSynced = false; + if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } + clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; + zapTotals.clear(); zapAsked.clear(); // 'mine' is per identity — refetch under the next }, }; } diff --git a/src/i18n.js b/src/i18n.js index 9d4bf73..5016754 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -191,6 +191,8 @@ const STR = { msgAdmin: 'admin', msgEdited: '(edited)', msgReact: 'React', + msgZap: 'Zap', + zapTallyTitle: '{n} sats zapped', msgReply: 'Reply', profNewPost: 'New post', profYourProfile: 'Your profile', diff --git a/src/style.css b/src/style.css index 95c0935..ba94c11 100644 --- a/src/style.css +++ b/src/style.css @@ -480,6 +480,11 @@ hr.divider { margin: 16px 0; } } .chat-react.clickable { cursor: pointer; } .chat-react.on { border-color: color-mix(in srgb, currentColor 55%, transparent); font-weight: 600; } +/* zap tally: a little bolt + sats, amber on both themes; .on = we zapped it */ +.zap-tally { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 650; color: #e6a800; white-space: nowrap; flex-shrink: 0; } +.zap-tally.chat-react { font-size: 12px; padding: 1px 7px 1px 5px; } +.zap-tally.clickable { cursor: pointer; } +.zap-tally.on { text-decoration: underline; text-decoration-color: color-mix(in srgb, #e6a800 60%, transparent); text-underline-offset: 2px; } .chat-compose { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); align-items: center; flex: 0 0 auto; |
