- Split single Zap button into Zap (⚡ Lightning) and Nutzap (🥜 Cashu) buttons - Each button has its own count display and popup dialog - Zap button: only sends Lightning zaps via Cashu melt, simplified dialog (no rail selector) - Nutzap button: sends NIP-61 nutzaps, dialog shows recipient's accepted mints - Removed capability badges (buttons themselves show capability via dimming) - Removed separate nutzap count display (count now on Nutzap button) - Added applyZapCapabilityDimming() — dims buttons based on resolveZapCapabilities - Added promptNutzapDetails() to zaps.mjs — shows recipient mints and shared mint info - Simplified promptZapDetails() — removed rail selector, Lightning only - Added .interaction-item.nutzap and .dimmed CSS styles - All colors use CSS variables only
945 lines
32 KiB
JavaScript
945 lines
32 KiB
JavaScript
const DEFAULT_FALLBACK_RELAY_HINTS = ['wss://relay.damus.io'];
|
|
|
|
let activeZapModalEl = null;
|
|
|
|
export function normalizeLud16(lud16) {
|
|
const value = String(lud16 || '').trim();
|
|
if (!value || !value.includes('@')) return '';
|
|
const [name, domain] = value.split('@');
|
|
if (!name || !domain) return '';
|
|
return `${name}@${domain}`;
|
|
}
|
|
|
|
export async function resolveZapSpecForPubkey(pubkey, options = {}) {
|
|
const { fetchProfile, logPrefix = '[zaps]' } = options;
|
|
if (typeof fetchProfile !== 'function') {
|
|
throw new Error('resolveZapSpecForPubkey requires fetchProfile(pubkey)');
|
|
}
|
|
|
|
console.log(`${logPrefix} resolveZapSpecForPubkey:start`, {
|
|
recipient: String(pubkey || '').slice(0, 8) + '…'
|
|
});
|
|
|
|
const profile = await fetchProfile(pubkey);
|
|
const lud16 = normalizeLud16(profile?.lud16);
|
|
if (!lud16) {
|
|
throw new Error('Recipient has no lud16 lightning address');
|
|
}
|
|
|
|
const [name, domain] = lud16.split('@');
|
|
const lnurl = new URL(`/.well-known/lnurlp/${encodeURIComponent(name)}`, `https://${domain}`).toString();
|
|
console.log(`${logPrefix} resolveZapSpecForPubkey:lnurl`, { lud16, lnurl });
|
|
|
|
const response = await fetch(lnurl, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`LNURL lookup failed (${response.status})`);
|
|
}
|
|
|
|
const body = await response.json();
|
|
if (body?.status === 'ERROR') {
|
|
throw new Error(body?.reason || 'LNURL server returned an error');
|
|
}
|
|
|
|
if (!body?.callback) {
|
|
throw new Error('LNURL pay response missing callback');
|
|
}
|
|
if (!body?.allowsNostr || !body?.nostrPubkey) {
|
|
throw new Error('Recipient lightning address does not support zaps');
|
|
}
|
|
|
|
const spec = {
|
|
callback: body.callback,
|
|
minSendable: Number(body.minSendable) || 0,
|
|
maxSendable: Number(body.maxSendable) || Number.MAX_SAFE_INTEGER,
|
|
nostrPubkey: body.nostrPubkey,
|
|
lnurl
|
|
};
|
|
|
|
console.log(`${logPrefix} resolveZapSpecForPubkey:ok`, {
|
|
callbackHost: (() => {
|
|
try { return new URL(spec.callback).host; } catch (_) { return '(invalid)'; }
|
|
})(),
|
|
minSendable: spec.minSendable,
|
|
maxSendable: spec.maxSendable
|
|
});
|
|
|
|
return spec;
|
|
}
|
|
|
|
export async function resolveNutzapSpecForPubkey(pubkey, options = {}) {
|
|
const { fetchMintList, logPrefix = '[zaps]' } = options;
|
|
if (typeof fetchMintList !== 'function') {
|
|
throw new Error('resolveNutzapSpecForPubkey requires fetchMintList(pubkey)');
|
|
}
|
|
|
|
const recipient = String(pubkey || '').trim();
|
|
if (!recipient) {
|
|
throw new Error('Recipient pubkey is required');
|
|
}
|
|
|
|
console.log(`${logPrefix} resolveNutzapSpecForPubkey:start`, {
|
|
recipient: recipient.slice(0, 8) + '…'
|
|
});
|
|
|
|
const mintList = await fetchMintList(recipient);
|
|
const mints = Array.isArray(mintList?.mints)
|
|
? mintList.mints.map((m) => String(m || '').trim().replace(/\/+$/, '')).filter(Boolean)
|
|
: [];
|
|
const relays = Array.isArray(mintList?.relays)
|
|
? mintList.relays.map((r) => String(r || '').trim()).filter((r) => /^wss?:\/\//i.test(r))
|
|
: [];
|
|
const p2pk = String(mintList?.p2pk || '').trim() || null;
|
|
|
|
if (!Boolean(mintList?.hasMintList) || mints.length === 0) {
|
|
throw new Error('Recipient has no kind 10019 mint list for nutzaps');
|
|
}
|
|
|
|
const spec = {
|
|
mints: Array.from(new Set(mints)),
|
|
relays: Array.from(new Set(relays)),
|
|
p2pk
|
|
};
|
|
|
|
console.log(`${logPrefix} resolveNutzapSpecForPubkey:ok`, {
|
|
mints: spec.mints.length,
|
|
relays: spec.relays.length,
|
|
hasP2pk: Boolean(spec.p2pk)
|
|
});
|
|
|
|
return spec;
|
|
}
|
|
|
|
export async function getZapRelayHints(options = {}) {
|
|
const {
|
|
getRelayData,
|
|
fallbackRelays = DEFAULT_FALLBACK_RELAY_HINTS
|
|
} = options;
|
|
|
|
if (typeof getRelayData !== 'function') {
|
|
return [...fallbackRelays];
|
|
}
|
|
|
|
try {
|
|
const relays = await getRelayData();
|
|
const list = Array.isArray(relays) ? relays : [];
|
|
const connected = list.filter((relay) => String(relay?.status || '').toLowerCase() === 'connected');
|
|
const source = connected.length > 0 ? connected : list;
|
|
const relayUrls = source
|
|
.map((relay) => String(relay?.url || '').trim())
|
|
.filter((url) => /^wss?:\/\//i.test(url))
|
|
.slice(0, 4);
|
|
|
|
if (relayUrls.length > 0) return relayUrls;
|
|
} catch (_error) {}
|
|
|
|
return [...fallbackRelays];
|
|
}
|
|
|
|
export async function createSignedZapRequest(options = {}) {
|
|
const {
|
|
eventId = '',
|
|
recipientPubkey,
|
|
amountSats,
|
|
comment = '',
|
|
zapSpec,
|
|
relayHints,
|
|
getRelayData,
|
|
logPrefix = '[zaps]'
|
|
} = options;
|
|
|
|
if (!window?.nostr?.signEvent) {
|
|
throw new Error('Signer unavailable (window.nostr.signEvent missing)');
|
|
}
|
|
|
|
if (!recipientPubkey) {
|
|
throw new Error('Recipient pubkey is required');
|
|
}
|
|
|
|
const amountMsats = Math.floor(Number(amountSats) * 1000);
|
|
const resolvedRelayHints = Array.isArray(relayHints) && relayHints.length > 0
|
|
? relayHints
|
|
: await getZapRelayHints({ getRelayData });
|
|
|
|
const tags = [
|
|
['p', recipientPubkey],
|
|
['amount', String(amountMsats)],
|
|
['relays', ...resolvedRelayHints],
|
|
['lnurl', zapSpec?.lnurl || '']
|
|
];
|
|
|
|
if (eventId) {
|
|
tags.push(['e', eventId]);
|
|
}
|
|
|
|
const unsignedZapRequest = {
|
|
kind: 9734,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: String(comment || ''),
|
|
tags
|
|
};
|
|
|
|
console.log(`${logPrefix} createSignedZapRequest:signing`, {
|
|
eventId: String(eventId || '').slice(0, 8) + '…',
|
|
recipient: String(recipientPubkey || '').slice(0, 8) + '…',
|
|
amountSats,
|
|
relayHints: resolvedRelayHints
|
|
});
|
|
|
|
const signed = await window.nostr.signEvent(unsignedZapRequest);
|
|
console.log(`${logPrefix} createSignedZapRequest:ok`, {
|
|
id: String(signed?.id || '').slice(0, 12) + '…'
|
|
});
|
|
return signed;
|
|
}
|
|
|
|
export async function fetchZapInvoice(options = {}) {
|
|
const {
|
|
zapSpec,
|
|
amountSats,
|
|
signedZapRequest,
|
|
logPrefix = '[zaps]'
|
|
} = options;
|
|
|
|
const amountMsats = Math.floor(Number(amountSats) * 1000);
|
|
|
|
const callbackUrl = new URL(zapSpec.callback);
|
|
callbackUrl.searchParams.set('amount', String(amountMsats));
|
|
callbackUrl.searchParams.set('nostr', JSON.stringify(signedZapRequest));
|
|
callbackUrl.searchParams.set('lnurl', zapSpec.lnurl);
|
|
|
|
console.log(`${logPrefix} fetchZapInvoice:start`, {
|
|
callbackHost: callbackUrl.host,
|
|
amountMsats
|
|
});
|
|
|
|
const response = await fetch(callbackUrl.toString(), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Invoice fetch failed (${response.status})`);
|
|
}
|
|
|
|
const body = await response.json();
|
|
if (body?.status === 'ERROR') {
|
|
throw new Error(body?.reason || 'LNURL callback returned an error');
|
|
}
|
|
|
|
const invoice = String(body?.pr || '').trim();
|
|
if (!invoice) {
|
|
throw new Error('LNURL callback did not return an invoice');
|
|
}
|
|
|
|
console.log(`${logPrefix} fetchZapInvoice:ok`, {
|
|
invoicePrefix: invoice.slice(0, 20) + '…'
|
|
});
|
|
|
|
return invoice;
|
|
}
|
|
|
|
function closeActiveZapModal(result, resolver) {
|
|
if (activeZapModalEl?.parentNode) {
|
|
activeZapModalEl.parentNode.removeChild(activeZapModalEl);
|
|
}
|
|
activeZapModalEl = null;
|
|
resolver(result);
|
|
}
|
|
|
|
/**
|
|
* Escape a string for safe insertion into HTML text/attribute content.
|
|
* @param {string} value
|
|
* @returns {string}
|
|
*/
|
|
const HTML_ESCAPE_RE = /[&<>"']/g;
|
|
function escapeHtml(value) {
|
|
const amp = '&' + 'amp;';
|
|
const lt = '&' + 'lt;';
|
|
const gt = '&' + 'gt;';
|
|
const quot = '&' + 'quot;';
|
|
const apos = '&' + '#39;';
|
|
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
|
|
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
|
|
}
|
|
|
|
/**
|
|
* Lightning-only zap details dialog.
|
|
*
|
|
* Shows amount input, preset amounts, Cashu balance, and an insufficient-balance
|
|
* warning. No rail selector — Lightning is the only rail here. Nutzaps have
|
|
* their own dialog (`promptNutzapDetails`) and their own button.
|
|
*
|
|
* @param {Object} options
|
|
* @param {number} [options.defaultAmountSats=21]
|
|
* @param {string} [options.defaultComment='']
|
|
* @param {boolean} [options.defaultShouldZap=true]
|
|
* @param {Function} [options.onSaveDefault]
|
|
* @param {number|null} [options.balanceSats=null]
|
|
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
|
|
*/
|
|
export function promptZapDetails(options = {}) {
|
|
if (activeZapModalEl) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
const {
|
|
defaultAmountSats = 21,
|
|
defaultComment = '',
|
|
defaultShouldZap = true,
|
|
onSaveDefault = null,
|
|
balanceSats = null
|
|
} = options;
|
|
|
|
return new Promise((resolve) => {
|
|
const initialAmount = Number(defaultAmountSats);
|
|
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
|
|
? Math.floor(initialAmount)
|
|
: 21;
|
|
const normalizedDefaultComment = String(defaultComment || '').trim();
|
|
|
|
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
|
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
|
: '';
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'zap-selector-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
|
|
<div class="zap-selector-title">⚡ Lightning Zap</div>
|
|
${balanceDisplay}
|
|
<div class="zap-selector-presets">
|
|
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
|
|
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
|
|
<button type="button" class="zap-preset" data-amount="500">⚡500</button>
|
|
<button type="button" class="zap-preset" data-amount="1000">⚡1K</button>
|
|
<button type="button" class="zap-preset" data-amount="5000">⚡5K</button>
|
|
</div>
|
|
<label class="zap-selector-label">
|
|
Amount (sats)
|
|
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
|
|
</label>
|
|
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
|
|
<label class="zap-selector-label">
|
|
Comment (optional)
|
|
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
|
|
</label>
|
|
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
|
|
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
|
|
Send zap
|
|
</label>
|
|
<div class="zap-selector-actions">
|
|
<button type="button" class="zap-cancel">Cancel</button>
|
|
<button type="button" class="zap-save-default">Save as default</button>
|
|
<button type="button" class="zap-send">Send</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const amountEl = overlay.querySelector('.zap-selector-amount');
|
|
const commentEl = overlay.querySelector('.zap-selector-comment');
|
|
const sendBtn = overlay.querySelector('.zap-send');
|
|
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
|
|
const cancelBtn = overlay.querySelector('.zap-cancel');
|
|
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
|
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
|
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
|
|
|
const parseCurrentValues = () => {
|
|
const amountSats = Number(amountEl?.value || 0);
|
|
const comment = String(commentEl?.value || '').trim();
|
|
const shouldZap = Boolean(enableZapEl?.checked);
|
|
return { amountSats, comment, shouldZap };
|
|
};
|
|
|
|
const updateBalanceWarning = () => {
|
|
if (!warningEl) return;
|
|
const { amountSats } = parseCurrentValues();
|
|
const balance = Number(balanceSats);
|
|
const insufficient = Number.isFinite(balance)
|
|
&& balance >= 0
|
|
&& Number.isFinite(amountSats)
|
|
&& amountSats > balance;
|
|
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
|
|
warningEl.style.display = insufficient ? '' : 'none';
|
|
};
|
|
|
|
const selectPreset = (button) => {
|
|
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
|
if (amountEl && button?.dataset?.amount) {
|
|
amountEl.value = button.dataset.amount;
|
|
}
|
|
updateBalanceWarning();
|
|
};
|
|
|
|
presetButtons.forEach((button) => {
|
|
button.addEventListener('click', () => selectPreset(button));
|
|
});
|
|
|
|
amountEl?.addEventListener('input', () => {
|
|
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
|
updateBalanceWarning();
|
|
});
|
|
|
|
saveDefaultBtn?.addEventListener('click', async () => {
|
|
const { amountSats, comment } = parseCurrentValues();
|
|
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
|
amountEl?.focus();
|
|
return;
|
|
}
|
|
|
|
if (typeof onSaveDefault === 'function') {
|
|
try {
|
|
await onSaveDefault({ amountSats, comment });
|
|
saveDefaultBtn.textContent = 'Saved';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
} catch (error) {
|
|
console.error('[zaps] Save as default failed:', error?.message || error, error);
|
|
saveDefaultBtn.textContent = 'Save failed';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
|
|
saveDefaultBtn.textContent = 'Unavailable';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
});
|
|
|
|
sendBtn?.addEventListener('click', () => {
|
|
const { amountSats, comment, shouldZap } = parseCurrentValues();
|
|
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
|
|
amountEl?.focus();
|
|
return;
|
|
}
|
|
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
|
|
});
|
|
|
|
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
|
|
overlay.addEventListener('click', (event) => {
|
|
if (event.target === overlay) closeActiveZapModal(null, resolve);
|
|
});
|
|
|
|
const onEscape = (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
document.removeEventListener('keydown', onEscape);
|
|
closeActiveZapModal(null, resolve);
|
|
};
|
|
document.addEventListener('keydown', onEscape);
|
|
|
|
activeZapModalEl = overlay;
|
|
document.body.appendChild(overlay);
|
|
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
|
|
if (defaultPreset) {
|
|
selectPreset(defaultPreset);
|
|
} else {
|
|
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
|
}
|
|
updateBalanceWarning();
|
|
amountEl?.focus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nutzap (Cashu ecash) details dialog.
|
|
*
|
|
* Shows amount input, preset amounts, Cashu balance, the recipient's accepted
|
|
* mints (from kind 10019), and which shared mint will be used to send the
|
|
* ecash. Returns the user's choices or null if cancelled.
|
|
*
|
|
* @param {Object} options
|
|
* @param {number} [options.defaultAmountSats=21]
|
|
* @param {string} [options.defaultComment='']
|
|
* @param {boolean} [options.defaultShouldZap=true]
|
|
* @param {Function} [options.onSaveDefault]
|
|
* @param {number|null} [options.balanceSats=null]
|
|
* @param {string[]} [options.recipientMints=[]] - all mints on recipient's kind 10019
|
|
* @param {string[]} [options.sharedMints=[]] - mints shared with sender's wallet
|
|
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
|
|
*/
|
|
export function promptNutzapDetails(options = {}) {
|
|
if (activeZapModalEl) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
const {
|
|
defaultAmountSats = 21,
|
|
defaultComment = '',
|
|
defaultShouldZap = true,
|
|
onSaveDefault = null,
|
|
balanceSats = null,
|
|
recipientMints = [],
|
|
sharedMints = []
|
|
} = options;
|
|
|
|
const normalizedRecipientMints = Array.isArray(recipientMints)
|
|
? recipientMints.map((m) => String(m || '').trim()).filter(Boolean)
|
|
: [];
|
|
const normalizedSharedMints = Array.isArray(sharedMints)
|
|
? sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
|
|
: [];
|
|
const primarySharedMint = normalizedSharedMints[0] || '';
|
|
|
|
return new Promise((resolve) => {
|
|
const initialAmount = Number(defaultAmountSats);
|
|
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
|
|
? Math.floor(initialAmount)
|
|
: 21;
|
|
const normalizedDefaultComment = String(defaultComment || '').trim();
|
|
|
|
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
|
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
|
: '';
|
|
|
|
const sharedMintInfoHtml = primarySharedMint
|
|
? `<div class="zap-selector-mint-info">Will send via ${escapeHtml(primarySharedMint)}</div>`
|
|
: '';
|
|
|
|
const recipientMintsListHtml = normalizedRecipientMints.length > 0
|
|
? `<div class="zap-selector-mint-info">
|
|
<div>Recipient accepts mints:</div>
|
|
<ul class="zap-selector-mint-list">
|
|
${normalizedRecipientMints.map((m) => {
|
|
const isShared = normalizedSharedMints.some(
|
|
(s) => s.replace(/\/+$/, '').toLowerCase() === m.replace(/\/+$/, '').toLowerCase()
|
|
);
|
|
return `<li class="zap-selector-mint-list-item${isShared ? ' shared' : ''}">${escapeHtml(m)}${isShared ? ' ✓' : ''}</li>`;
|
|
}).join('')}
|
|
</ul>
|
|
</div>`
|
|
: '';
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'zap-selector-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send nutzap">
|
|
<div class="zap-selector-title">🥜 Nutzap</div>
|
|
${balanceDisplay}
|
|
<div class="zap-selector-presets">
|
|
<button type="button" class="zap-preset" data-amount="21">🥜21</button>
|
|
<button type="button" class="zap-preset" data-amount="100">🥜100</button>
|
|
<button type="button" class="zap-preset" data-amount="500">🥜500</button>
|
|
<button type="button" class="zap-preset" data-amount="1000">🥜1K</button>
|
|
<button type="button" class="zap-preset" data-amount="5000">🥜5K</button>
|
|
</div>
|
|
<label class="zap-selector-label">
|
|
Amount (sats)
|
|
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
|
|
</label>
|
|
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
|
|
${sharedMintInfoHtml}
|
|
${recipientMintsListHtml}
|
|
<label class="zap-selector-label">
|
|
Comment (optional)
|
|
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post 🥜">${escapeHtml(normalizedDefaultComment)}</textarea>
|
|
</label>
|
|
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
|
|
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
|
|
Send nutzap
|
|
</label>
|
|
<div class="zap-selector-actions">
|
|
<button type="button" class="zap-cancel">Cancel</button>
|
|
<button type="button" class="zap-save-default">Save as default</button>
|
|
<button type="button" class="zap-send">Send</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const amountEl = overlay.querySelector('.zap-selector-amount');
|
|
const commentEl = overlay.querySelector('.zap-selector-comment');
|
|
const sendBtn = overlay.querySelector('.zap-send');
|
|
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
|
|
const cancelBtn = overlay.querySelector('.zap-cancel');
|
|
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
|
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
|
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
|
|
|
const parseCurrentValues = () => {
|
|
const amountSats = Number(amountEl?.value || 0);
|
|
const comment = String(commentEl?.value || '').trim();
|
|
const shouldZap = Boolean(enableZapEl?.checked);
|
|
return { amountSats, comment, shouldZap };
|
|
};
|
|
|
|
const updateBalanceWarning = () => {
|
|
if (!warningEl) return;
|
|
const { amountSats } = parseCurrentValues();
|
|
const balance = Number(balanceSats);
|
|
const insufficient = Number.isFinite(balance)
|
|
&& balance >= 0
|
|
&& Number.isFinite(amountSats)
|
|
&& amountSats > balance;
|
|
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
|
|
warningEl.style.display = insufficient ? '' : 'none';
|
|
};
|
|
|
|
const selectPreset = (button) => {
|
|
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
|
if (amountEl && button?.dataset?.amount) {
|
|
amountEl.value = button.dataset.amount;
|
|
}
|
|
updateBalanceWarning();
|
|
};
|
|
|
|
presetButtons.forEach((button) => {
|
|
button.addEventListener('click', () => selectPreset(button));
|
|
});
|
|
|
|
amountEl?.addEventListener('input', () => {
|
|
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
|
updateBalanceWarning();
|
|
});
|
|
|
|
saveDefaultBtn?.addEventListener('click', async () => {
|
|
const { amountSats, comment } = parseCurrentValues();
|
|
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
|
amountEl?.focus();
|
|
return;
|
|
}
|
|
|
|
if (typeof onSaveDefault === 'function') {
|
|
try {
|
|
await onSaveDefault({ amountSats, comment });
|
|
saveDefaultBtn.textContent = 'Saved';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
} catch (error) {
|
|
console.error('[zaps] Save as default failed:', error?.message || error, error);
|
|
saveDefaultBtn.textContent = 'Save failed';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
|
|
saveDefaultBtn.textContent = 'Unavailable';
|
|
setTimeout(() => {
|
|
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
|
}, 1200);
|
|
});
|
|
|
|
sendBtn?.addEventListener('click', () => {
|
|
const { amountSats, comment, shouldZap } = parseCurrentValues();
|
|
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
|
|
amountEl?.focus();
|
|
return;
|
|
}
|
|
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
|
|
});
|
|
|
|
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
|
|
overlay.addEventListener('click', (event) => {
|
|
if (event.target === overlay) closeActiveZapModal(null, resolve);
|
|
});
|
|
|
|
const onEscape = (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
document.removeEventListener('keydown', onEscape);
|
|
closeActiveZapModal(null, resolve);
|
|
};
|
|
document.addEventListener('keydown', onEscape);
|
|
|
|
activeZapModalEl = overlay;
|
|
document.body.appendChild(overlay);
|
|
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
|
|
if (defaultPreset) {
|
|
selectPreset(defaultPreset);
|
|
} else {
|
|
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
|
}
|
|
updateBalanceWarning();
|
|
amountEl?.focus();
|
|
});
|
|
}
|
|
|
|
export async function prepareZapInvoiceForEvent(options = {}) {
|
|
const {
|
|
eventId = '',
|
|
recipientPubkey,
|
|
amountSats,
|
|
comment = '',
|
|
fetchProfile,
|
|
getRelayData,
|
|
logPrefix = '[zaps]',
|
|
onProgress = null
|
|
} = options;
|
|
|
|
const emitProgress = (step, data = {}, level = 'info') => {
|
|
if (typeof onProgress !== 'function') return;
|
|
try {
|
|
onProgress({ step, level, ...data });
|
|
} catch (_error) {}
|
|
};
|
|
|
|
emitProgress('resolve-zap-spec:start', {
|
|
recipientPubkey: String(recipientPubkey || '').slice(0, 12)
|
|
});
|
|
|
|
const zapSpec = await resolveZapSpecForPubkey(recipientPubkey, { fetchProfile, logPrefix });
|
|
const amountMsats = Math.floor(Number(amountSats) * 1000);
|
|
|
|
emitProgress('resolve-zap-spec:ok', {
|
|
callback: zapSpec?.callback || '',
|
|
minSendable: zapSpec?.minSendable || 0,
|
|
maxSendable: zapSpec?.maxSendable || 0,
|
|
amountMsats
|
|
});
|
|
|
|
if (amountMsats < zapSpec.minSendable) {
|
|
emitProgress('amount-check:below-min', {
|
|
amountMsats,
|
|
minSendable: zapSpec.minSendable
|
|
}, 'error');
|
|
throw new Error(`Amount is below minimum (${Math.ceil(zapSpec.minSendable / 1000)} sats)`);
|
|
}
|
|
if (amountMsats > zapSpec.maxSendable) {
|
|
emitProgress('amount-check:above-max', {
|
|
amountMsats,
|
|
maxSendable: zapSpec.maxSendable
|
|
}, 'error');
|
|
throw new Error(`Amount exceeds maximum (${Math.floor(zapSpec.maxSendable / 1000)} sats)`);
|
|
}
|
|
|
|
emitProgress('create-zap-request:start', { amountSats, hasComment: Boolean(String(comment || '').trim()) });
|
|
|
|
const signedZapRequest = await createSignedZapRequest({
|
|
eventId,
|
|
recipientPubkey,
|
|
amountSats,
|
|
comment,
|
|
zapSpec,
|
|
getRelayData,
|
|
logPrefix
|
|
});
|
|
|
|
emitProgress('create-zap-request:ok', {
|
|
eventId: String(eventId || '').slice(0, 12),
|
|
requestId: String(signedZapRequest?.id || '').slice(0, 16)
|
|
});
|
|
|
|
emitProgress('fetch-invoice:start', {
|
|
callback: zapSpec?.callback || '',
|
|
amountMsats
|
|
});
|
|
|
|
const invoice = await fetchZapInvoice({
|
|
zapSpec,
|
|
amountSats,
|
|
signedZapRequest,
|
|
logPrefix
|
|
});
|
|
|
|
emitProgress('fetch-invoice:ok', {
|
|
invoicePrefix: String(invoice || '').slice(0, 24)
|
|
});
|
|
|
|
return {
|
|
invoice,
|
|
zapSpec,
|
|
signedZapRequest
|
|
};
|
|
}
|
|
|
|
export function extractZapAmount(event) {
|
|
const bolt11 = event?.tags?.find((t) => t[0] === 'bolt11')?.[1];
|
|
if (!bolt11) return 0;
|
|
|
|
try {
|
|
const sats = window.NostrTools?.nip57?.getSatoshisAmountFromBolt11?.(bolt11);
|
|
return Number.isFinite(sats) ? sats : 0;
|
|
} catch (_error) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export function extractZapComment(event) {
|
|
const description = event?.tags?.find((t) => t[0] === 'description')?.[1];
|
|
if (description) {
|
|
try {
|
|
const descObj = JSON.parse(description);
|
|
return descObj.content || '';
|
|
} catch (_e) {
|
|
return '';
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// =============================================================================
|
|
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
|
|
// =============================================================================
|
|
//
|
|
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
|
|
// available for a given recipient pubkey:
|
|
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
|
|
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
|
|
// one of those mints overlaps with the sender's wallet mints
|
|
// - sharedMints: the overlapping mint URLs (empty when no overlap)
|
|
// - nutzapMints: all mints declared on the recipient's kind 10019
|
|
// - lud16: the recipient's Lightning address (or null)
|
|
//
|
|
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
|
|
// repeated feed renders do not refetch. The cache is best-effort and safe to
|
|
// clear on page navigation (see clearZapCapabilitiesCache).
|
|
|
|
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
|
|
|
|
/**
|
|
* Clear the in-memory zap-capability cache.
|
|
* Safe to call on page navigation; subsequent calls will refetch.
|
|
*/
|
|
export function clearZapCapabilitiesCache() {
|
|
zapCapCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Normalize a mint URL for comparison (trim + strip trailing slashes).
|
|
* @param {string} url
|
|
* @returns {string}
|
|
*/
|
|
function normalizeMintForCompare(url) {
|
|
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Resolve which zap rails are available for a given pubkey.
|
|
*
|
|
* @param {string} pubkey - The recipient's pubkey (hex)
|
|
* @param {Object} options
|
|
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
|
|
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
|
|
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
|
|
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
|
|
* @param {string} [options.logPrefix='[zaps]']
|
|
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
|
|
*/
|
|
export async function resolveZapCapabilities(pubkey, options = {}) {
|
|
const {
|
|
fetchProfile: fetchProfileFn,
|
|
fetchMintList,
|
|
getSenderMints,
|
|
useCache = true,
|
|
logPrefix = '[zaps]'
|
|
} = options;
|
|
|
|
const recipient = String(pubkey || '').trim();
|
|
if (!recipient) {
|
|
return {
|
|
canLightning: false,
|
|
canNutzap: false,
|
|
sharedMints: [],
|
|
nutzapMints: [],
|
|
lud16: null,
|
|
hasMintList: false
|
|
};
|
|
}
|
|
|
|
// --- Cache lookup ---------------------------------------------------------
|
|
const now = Date.now();
|
|
if (useCache) {
|
|
const cached = zapCapCache.get(recipient);
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.result;
|
|
}
|
|
}
|
|
|
|
// --- Lightning capability (lud16 from kind 0) -----------------------------
|
|
let lud16 = null;
|
|
let canLightning = false;
|
|
if (typeof fetchProfileFn === 'function') {
|
|
try {
|
|
const profile = await fetchProfileFn(recipient);
|
|
const normalized = normalizeLud16(profile?.lud16);
|
|
if (normalized) {
|
|
lud16 = normalized;
|
|
canLightning = true;
|
|
}
|
|
} catch (error) {
|
|
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
|
|
}
|
|
}
|
|
|
|
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
|
|
let nutzapMints = [];
|
|
let sharedMints = [];
|
|
let canNutzap = false;
|
|
let hasMintList = false;
|
|
|
|
if (typeof fetchMintList === 'function') {
|
|
try {
|
|
const mintList = await fetchMintList(recipient);
|
|
const rawMints = Array.isArray(mintList?.mints)
|
|
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
|
: [];
|
|
nutzapMints = Array.from(new Set(rawMints));
|
|
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
|
|
|
|
if (hasMintList) {
|
|
// Determine the sender's wallet mints to compute overlap.
|
|
let senderMints = [];
|
|
if (typeof getSenderMints === 'function') {
|
|
try {
|
|
const senderResult = await getSenderMints();
|
|
senderMints = Array.isArray(senderResult?.mints)
|
|
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
|
: [];
|
|
} catch (error) {
|
|
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
|
|
}
|
|
}
|
|
|
|
if (senderMints.length > 0) {
|
|
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
|
|
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
|
|
canNutzap = sharedMints.length > 0;
|
|
} else {
|
|
// No sender wallet mints available yet — cannot confirm a shared mint,
|
|
// so we do not claim canNutzap. The recipient still has a mint list,
|
|
// which the UI may render in a muted state.
|
|
canNutzap = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
canLightning,
|
|
canNutzap,
|
|
sharedMints,
|
|
nutzapMints,
|
|
lud16,
|
|
hasMintList
|
|
};
|
|
|
|
// --- Cache store ----------------------------------------------------------
|
|
zapCapCache.set(recipient, {
|
|
result,
|
|
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
|
|
});
|
|
|
|
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
|
|
recipient: recipient.slice(0, 8) + '…',
|
|
canLightning,
|
|
canNutzap,
|
|
hasMintList,
|
|
sharedMints: sharedMints.length,
|
|
nutzapMints: nutzapMints.length
|
|
});
|
|
|
|
return result;
|
|
}
|