509 lines
15 KiB
JavaScript
509 lines
15 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);
|
|
}
|
|
|
|
export function promptZapDetails(options = {}) {
|
|
if (activeZapModalEl) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
const {
|
|
defaultAmountSats = 21,
|
|
defaultComment = '',
|
|
defaultShouldZap = true,
|
|
onSaveDefault = 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 overlay = document.createElement('div');
|
|
overlay.className = 'zap-selector-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
|
|
<div class="zap-selector-title">Send Zap</div>
|
|
<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>
|
|
<label class="zap-selector-label">
|
|
Comment (optional)
|
|
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</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 parseCurrentValues = () => {
|
|
const amountSats = Number(amountEl?.value || 0);
|
|
const comment = String(commentEl?.value || '').trim();
|
|
const shouldZap = Boolean(enableZapEl?.checked);
|
|
return { amountSats, comment, shouldZap };
|
|
};
|
|
|
|
const selectPreset = (button) => {
|
|
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
|
if (amountEl && button?.dataset?.amount) {
|
|
amountEl.value = button.dataset.amount;
|
|
}
|
|
};
|
|
|
|
presetButtons.forEach((button) => {
|
|
button.addEventListener('click', () => selectPreset(button));
|
|
});
|
|
|
|
amountEl?.addEventListener('input', () => {
|
|
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
|
});
|
|
|
|
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'));
|
|
}
|
|
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 '';
|
|
}
|