diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 163 | ||||
| -rw-r--r-- | src/features/gifts-wallet.js | 22 | ||||
| -rw-r--r-- | src/features/gifts.js | 86 | ||||
| -rw-r--r-- | src/features/messages.js | 80 | ||||
| -rw-r--r-- | src/features/sync.js | 3 | ||||
| -rw-r--r-- | src/features/zaps.js | 13 |
6 files changed, 322 insertions, 45 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 1a5a2a2..54bd32b 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -885,8 +885,9 @@ export function arkFeature(ctx) { // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; - function startNpubPay(pk, npub) { - const z = (ui.arkZap = { npub, pk, amount: '', comment: '', status: 'lookup' }); + function startNpubPay(pk, npub, eventId = null) { + const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup' }); + ui.zap = null; // a stale Lightning-zap card must not resurface behind ours ui.sendError = ''; render(); const live = () => ui.arkZap === z; @@ -925,7 +926,7 @@ export function arkFeature(ctx) { if (profile && (profile.lud16 || profile.lud06)) { await connectArk().catch(() => {}); if (!live()) return; - if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub); return; } + if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub, z.eventId); return; } } // 3. on-chain fallback if (onchain) { @@ -979,7 +980,8 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], }).catch(() => {}); ui.arkZapped = { amountSat: sats, npub: z.npub }; ui.arkZap = null; @@ -989,51 +991,121 @@ export function arkFeature(ctx) { ui.busy = false; render(); } + // No ark address published: the zap leaves anyway, as an ark gift locked to + // their nostr key. The sats move into a bearer vtxo now; the public link is + // safe to put in the receipt because only the recipient's key — or the + // claim code we DM them — opens it. Unclaimed, it stays revocable from the + // gift card like any other ark gift. + async function doArkZapGift() { + const z = ui.arkZap; + const sats = ctx.parseAmount(z.amount, ctx.getUnit()); + if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } + if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; } + ui.busy = true; ui.sendError = ''; render(); + try { + const g = await createArkGift(sats, z.pk); + const locked = ctx.hook('lockArkGift', g.code, sats, z.pk); + if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build + noteZap('to:' + g.address, z.pk); + const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } }); + ui.arkZap = null; + // best-effort receipt; carries the locked link so the recipient can + // discover the gift from the note even if the DM never lands + await wallet.nostrPublish({ + kind: ARK_ZAP_KIND, + content: (z.comment || '').slice(0, 280), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url]], + }).catch(() => {}); + const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); + if (wallet.sendNostrDM) { + wallet.sendNostrDM(z.pk, dmText) + .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); }) + .catch(() => { done.gift.dm = 'failed'; render(); }); + } else done.gift.dm = 'failed'; + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); + } + function arkZapView() { // While resolving, show the SHAPE of the card that's coming — same // title, same recipient chip, a spinner where the form will be. The // screen fills in rather than being replaced. if (ui.arkZap && ui.arkZap.status === 'lookup') return zapSkeleton(ui.arkZap.pk, ui.arkZap.npub); if (ui.arkZapped) { - return h('div', { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { ui.arkZapped = null; ui.send = blankSend(); render(); }, - }, - h('div', { class: 'check-badge' }, '⚡'), - h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), + const gift = ui.arkZapped.gift; + const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); }; + if (!gift) { + return h('div', { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: finish, + }, + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), + h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), + h('div', { class: 'small muted' }, t('tapToProceed'))); + } + // A locked-gift zap has aftercare: the DM's fate, and the link + claim + // code to pass along by hand when the DM couldn't be delivered. + return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' }, + h('div', { class: 'check-badge' }, '🎁'), + h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), - h('div', { class: 'small muted' }, t('tapToProceed'))); + gift.dm === 'sending' + ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending'))) + : gift.dm === 'sent' + ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent')) + : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url), + h('div', { class: 'row gap6 wrap', style: 'justify-content:center' }, + copyBtn(gift.url, t('copyLink')), + gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null), + h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done'))); } const z = ui.arkZap; if (!z) return null; + const spendable = arkBalance()?.spendableSat || 0; + // No ark address found — but a zap can still leave as an ark gift locked + // to their nostr key, when this build carries the gifts feature. + const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift'); + const amountInputs = (hint) => h('div', { class: 'col gap6' }, + h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, + onInput: (e) => { z.amount = e.target.value; } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, + onInput: (e) => { z.comment = e.target.value; } }), + h('div', { class: 'small faint' }, hint)); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, '⚡ ' + t('zapTitle')), ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub), z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null, - z.status === 'noark' ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, + z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, + giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null, + giftOk ? amountInputs(t('arkZapGiftHint')) : null, + giftOk + ? (ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn'))) + : null, + z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, // No Ark address, but they may still take a Lightning zap — hand off to // the zaps feature (present alongside swaps). (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap') - ? h('button', { class: 'btn-primary btn-block', onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub); } }, '⚡ ' + t('lnZapFallback')) + ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) : null, - z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, - z.status === 'ready' && (arkBalance()?.spendableSat || 0) < 330 + z.status === 'ready' && spendable < 330 ? h('div', { class: 'notice info' }, t('zapNoBalance')) : z.status === 'ready' - ? h('div', { class: 'col gap6' }, - h('div', { class: 'input-group' }, - h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, - onInput: (e) => { z.amount = e.target.value; } }), - h('div', { style: 'display:flex;align-items:center' }, unitTag())), - h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, - onInput: (e) => { z.comment = e.target.value; } }), - h('div', { class: 'small faint' }, t('arkZapHint'))) + ? amountInputs(t('arkZapHint')) : null, ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')), - z.status === 'ready' && (arkBalance()?.spendableSat || 0) >= 330 + z.status === 'ready' && spendable >= 330 ? (ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn'))) @@ -1510,6 +1582,24 @@ export function arkFeature(ctx) { })).then((flags) => { if (flags.some(Boolean)) ark._save(); }); } + // Fund a fresh bearer gift identity. Shared by the gifts feature's hook and + // the zap flow's locked-gift fallback; `lockedTo` records who a locked gift + // was for (the sender can still revoke either kind while unclaimed). + async function createArkGift(amountSat, lockedTo = null) { + const mgr = await connectArk(); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const secretHex = hex.encode(secret); + const gm = await giftManager(secretHex); + const address = gm.address(); + const actionId = await mgr.send(address, amountSat); + const action = mgr.state.actions.find((a) => a.id === actionId); + if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); + const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; + arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) }); + mgr._save(); + return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address }; + } + // Sweep the gift identity's balance to `destAddress`. Shared by claim // (recipient's address) and revoke (sender's own address). async function sweepArkGift(code, destAddress) { @@ -1962,6 +2052,15 @@ export function arkFeature(ctx) { startNpubPay(pk, String(text).trim()); return true; }, + // Zap a person — optionally a specific note of theirs (the profile feed's + // ⚡ button) — through the same resolution ladder as a pasted npub. The + // event id rides into the receipt's e tag; callers fall back to the + // lnZapNpub hook when ark can't serve this build/wallet. + zapNpub(pk, npub, eventId) { + if (!arkAvailable() || !wallet.nostrFetch) return false; + startNpubPay(pk, npub, eventId || null); + return true; + }, historyEntries() { const s = arkStateNow(); if (!s) return []; @@ -2062,19 +2161,7 @@ export function arkFeature(ctx) { setNetwork(g.net); return true; }, - async arkGiftCreate(amountSat) { - const mgr = await connectArk(); - const secret = crypto.getRandomValues(new Uint8Array(32)); - const secretHex = hex.encode(secret); - const gm = await giftManager(secretHex); - const actionId = await mgr.send(gm.address(), amountSat); - const action = mgr.state.actions.find((a) => a.id === actionId); - if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); - const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; - arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false }); - mgr._save(); - return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat }; - }, + arkGiftCreate(amountSat) { return createArkGift(amountSat); }, // Claim-side status: claimable (with the live amount), taken, wrongnet, or // unknown (not visible here — wrong server or not yet delivered). async arkGiftStatus(code) { diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js index bf8a82d..c25e85a 100644 --- a/src/features/gifts-wallet.js +++ b/src/features/gifts-wallet.js @@ -5,6 +5,7 @@ import * as btc from '@scure/btc-signer'; import { hex, base64urlnopad, base32nopad } from '@scure/base'; +import { encryptGiftPayload } from '../nostr.js'; import { utxoId } from '../wallet.js'; import { p2wpkh } from '@scure/btc-signer/payment'; import { concatBytes } from '@noble/hashes/utils'; @@ -467,3 +468,24 @@ export function buildClaimTx(code, toAddress, feeRate, net) { // public (anyone can see what it is and who it's for), but the claim payload (the // gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the // link can be shared openly yet only the recipient's nostr key can claim it. +// v2 wraps an ARK gift code (v1 wrapped on-chain PSBT gifts and is retired — +// its links no longer exist in the wild). Two ways in, same plaintext: the +// one-time claim code DM'd to the recipient, or a NIP-07 extension holding +// the recipient's key decrypting the ephemeral-key ciphertext in place. +const LOCKED_GIFT_VERSION = 2; +// Encrypt the gift's claim payload under a one-time code. The code is delivered +// to the recipient via a nostr DM; the public link carries only the ciphertext. +// Returns { blob (for the /lg/ link), claimCode (to DM) }. +export function lockGift(code, amount, recipientPkHex) { + const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey }))); + return { blob, claimCode }; +} +// The public fields of a locked-gift blob (or null). Decryption needs the code. +export function previewLockedGift(blob) { + try { + const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); + if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; + } catch {} + return null; +} diff --git a/src/features/gifts.js b/src/features/gifts.js index e3025d3..9c4e0eb 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -4,7 +4,8 @@ import { newMnemonic } from '../wallet.js'; import { getNetwork } from '../api.js'; -import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum } from './gifts-wallet.js'; +import { decryptWithCode, npubOf } from '../nostr.js'; +import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; @@ -49,6 +50,76 @@ export function giftsFeature(ctx) { } } + // A locked ark gift (/lg/ — base64url, so case-sensitive unlike /g/ and + // /ag/): the blob is public, the ark gift code inside is ciphertext only + // the recipient's nostr key (or the DM'd claim code) can open. + function readLockedGiftHash() { + try { + const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); + if (!m) return null; + history.replaceState(null, '', '/'); + return previewLockedGift(m[1]); + } catch { return null; } + } + + // The locked-gift claim screen: who it's for, and the two ways to open it. + function lockedGiftClaimView() { + const lk = ui.claimLocked; + const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); + const dismiss = () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); }; + return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, + h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, + h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), + h('h3', { style: 'margin:0' }, t('giftForYou')), + h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted' }, t('giftLockedTo')), + hook('profileChip', lk.to) || h('span', { class: 'small mono' }, (npubOf(lk.to) || lk.to).slice(0, 16) + '…')), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, + hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, + onInput: (e) => { ui.claimCodeInput = e.target.value; } }), + h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimBtn')), + h('button', { class: 'btn-ghost btn-block', onClick: dismiss }, t('dismiss')) + ) + ); + } + + // Both unlock paths decrypt to the ordinary ark gift code and then walk the + // ordinary claim flow — the lock is purely a gate on learning the code. + function acceptLockedPayload(payload) { + if (!payload || !arkGiftOf(payload)) return false; + ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + claimGift(payload); + return true; + } + + async function claimViaExtension() { + const ext = typeof globalThis !== 'undefined' && globalThis.nostr; + if (!ext) return; + ui.busy = true; ui.claimError = ''; render(); + try { + const pk = await ext.getPublicKey(); + if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } + if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } + const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); + ui.busy = false; + if (!acceptLockedPayload(payload)) { ui.claimError = t('extDecryptFailed'); render(); } + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } + } + + function submitLockedCode() { + const code = (ui.claimCodeInput || '').trim(); + if (!code) return; + let payload = null; + try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} + if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); } + } + // Entry point for claiming a gift code. If the recipient already has wallet(s), // offer to claim into one or make a new one; otherwise go straight to a fresh // wallet (the original first-timer flow). @@ -779,19 +850,30 @@ export function giftsFeature(ctx) { return { id: 'gifts', - // opened with ?gift / /g/ / /lg/ in the URL — start the claim flow + // opened with ?gift / /g/ / /ag/ / /lg/ in the URL — start the claim flow bootUrl() { const code = readGiftHash(); if (code) { claimGift(code); return true; } // bearer gift → claim into an existing wallet or a new one + const locked = readLockedGiftHash(); + if (locked) { ui.claimLocked = locked; render(); return true; } // locked gift → unlock screen first return false; }, giftOpened(code) { checkGiftClaimed(code); }, screenView() { + if (ui.claimLocked) return lockedGiftClaimView(); if (ui.viewGift) return viewGiftView(); if (ui.claimChoose) return claimChooseView(); if (ui.screen === 'claim') return claimScreen(); return null; }, + // Wrap an ark gift code in an npub-locked blob (the ark zap flow's + // "no ark address" fallback). Returns the public link + the one-time + // claim code to deliver to the recipient. + canLockGift() { return true; }, + lockArkGift(code, amount, recipientPkHex) { + const { blob, claimCode } = lockGift(code, amount, recipientPkHex); + return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; + }, receiveTakeover() { return claimCelebration(); }, sendView() { return ui.giftMode ? giftView() : null; }, sendFormExtras() { diff --git a/src/features/messages.js b/src/features/messages.js index 6d9217d..ab2e900 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1010,6 +1010,75 @@ export function messagesFeature(ctx) { } } + // ---- profile notes: latest public posts & replies ----------------------- + + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } + function notesFor(pk) { + let c = notesCache.get(pk); + if (c) return c; + c = { status: 'loading', notes: [] }; + notesCache.set(pk, c); + queryOn(NOTE_RELAYS, { kinds: [1], authors: [pk], limit: 30 }, 4500).then((evs) => { + const seen = new Set(); + c.notes = (evs || []) + .filter((e) => !seen.has(e.id) && seen.add(e.id)) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 20); + c.status = 'ready'; + if (ui.profilePk === pk) render(); + }).catch(() => { c.status = 'ready'; if (ui.profilePk === pk) render(); }); + return c; + } + + // Note content, safely: text stays text nodes — relay content must never + // reach innerHTML. URLs become links (image URLs inline), npub mentions a + // clickable @name, other nostr: refs a dim stub. + const NOTE_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi; + function noteBody(text) { + const out = []; + for (const part of String(text || '').split(NOTE_SPLIT)) { + if (!part) continue; + if (/^https?:\/\//i.test(part)) { + if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) { + out.push(h('img', { src: part, class: 'note-img', loading: 'lazy', + onError: (e) => { e.target.style.display = 'none'; } })); + } else { + out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' }, + part.length > 64 ? part.slice(0, 61) + '…' : part)); + } + } else if (/^nostr:npub1/i.test(part)) { + const mpk = parseNostrPubkey(part.slice(6)); + if (mpk) out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(mpk); } }, '@' + displayName(mpk))); + else out.push(part); + } else if (/^nostr:/i.test(part)) { + out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); + } else out.push(part); + } + return out; + } + + // Zap a specific note: ark first (instant, free, e-tagged receipt), else + // the Lightning zap flow — same ladder as paying the person directly. + function zapNote(pk, ev) { + const npubStr = npubOf(pk); + ui.profilePk = null; + ui.chatOpen = false; + ui.tab = 'send'; + render(); + if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); + } + + function noteCard(pk, ev) { + const isReply = ev.tags.some((x) => x[0] === 'e'); + const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); + return h('div', { class: 'card col', style: 'gap:6px' }, + h('div', { class: 'row between', style: 'align-items:center' }, + h('span', { class: 'small faint' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)), + canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: () => zapNote(pk, ev) }, '⚡') : null), + h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content))); + } + async function saveProfile() { const id = await identity(); if (!id) { toast(t('msgNoIdentity')); return; } @@ -1131,6 +1200,17 @@ export function messagesFeature(ctx) { render(); hook('matchSendText', npubStr); } }, t('profPay')))), + // Their latest public notes, zappable in place. + (() => { + const c = notesFor(pk); + if (c.status === 'loading') + return h('div', { class: 'card col', style: 'align-items:center;padding:18px' }, h('span', { class: 'spinner sm' })); + if (!c.notes.length) + return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone')); + return h('div', { class: 'col', style: 'gap:10px' }, + h('div', { class: 'small muted', style: 'padding:0 2px' }, t('profNotesTitle')), + ...c.notes.map((ev) => noteCard(pk, ev))); + })(), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.profEdit = null; render(); } }, t('back'))); } diff --git a/src/features/sync.js b/src/features/sync.js index 481dda9..35bf358 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -63,6 +63,9 @@ export function installSyncWallet(wallet) { nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; }, // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. + async sendNostrDM(recipientPkHex, text) { + return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; + }, // Generic event publish/fetch on the configured sync relays — the seam // other features (ark zaps) use to speak their own kinds. No-ops when diff --git a/src/features/zaps.js b/src/features/zaps.js index 18676de..3f1b599 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -41,7 +41,8 @@ export function zapsFeature(ctx) { if (profile && profile.name) z.name = profile.name; const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } - z.target = target = lnTarget; + z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped + z.address = lnTarget.address || z.address; } const params = await fetchPayParams(target.url); @@ -72,7 +73,8 @@ export function zapsFeature(ctx) { if (canZap) { zapRequest = buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, - recipientPk: z.target.pk, comment: z.comment, signFn: (partial) => wallet.nostrSign(partial), + recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment, + signFn: (partial) => wallet.nostrSign(partial), }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); @@ -197,10 +199,11 @@ export function zapsFeature(ctx) { // ark's "no Ark address" screen checks this before offering the fallback. canLnZap() { return canPay() && !!wallet.nostrProfile; }, // Ark's "no Ark address published" screen offers a Lightning fallback, - // which lands here with the recipient's pubkey. - lnZapNpub(pk, npub) { + // which lands here with the recipient's pubkey (and, when the zap was + // aimed at a note, its event id for the zap request's e tag). + lnZapNpub(pk, npub, eventId) { if (!canPay() || !wallet.nostrProfile) return false; - begin({ kind: 'npub', pk }, shortNpub(npub || npubOf(pk))); + begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, sendView() { return zapView(); }, |
