aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js163
-rw-r--r--src/features/gifts-wallet.js22
-rw-r--r--src/features/gifts.js86
-rw-r--r--src/features/messages.js80
-rw-r--r--src/features/sync.js3
-rw-r--r--src/features/zaps.js13
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(); },