Files
client/build/.cashu-inline-check.mjs
2026-04-17 16:52:51 -04:00

1980 lines
73 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay, getRelayData, ndkFetchEvents, queryCache, getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[cashu.html ${VERSION}] Loading...`);
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let walletController = null;
let unsubscribeTx = null;
let currentRelayUrls = [];
let latestWorkerWalletBalance = null;
let mintDiscoveryPromise = null;
let unsubscribeUserSettings = null;
let zapSettingsLoaded = false;
let zapSettings = {
defaultAmountSats: 21,
defaultComment: '',
preferredMethod: 'auto',
receiveMintAllowlist: []
};
let latestMintListStatus = {
mints: [],
relays: [],
p2pk: null,
eventId: null,
publishedRelayCount: 0,
updatedAtSec: 0
};
let walletStartupPromise = null;
let mintDiscoveryCache = {
contactMintsByScope: { all: [], wot: [] },
recommendationMintsByScope: { all: [], wot: [] },
followedPubkeys: [],
rawContactEvents: [],
rawMintListEvents: [],
rawRecommendationEvents: [],
error: null,
scope: 'all',
hydratedFromCache: false,
refreshedFromRelays: false
};
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divSideNavBody = document.getElementById('divSideNavBody');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divWalletStatus = document.getElementById('divWalletStatus');
const divSetupPanel = document.getElementById('divSetupPanel');
const divSetupStatus = document.getElementById('divSetupStatus');
const taSetupMints = document.getElementById('taSetupMints');
const btnCreateWallet = document.getElementById('btnCreateWallet');
const divWalletContent = document.getElementById('divWalletContent');
const divBalanceAmount = document.getElementById('divBalanceAmount');
const divMintBalances = document.getElementById('divMintBalances');
const btnTabReceive = document.getElementById('btnTabReceive');
const btnTabSend = document.getElementById('btnTabSend');
const btnTabDeposit = document.getElementById('btnTabDeposit');
const btnTabWithdraw = document.getElementById('btnTabWithdraw');
const panelReceive = document.getElementById('panelReceive');
const panelSend = document.getElementById('panelSend');
const panelDeposit = document.getElementById('panelDeposit');
const panelWithdraw = document.getElementById('panelWithdraw');
const taReceiveToken = document.getElementById('taReceiveToken');
const btnReceiveToken = document.getElementById('btnReceiveToken');
const inpSendAmount = document.getElementById('inpSendAmount');
const inpSendMemo = document.getElementById('inpSendMemo');
const selSendMint = document.getElementById('selSendMint');
const btnSendToken = document.getElementById('btnSendToken');
const divSendTokenOutput = document.getElementById('divSendTokenOutput');
const divSendTokenPreview = document.getElementById('divSendTokenPreview');
const divSendTokenQr = document.getElementById('divSendTokenQr');
const btnCloseSendTokenPreview = document.getElementById('btnCloseSendTokenPreview');
const btnCopySendToken = document.getElementById('btnCopySendToken');
const btnClearSendToken = document.getElementById('btnClearSendToken');
const inpDepositAmount = document.getElementById('inpDepositAmount');
const selDepositMint = document.getElementById('selDepositMint');
const btnCreateInvoice = document.getElementById('btnCreateInvoice');
const divDepositInvoice = document.getElementById('divDepositInvoice');
const divDepositInvoiceQr = document.getElementById('divDepositInvoiceQr');
const btnCopyInvoice = document.getElementById('btnCopyInvoice');
const selWithdrawMint = document.getElementById('selWithdrawMint');
const taWithdrawInvoice = document.getElementById('taWithdrawInvoice');
const btnPayInvoice = document.getElementById('btnPayInvoice');
const divTxList = document.getElementById('divTxList');
const inpAddMint = document.getElementById('inpAddMint');
const btnAddMint = document.getElementById('btnAddMint');
const btnCheckProofs = document.getElementById('btnCheckProofs');
const divCheckProofsResult = document.getElementById('divCheckProofsResult');
const btnRefreshWallet = document.getElementById('btnRefreshWallet');
const divContactMintsTitle = document.getElementById('divContactMintsTitle');
const divContactMintsList = document.getElementById('divContactMintsList');
const btnSortByCount = document.getElementById('btnSortByCount');
const btnSortByRating = document.getElementById('btnSortByRating');
const btnToggleWot = document.getElementById('btnToggleWot');
const inpZapDefaultAmount = document.getElementById('inpZapDefaultAmount');
const taZapDefaultComment = document.getElementById('taZapDefaultComment');
const selZapPreferredMethod = document.getElementById('selZapPreferredMethod');
const btnSaveZapsSettings = document.getElementById('btnSaveZapsSettings');
const btnResetZapsSettings = document.getElementById('btnResetZapsSettings');
const btnRepublishMintList = document.getElementById('btnRepublishMintList');
const divNutzapMintCount = document.getElementById('divNutzapMintCount');
const divNutzapRelayCount = document.getElementById('divNutzapRelayCount');
const divNutzapReceiveMintCount = document.getElementById('divNutzapReceiveMintCount');
const divNutzapP2pk = document.getElementById('divNutzapP2pk');
const divNutzapLastPublish = document.getElementById('divNutzapLastPublish');
const taNutzapReceiveMints = document.getElementById('taNutzapReceiveMints');
const btnUseWalletMintsForNutzap = document.getElementById('btnUseWalletMintsForNutzap');
const divZapsStatus = document.getElementById('divZapsStatus');
const MINT_DISCOVERY_WINDOW_SEC = 365 * 24 * 60 * 60;
let mintDiscoveryScope = 'all';
let mintDiscoverySortMode = 'count';
const mintDiscoveryLastNonEmptyByView = new Map();
let walletStatusReady = false;
let hasFullWalletBalanceReady = false;
let txTimeAgoIntervalId = null;
let workerWalletUiActivated = false;
const redeemedSendTxIds = new Set();
let hasSeededInitialSendTxState = false;
function setStatus(message, isError = false) {
divWalletStatus.textContent = message;
divWalletStatus.classList.toggle('error', Boolean(isError));
if (divSetupStatus) {
divSetupStatus.textContent = message;
divSetupStatus.classList.toggle('error', Boolean(isError));
}
}
function formatSats(amount, { approximate = false } = {}) {
const value = Number(amount || 0);
const prefix = approximate ? '~' : '';
return `${prefix}${value.toLocaleString()} sats`;
}
function renderImmediateWorkerBalance(payload = {}) {
const total = Number(payload?.balance || 0);
const byMint = (payload?.balancesByMint && typeof payload?.balancesByMint === 'object')
? payload.balancesByMint
: {};
const isApproximate = payload?.isApproximate === true || payload?.balanceSource === 'lightweight';
divBalanceAmount.textContent = formatSats(total, { approximate: isApproximate });
const mintRows = Object.entries(byMint || {});
if (mintRows.length === 0) {
divMintBalances.innerHTML = total > 0 ? '' : 'No mint balances yet';
} else {
divMintBalances.innerHTML = mintRows
.map(([mint, amount]) => `
<div class="cashuMintBalanceRow">
<div class="cashuMintUrl">${mint}</div>
<div>${formatSats(amount)}</div>
</div>
`)
.join('');
}
const payloadMints = Object.keys(byMint || {})
.map((mint) => normalizeMintUrl(mint))
.filter(Boolean);
if (payloadMints.length > 0) {
renderMintSelectors(payloadMints);
if (selWithdrawMint) {
selWithdrawMint.innerHTML = payloadMints
.map((mint) => `<option value="${mint}">${mint}</option>`)
.join('');
}
}
}
function formatTimeAgo(timestamp, nowSec = Math.floor(Date.now() / 1000)) {
const ts = Number(timestamp || 0);
const diff = Math.max(0, nowSec - ts);
if (diff < 60) {
return {
text: `${diff}s ago`,
nextUpdateAt: nowSec + 1
};
}
if (diff < 3600) {
const remainder = diff % 60;
return {
text: `${Math.floor(diff / 60)}m ago`,
nextUpdateAt: nowSec + (remainder === 0 ? 60 : 60 - remainder)
};
}
if (diff < 86400) {
const remainder = diff % 3600;
return {
text: `${Math.floor(diff / 3600)}h ago`,
nextUpdateAt: nowSec + (remainder === 0 ? 3600 : 3600 - remainder)
};
}
const remainder = diff % 86400;
return {
text: `${Math.floor(diff / 86400)}d ago`,
nextUpdateAt: nowSec + (remainder === 0 ? 86400 : 86400 - remainder)
};
}
function shortenMintName(mintUrl) {
const normalized = normalizeMintUrl(mintUrl) || String(mintUrl || '');
if (!normalized) return '';
try {
const host = new URL(normalized).hostname || normalized;
return host.replace(/^www\./i, '');
} catch (_error) {
return normalized
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '');
}
}
function clearTransactionOutputs({ send = true, deposit = true, withdraw = true } = {}) {
if (send) {
divSendTokenOutput.textContent = '';
divSendTokenOutput.classList.add('clsHidden');
renderQrCode(divSendTokenQr, '');
if (divSendTokenPreview) {
divSendTokenPreview.classList.add('clsHidden');
}
btnCopySendToken.classList.add('clsHidden');
if (btnClearSendToken) {
btnClearSendToken.classList.add('clsHidden');
}
}
if (deposit) {
divDepositInvoice.textContent = '';
divDepositInvoice.classList.add('clsHidden');
renderQrCode(divDepositInvoiceQr, '');
btnCopyInvoice.classList.add('clsHidden');
}
if (withdraw && taWithdrawInvoice) {
taWithdrawInvoice.value = '';
}
}
function confirmDiscardTransactionOutputs({ send = false, deposit = false, withdraw = false } = {}) {
const parts = [];
if (send) parts.push('generated token QR/code');
if (deposit) parts.push('deposit invoice QR/code');
if (withdraw) parts.push('withdraw invoice input');
if (parts.length === 0) return true;
return window.confirm(`Transaction complete. Discard ${parts.join(' and ')}?`);
}
function startTimestampUpdater() {
if (txTimeAgoIntervalId) return;
txTimeAgoIntervalId = setInterval(() => {
const nowSec = Math.floor(Date.now() / 1000);
const nodes = divTxList.querySelectorAll('.cashuTxTimeAgo[data-timestamp]');
for (const node of nodes) {
const timestamp = Number(node.dataset.timestamp || 0);
const nextUpdateAt = Number(node.dataset.nextUpdateAt || 0);
if (nextUpdateAt > nowSec) continue;
const formatted = formatTimeAgo(timestamp, nowSec);
node.textContent = formatted.text;
node.dataset.nextUpdateAt = String(formatted.nextUpdateAt);
}
}, 1000);
}
async function copyToClipboard(value, successMessage) {
try {
await navigator.clipboard.writeText(String(value || ''));
setStatus(successMessage);
} catch (error) {
console.error('[cashu.html] Clipboard error:', error);
setStatus('Failed to copy to clipboard', true);
}
}
function renderQrCode(container, text) {
if (!container) return;
const raw = String(text || '').trim();
if (!raw) {
container.innerHTML = '';
container.classList.add('clsHidden');
return;
}
const isLightningInvoice = /^ln[a-z0-9]+/i.test(raw);
const value = isLightningInvoice ? raw.toUpperCase() : raw;
const margin = isLightningInvoice ? 2 : 1;
if (typeof qrcode !== 'function') {
console.warn('[cashu.html] qrcode-generator not available');
container.innerHTML = '';
container.classList.add('clsHidden');
return;
}
try {
container.innerHTML = '';
// Use byte mode for cashuB tokens so long payloads are encoded correctly.
const qr = qrcode(0, 'L');
qr.addData(value, 'Byte');
qr.make();
const svgMarkup = qr.createSvgTag(4, margin);
container.innerHTML = svgMarkup;
const svg = container.querySelector('svg');
if (svg) {
svg.removeAttribute('width');
svg.removeAttribute('height');
}
container.classList.remove('clsHidden');
} catch (error) {
console.error('[cashu.html] QR render failed:', error);
container.innerHTML = '';
container.classList.add('clsHidden');
}
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
renderMintDiscoverySidebar();
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) closeNav();
else openNav();
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[cashu.html] Error updating footer:', error);
}
};
const Logout = async () => {
console.log('[cashu.html] Starting logout process...');
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
try {
await walletController?.shutdown?.();
} catch (_error) {
// no-op
}
disconnect();
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) window.indexedDB.deleteDatabase(db.name);
}
}
location.reload(true);
};
function showSetup() {
divSetupPanel.classList.remove('clsHidden');
divWalletContent.classList.add('clsHidden');
divWalletStatus.classList.add('clsHidden');
walletStatusReady = false;
hasFullWalletBalanceReady = false;
updateCheckProofsButtonState();
if (!taSetupMints.value.trim()) {
taSetupMints.value = walletController?.DEFAULT_MINTS?.join('\n') || '';
}
}
function updateCheckProofsButtonState() {
const isReady = walletStatusReady;
if (btnCheckProofs) {
btnCheckProofs.disabled = !isReady;
btnCheckProofs.title = isReady
? 'Validate local proofs against each mint and remove spent proofs'
: 'Waiting for wallet startup to finish before proof checking';
}
}
function showWallet() {
divSetupPanel.classList.add('clsHidden');
divWalletContent.classList.remove('clsHidden');
divWalletStatus.classList.remove('clsHidden');
setStatus('Wallet ready');
updateCheckProofsButtonState();
}
function setCheckProofsStatus(message, isError = false) {
if (!divCheckProofsResult) return;
divCheckProofsResult.textContent = String(message || '');
divCheckProofsResult.classList.toggle('error', Boolean(isError));
}
function setZapsStatus(message, isError = false) {
if (!divZapsStatus) return;
divZapsStatus.textContent = String(message || '');
divZapsStatus.classList.toggle('error', Boolean(isError));
}
function normalizePreferredZapMethod(value) {
const method = String(value || '').trim().toLowerCase();
if (method === 'nutzap' || method === 'lightning') return method;
return 'auto';
}
function normalizeZapSettings(raw = {}) {
const amount = Number(raw?.zaps?.defaultAmountSats);
const defaultAmountSats = Number.isFinite(amount) && amount > 0 ? Math.floor(amount) : 21;
const defaultComment = String(raw?.zaps?.defaultComment || '').trim();
const preferredMethod = normalizePreferredZapMethod(raw?.zaps?.preferredMethod || 'auto');
const receiveMintAllowlist = Array.from(new Set((Array.isArray(raw?.zaps?.receiveMintAllowlist) ? raw.zaps.receiveMintAllowlist : [])
.map((value) => normalizeMintUrl(value))
.filter(Boolean)));
return { defaultAmountSats, defaultComment, preferredMethod, receiveMintAllowlist };
}
function applyZapSettingsForm() {
if (inpZapDefaultAmount) inpZapDefaultAmount.value = String(zapSettings.defaultAmountSats || 21);
if (taZapDefaultComment) taZapDefaultComment.value = String(zapSettings.defaultComment || '');
if (selZapPreferredMethod) selZapPreferredMethod.value = normalizePreferredZapMethod(zapSettings.preferredMethod);
if (taNutzapReceiveMints) taNutzapReceiveMints.value = (zapSettings.receiveMintAllowlist || []).join('\n');
}
function formatNutzapPublishText() {
const eventId = String(latestMintListStatus?.eventId || '').trim();
const relayCount = Number(latestMintListStatus?.publishedRelayCount || 0);
if (!eventId) return 'Not published in this session';
return `${eventId.slice(0, 12)}… (${relayCount} relays)`;
}
function renderNutzapStatusFromLocalState() {
const walletMints = walletController?.hasWallet?.()
? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean)
: [];
const relayList = Array.isArray(currentRelayUrls) ? currentRelayUrls.filter((url) => /^wss?:\/\//i.test(String(url || '').trim())) : [];
const effectiveMints = Array.isArray(latestMintListStatus?.mints) && latestMintListStatus.mints.length > 0
? latestMintListStatus.mints
: walletMints;
const effectiveRelays = Array.isArray(latestMintListStatus?.relays) && latestMintListStatus.relays.length > 0
? latestMintListStatus.relays
: relayList;
if (divNutzapMintCount) {
divNutzapMintCount.textContent = `${effectiveMints.length} configured`;
}
const allowlist = Array.from(new Set((Array.isArray(zapSettings?.receiveMintAllowlist) ? zapSettings.receiveMintAllowlist : [])
.map((value) => normalizeMintUrl(value))
.filter(Boolean)));
if (divNutzapReceiveMintCount) {
divNutzapReceiveMintCount.textContent = allowlist.length > 0
? `${allowlist.length} selected`
: 'All wallet mints';
}
if (divNutzapRelayCount) {
divNutzapRelayCount.textContent = `${effectiveRelays.length} connected/known`;
}
if (divNutzapP2pk) {
const p2pk = String(latestMintListStatus?.p2pk || '').trim();
divNutzapP2pk.textContent = p2pk || '(not published yet)';
}
if (divNutzapLastPublish) {
divNutzapLastPublish.textContent = formatNutzapPublishText();
}
const canRepublish = Boolean(walletController?.hasWallet?.());
if (btnRepublishMintList) {
btnRepublishMintList.disabled = !canRepublish;
btnRepublishMintList.title = canRepublish
? 'Publish kind 10019 mint list with current mints/relays'
: 'Create a wallet first';
}
}
async function hydrateZapSettingsFromStore({ silent = true } = {}) {
try {
const settings = await getUserSettings();
zapSettings = normalizeZapSettings(settings || {});
zapSettingsLoaded = true;
applyZapSettingsForm();
if (!silent) {
setZapsStatus('Loaded zap settings');
}
} catch (error) {
console.error('[cashu.html] Failed to load zap settings:', error);
if (!silent) {
setZapsStatus(error?.message || 'Failed to load zap settings', true);
}
}
}
async function refreshZapsSection() {
if (!zapSettingsLoaded) {
await hydrateZapSettingsFromStore({ silent: true });
} else {
applyZapSettingsForm();
}
renderNutzapStatusFromLocalState();
}
function parseMintLines(text) {
return String(text || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function normalizeMintUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
let parsed;
try {
parsed = new URL(raw);
} catch (_error) {
return '';
}
if (parsed.protocol !== 'https:') return '';
if (!parsed.hostname) return '';
return parsed.toString().replace(/\/+$/, '');
}
function parseNutzapReceiveAllowlistInput(inputText) {
const rawLines = String(inputText || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const invalid = [];
const normalized = [];
const seen = new Set();
for (const line of rawLines) {
const mintUrl = normalizeMintUrl(line);
if (!mintUrl) {
invalid.push(line);
continue;
}
if (seen.has(mintUrl)) continue;
seen.add(mintUrl);
normalized.push(mintUrl);
}
return { normalized, invalid };
}
function flattenEventsByRelay(eventsByRelay) {
const out = [];
for (const events of Object.values(eventsByRelay || {})) {
if (!Array.isArray(events)) continue;
out.push(...events);
}
return out;
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value || '');
return div.innerHTML;
}
function renderMintDiscoveryRows(container, mints, emptyMessage) {
if (!container) return;
if (!Array.isArray(mints) || mints.length === 0) {
container.textContent = emptyMessage;
return;
}
container.innerHTML = mints
.map((entry) => {
const url = String(entry?.url || '');
const safeUrl = escapeHtml(url);
const mentions = Number(entry?.count || 0);
const votes = Number(entry?.starVotes || 0);
const reviewCount = Number(entry?.recCount || 0);
const rating = votes > 0
? `${Number(entry?.avgStars || 0).toFixed(1)}/${votes}`
: (reviewCount > 0 ? `0.0/${reviewCount}` : '—');
const isWalletMint = Boolean(entry?.isWalletMint);
return `
<div class="mintDiscoveryRow ${isWalletMint ? 'walletMint' : ''}">
<div class="mintDiscoveryUrl">${safeUrl}</div>
<div class="mintDiscoveryCount" title="Mentions">${mentions}</div>
<div class="mintDiscoveryRating" title="Average stars / reviews">${escapeHtml(rating)}</div>
${isWalletMint
? `<button class="mintDiscoveryRemove" data-mint-url="${safeUrl}" title="Remove mint"></button>`
: `<button class="mintDiscoveryAdd" data-mint-url="${safeUrl}" title="Add mint">⊕</button>`
}
</div>
`;
})
.join('');
container.querySelectorAll('.mintDiscoveryAdd').forEach((button) => {
button.addEventListener('click', async () => {
const mintUrl = button.getAttribute('data-mint-url');
await handleAddDiscoveredMint(mintUrl);
});
});
container.querySelectorAll('.mintDiscoveryRemove').forEach((button) => {
button.addEventListener('click', async () => {
const mintUrl = button.getAttribute('data-mint-url');
await handleRemoveDiscoveredMint(mintUrl);
});
});
}
function getDiscoverySinceSec() {
return Math.floor(Date.now() / 1000) - MINT_DISCOVERY_WINDOW_SEC;
}
function dedupeEvents(events) {
const byId = new Map();
for (const evt of events || []) {
if (!evt || !evt.id) continue;
const prev = byId.get(evt.id);
if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) {
byId.set(evt.id, evt);
}
}
return Array.from(byId.values());
}
function extractFollowedPubkeysFromContactEvents(contactEvents) {
const latest = (contactEvents || [])
.filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey)
.sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))[0];
if (!latest) return [];
return Array.from(
new Set(
(latest.tags || [])
.filter((tag) => tag[0] === 'p' && tag[1])
.map((tag) => tag[1])
)
);
}
function extractRecommendationStars(evt) {
const parseStars = (value) => {
const text = String(value || '').trim();
if (!text) return null;
const starEmojiCount = (text.match(/[⭐★]/g) || []).length;
if (starEmojiCount > 0 && starEmojiCount <= 5) return starEmojiCount;
const direct = Number(text);
if (Number.isFinite(direct) && direct >= 0 && direct <= 5) return direct;
const slashMatch = text.match(/([0-5](?:\.\d+)?)\s*\/\s*5/i);
if (slashMatch) {
const parsed = Number(slashMatch[1]);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed;
}
const labelledMatch = text.match(/([0-5](?:\.\d+)?)\s*(?:stars?|star|⭐|★)/i);
if (labelledMatch) {
const parsed = Number(labelledMatch[1]);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed;
}
return null;
};
const tags = Array.isArray(evt?.tags) ? evt.tags : [];
for (const tag of tags) {
const key = String(tag?.[0] || '').toLowerCase();
if (!['rating', 'stars', 'star', 'score', 'review', 'rank', 'r'].includes(key)) continue;
const candidates = [tag?.[1], tag?.[2], tag?.[3]].filter((v) => v !== undefined);
for (const candidate of candidates) {
const parsed = parseStars(candidate);
if (parsed !== null) return parsed;
}
}
const content = String(evt?.content || '').trim();
return parseStars(content);
}
function aggregateMintLists(rawMintListEvents, followedPubkeys = null) {
const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null;
const latestByAuthor = new Map();
for (const evt of rawMintListEvents || []) {
if (!evt || evt.kind !== 10019) continue;
if (followedSet && !followedSet.has(evt.pubkey)) continue;
const prev = latestByAuthor.get(evt.pubkey);
if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) {
latestByAuthor.set(evt.pubkey, evt);
}
}
const mintCounts = new Map();
for (const evt of latestByAuthor.values()) {
const authorMints = new Set(
(evt.tags || [])
.filter((tag) => tag[0] === 'mint' && tag[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)
);
for (const mintUrl of authorMints) {
mintCounts.set(mintUrl, (mintCounts.get(mintUrl) || 0) + 1);
}
}
return Array.from(mintCounts.entries())
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count || a.url.localeCompare(b.url));
}
function aggregateRecommendations(rawRecommendationEvents, followedPubkeys = null) {
const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null;
const mintCounts = new Map();
for (const evt of rawRecommendationEvents || []) {
if (!evt || evt.kind !== 38000) continue;
if (followedSet && !followedSet.has(evt.pubkey)) continue;
const stars = extractRecommendationStars(evt);
const eventMints = new Set(
(evt.tags || [])
.filter((tag) => tag[0] === 'u' && tag[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)
);
const contentMatches = String(evt.content || '').match(/https:\/\/[^\s"'<>]+/gi) || [];
for (const candidate of contentMatches) {
const normalized = normalizeMintUrl(candidate);
if (normalized) eventMints.add(normalized);
}
for (const mintUrl of eventMints) {
const prev = mintCounts.get(mintUrl) || { count: 0, starSum: 0, starVotes: 0 };
prev.count += 1;
if (stars !== null) {
prev.starSum += stars;
prev.starVotes += 1;
}
mintCounts.set(mintUrl, prev);
}
}
return Array.from(mintCounts.entries())
.map(([url, stats]) => {
const starVotes = Number(stats?.starVotes || 0);
const starSum = Number(stats?.starSum || 0);
const avgStars = starVotes > 0 ? (starSum / starVotes) : 0;
return {
url,
count: Number(stats?.count || 0),
starSum,
starVotes,
avgStars
};
})
.sort((a, b) => {
if (b.starSum !== a.starSum) return b.starSum - a.starSum;
if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars;
if (b.count !== a.count) return b.count - a.count;
return a.url.localeCompare(b.url);
});
}
function recomputeMintDiscoveryViews() {
const followed = mintDiscoveryCache.followedPubkeys || [];
mintDiscoveryCache.contactMintsByScope = {
all: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, null),
wot: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, followed)
};
mintDiscoveryCache.recommendationMintsByScope = {
all: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, null),
wot: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, followed)
};
}
async function hydrateMintDiscoveryFromCache() {
const since = getDiscoverySinceSec();
const [cachedContacts, cachedMintLists, cachedRecommendations] = await Promise.all([
queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []),
queryCache({ kinds: [10019], limit: 4000 }).catch(() => []),
queryCache({ kinds: [38000], limit: 4000 }).catch(() => [])
]);
mintDiscoveryCache.rawContactEvents = dedupeEvents((cachedContacts || []).filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey));
mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents);
mintDiscoveryCache.rawMintListEvents = dedupeEvents((cachedMintLists || []).filter((evt) => evt && evt.kind === 10019 && Number(evt.created_at || 0) >= since));
mintDiscoveryCache.rawRecommendationEvents = dedupeEvents((cachedRecommendations || []).filter((evt) => evt && evt.kind === 38000 && Number(evt.created_at || 0) >= since));
mintDiscoveryCache.hydratedFromCache = true;
mintDiscoveryCache.error = null;
recomputeMintDiscoveryViews();
console.log('[cashu.html] mint-discovery:cache-hydrated', {
follows: mintDiscoveryCache.followedPubkeys.length,
mintListEvents: mintDiscoveryCache.rawMintListEvents.length,
recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length
});
}
async function refreshMintDiscoveryFromRelays() {
const since = getDiscoverySinceSec();
const [relayContacts, relayMintLists, relayRecommendations] = await Promise.all([
ndkFetchEvents({ kinds: [3], authors: [currentPubkey], limit: 1 }),
ndkFetchEvents({ kinds: [10019], since, limit: 2000 }),
ndkFetchEvents({ kinds: [38000], since, limit: 2000 })
]);
mintDiscoveryCache.rawContactEvents = dedupeEvents([...(mintDiscoveryCache.rawContactEvents || []), ...(relayContacts || [])]);
mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents);
mintDiscoveryCache.rawMintListEvents = dedupeEvents([...(mintDiscoveryCache.rawMintListEvents || []), ...(relayMintLists || [])]);
mintDiscoveryCache.rawRecommendationEvents = dedupeEvents([...(mintDiscoveryCache.rawRecommendationEvents || []), ...(relayRecommendations || [])]);
mintDiscoveryCache.refreshedFromRelays = true;
mintDiscoveryCache.error = null;
recomputeMintDiscoveryViews();
console.log('[cashu.html] mint-discovery:relay-refresh', {
follows: mintDiscoveryCache.followedPubkeys.length,
mintListEvents: mintDiscoveryCache.rawMintListEvents.length,
recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length
});
}
function startMintDiscoveryInBackground(force = false) {
console.log('[cashu.html] mint-discovery:bg-start:called', {
hasPromise: Boolean(mintDiscoveryPromise),
hasWalletController: Boolean(walletController),
scope: mintDiscoveryScope,
force
});
if (!walletController) return;
if (mintDiscoveryPromise && !force) return;
mintDiscoveryPromise = (async () => {
try {
if (!mintDiscoveryCache.hydratedFromCache || force) {
await hydrateMintDiscoveryFromCache();
if (isNavOpen) {
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
}
}
await refreshMintDiscoveryFromRelays();
if (isNavOpen) {
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
}
} catch (error) {
console.error('[cashu.html] Mint discovery failed:', error);
mintDiscoveryCache.error = error;
if (isNavOpen) {
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
}
} finally {
mintDiscoveryPromise = null;
}
})();
}
function mergeMintDiscoveryRows(scope) {
const mintLists = mintDiscoveryCache.contactMintsByScope?.[scope] || [];
const recs = mintDiscoveryCache.recommendationMintsByScope?.[scope] || [];
const walletMints = walletController?.hasWallet()
? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean)
: [];
const byUrl = new Map();
for (const row of mintLists) {
const url = normalizeMintUrl(row?.url);
if (!url) continue;
const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false };
prev.count += Number(row?.count || 0);
byUrl.set(url, prev);
}
for (const row of recs) {
const url = normalizeMintUrl(row?.url);
if (!url) continue;
const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false };
prev.count += Number(row?.count || 0);
prev.recCount += Number(row?.count || 0);
prev.starVotes += Number(row?.starVotes || 0);
prev.starSum += Number(row?.starSum || 0);
prev.avgStars = prev.starVotes > 0 ? prev.starSum / prev.starVotes : 0;
byUrl.set(url, prev);
}
const walletOrder = new Map(walletMints.map((url, idx) => [url, idx]));
for (const mintUrl of walletMints) {
const prev = byUrl.get(mintUrl) || { url: mintUrl, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false };
prev.isWalletMint = true;
byUrl.set(mintUrl, prev);
}
const rows = Array.from(byUrl.values());
rows.sort((a, b) => {
const aWallet = a.isWalletMint ? 1 : 0;
const bWallet = b.isWalletMint ? 1 : 0;
if (aWallet !== bWallet) return bWallet - aWallet;
if (a.isWalletMint && b.isWalletMint) {
return (walletOrder.get(a.url) ?? 99999) - (walletOrder.get(b.url) ?? 99999);
}
if (mintDiscoverySortMode === 'rating') {
if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars;
if (b.starVotes !== a.starVotes) return b.starVotes - a.starVotes;
if (b.count !== a.count) return b.count - a.count;
return a.url.localeCompare(b.url);
}
if (b.count !== a.count) return b.count - a.count;
if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars;
return a.url.localeCompare(b.url);
});
return rows;
}
function applyMintDiscoveryScopeUi() {
const isAll = mintDiscoveryScope === 'all';
btnToggleWot?.classList.toggle('active', !isAll);
if (btnToggleWot) {
btnToggleWot.textContent = isAll ? 'Scope: All' : 'Scope: WoT';
}
btnSortByCount?.classList.toggle('active', mintDiscoverySortMode === 'count');
btnSortByRating?.classList.toggle('active', mintDiscoverySortMode === 'rating');
if (divContactMintsTitle) {
divContactMintsTitle.textContent = 'Mints';
}
}
function bindMintDiscoveryControls() {
applyMintDiscoveryScopeUi();
btnSortByCount?.addEventListener('click', () => {
if (mintDiscoverySortMode === 'count') return;
mintDiscoverySortMode = 'count';
applyMintDiscoveryScopeUi();
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
});
btnSortByRating?.addEventListener('click', () => {
if (mintDiscoverySortMode === 'rating') return;
mintDiscoverySortMode = 'rating';
applyMintDiscoveryScopeUi();
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
});
btnToggleWot?.addEventListener('click', () => {
mintDiscoveryScope = mintDiscoveryScope === 'all' ? 'wot' : 'all';
applyMintDiscoveryScopeUi();
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
});
}
function renderMintDiscoverySidebar({ skipBackgroundKickoff = false } = {}) {
console.log('[cashu.html] mint-discovery:sidebar-render:start', {
scope: mintDiscoveryScope,
hydratedFromCache: mintDiscoveryCache.hydratedFromCache,
refreshedFromRelays: mintDiscoveryCache.refreshedFromRelays,
skipBackgroundKickoff
});
if (!walletController) {
console.warn('[cashu.html] mint-discovery:sidebar-render:no-wallet-controller');
if (divContactMintsList) divContactMintsList.textContent = 'Wallet controller not ready yet';
return;
}
if (!skipBackgroundKickoff) {
startMintDiscoveryInBackground();
}
if (!mintDiscoveryCache.hydratedFromCache && !mintDiscoveryCache.refreshedFromRelays) {
if (divContactMintsList) divContactMintsList.textContent = 'Loading mints from cache…';
return;
}
if (mintDiscoveryCache.error && !mintDiscoveryCache.refreshedFromRelays) {
if (divContactMintsList) divContactMintsList.textContent = 'Failed to load mints';
return;
}
const mergedMints = mergeMintDiscoveryRows(mintDiscoveryScope);
const viewKey = `${mintDiscoveryScope}:${mintDiscoverySortMode}`;
if (mergedMints.length > 0) {
mintDiscoveryLastNonEmptyByView.set(viewKey, mergedMints.map((row) => ({ ...row })));
}
const renderedMints = mergedMints.length > 0
? mergedMints
: (mintDiscoveryLastNonEmptyByView.get(viewKey) || []);
if (mergedMints.length === 0 && renderedMints.length > 0) {
console.warn('[cashu.html] mint-discovery:sidebar-render:using-last-non-empty-view', {
scope: mintDiscoveryScope,
sort: mintDiscoverySortMode,
cachedCount: renderedMints.length
});
}
console.log('[cashu.html] mint-discovery:sidebar-render:success', {
scope: mintDiscoveryScope,
mintCount: renderedMints.length
});
const emptyMessage = mintDiscoveryScope === 'all'
? 'No mints found in the last year'
: 'No web-of-trust mints found in the last year';
renderMintDiscoveryRows(divContactMintsList, renderedMints, emptyMessage);
}
async function handleAddDiscoveredMint(mintUrl) {
const url = String(mintUrl || '').trim();
if (!url) return;
if (walletController?.hasWallet()) {
try {
setStatus('Adding mint...');
await walletController.addMint(url);
await refreshAllWalletViews();
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
setStatus('Mint added');
} catch (error) {
console.error('[cashu.html] Add discovered mint failed:', error);
setStatus(error.message || 'Failed to add mint', true);
}
return;
}
const existing = parseMintLines(taSetupMints.value);
if (!existing.includes(url)) {
taSetupMints.value = [...existing, url].join('\n');
}
setStatus('Mint added to setup list');
}
async function handleRemoveDiscoveredMint(mintUrl) {
const url = String(mintUrl || '').trim();
if (!url || !walletController?.hasWallet()) return;
try {
setStatus('Removing mint...');
await walletController.removeMint(url);
await refreshAllWalletViews();
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
setStatus('Mint removed');
} catch (error) {
console.error('[cashu.html] Remove discovered mint failed:', error);
setStatus(error.message || 'Failed to remove mint', true);
}
}
function renderMintSelectors(mints) {
const options = (mints || []).map((mint) => `<option value="${mint}">${mint}</option>`).join('');
selSendMint.innerHTML = options;
selDepositMint.innerHTML = options;
}
function switchPanel(panelName) {
const panelMap = {
receive: panelReceive,
send: panelSend,
deposit: panelDeposit,
withdraw: panelWithdraw
};
for (const [name, panelEl] of Object.entries(panelMap)) {
panelEl.classList.toggle('active', name === panelName);
}
btnTabReceive.classList.toggle('active', panelName === 'receive');
btnTabSend.classList.toggle('active', panelName === 'send');
btnTabDeposit.classList.toggle('active', panelName === 'deposit');
btnTabWithdraw.classList.toggle('active', panelName === 'withdraw');
}
async function renderBalance() {
if (!walletController?.hasWallet()) return;
const total = walletController.getBalance();
const byMint = walletController.getBalanceByMint();
divBalanceAmount.textContent = formatSats(total);
const mintRows = Object.entries(byMint || {});
if (mintRows.length === 0) {
divMintBalances.innerHTML = 'No mint balances yet';
} else {
divMintBalances.innerHTML = mintRows
.map(([mint, amount]) => `
<div class="cashuMintBalanceRow">
<div class="cashuMintUrl">${mint}</div>
<div>${formatSats(amount)}</div>
</div>
`)
.join('');
}
}
async function renderSettings() {
if (!walletController?.hasWallet()) return;
const fromWallet = walletController.getMints();
const fromBalance = Object.keys(walletController.getBalanceByMint() || {});
const mints = Array.from(new Set([...(fromWallet || []), ...(fromBalance || [])]))
.map((mint) => normalizeMintUrl(mint))
.filter(Boolean);
renderMintSelectors(mints);
if (selWithdrawMint) {
selWithdrawMint.innerHTML = (mints || []).map((mint) => `<option value="${mint}">${mint}</option>`).join('');
}
if (isNavOpen) {
renderMintDiscoverySidebar({ skipBackgroundKickoff: true });
}
}
async function renderTransactions() {
if (!walletController) return;
const hasWalletNow = Boolean(walletController?.hasWallet?.());
const hasWorkerWallet = Boolean(latestWorkerWalletBalance?.hasWallet);
console.log('[cashu.html] renderTransactions invoked', { hasWalletNow, hasWorkerWallet });
if (!hasWalletNow && !hasWorkerWallet) return;
const txs = await walletController.getTransactionHistory();
if (!hasSeededInitialSendTxState) {
txs.forEach((tx) => {
const txId = String(tx?.id || '').trim();
if (tx?.direction === 'out' && txId) {
redeemedSendTxIds.add(txId);
}
});
hasSeededInitialSendTxState = true;
}
if (!txs.length) {
divTxList.innerHTML = 'No transactions yet';
return;
}
divTxList.innerHTML = txs
.map((tx) => {
const direction = tx.direction === 'in' ? '↓' : '↑';
const txId = String(tx.id || '').trim();
const isRedeemedOut = tx.direction === 'out' && txId && redeemedSendTxIds.has(txId);
const dirClass = tx.direction === 'in'
? 'cashuTxIn'
: (isRedeemedOut ? 'cashuTxOutRedeemed' : 'cashuTxOut');
const amount = Number(tx.amount || 0);
const mint = tx.mint ? ` · ${shortenMintName(tx.mint)}` : '';
const description = tx.description ? ` · ${tx.description}` : '';
const redeemedSuffix = isRedeemedOut ? ' · Redeemed' : '';
const formattedTime = formatTimeAgo(tx.timestamp);
return `
<div class="cashuTxItem">
<div class="${dirClass}">${direction} ${formatSats(amount)}${mint}${description}${redeemedSuffix}</div>
<div class="cashuTxTimeAgo" data-timestamp="${Number(tx.timestamp || 0)}" data-next-update-at="${formattedTime.nextUpdateAt}">${formattedTime.text}</div>
</div>
`;
})
.join('');
}
async function refreshAllWalletViews() {
await renderBalance();
await renderSettings();
await renderTransactions();
await refreshZapsSection();
}
async function ensureWalletStarted({ force = false } = {}) {
if (!walletController?.hasWallet()) return;
if (walletStartupPromise && !force) {
return walletStartupPromise;
}
walletStartupPromise = (async () => {
await walletController.startWallet();
})();
try {
await walletStartupPromise;
} finally {
walletStartupPromise = null;
}
}
function startWalletInBackground() {
if (!walletController?.hasWallet()) return;
setStatus('Wallet loaded from cache, syncing in background…');
ensureWalletStarted()
.then(async () => {
await refreshAllWalletViews();
setStatus('Wallet loaded');
})
.catch((error) => {
console.error('[cashu.html] Background wallet startup failed:', error);
setStatus(error.message || 'Wallet startup delayed', true);
});
}
async function activateWalletUiFromWorkerEvent(reason = 'worker-event') {
if (!walletController || workerWalletUiActivated) return;
workerWalletUiActivated = true;
try {
console.log('[cashu.html] activateWalletUiFromWorkerEvent', { reason });
await walletController.loadWallet();
showWallet();
setStatus('Wallet detected, loading details...');
unsubscribeTx?.();
unsubscribeTx = walletController.subscribeTransactions(async () => {
await renderBalance();
await renderTransactions();
});
walletController.onWalletEvent('balance_updated', async () => {
await renderBalance();
});
walletController.onWalletEvent('status_changed', async (payload) => {
if (payload?.status !== 'ready') return;
try {
await walletController.refreshBalance();
await refreshAllWalletViews();
} catch (error) {
console.error('[cashu.html] Status-based wallet refresh failed:', error);
}
});
await refreshAllWalletViews();
startWalletInBackground();
} catch (error) {
workerWalletUiActivated = false;
console.error('[cashu.html] activateWalletUiFromWorkerEvent failed:', error);
}
}
function bindTabs() {
btnTabReceive.addEventListener('click', () => switchPanel('receive'));
btnTabSend.addEventListener('click', () => switchPanel('send'));
btnTabDeposit.addEventListener('click', () => switchPanel('deposit'));
btnTabWithdraw.addEventListener('click', () => switchPanel('withdraw'));
}
function bindActions() {
btnCreateWallet.addEventListener('click', async () => {
console.log('[cashu.html] Create wallet button clicked');
try {
const mints = parseMintLines(taSetupMints.value);
setStatus('Creating wallet…');
const relaysToUse = Array.isArray(currentRelayUrls) ? [...currentRelayUrls] : [];
await walletController.createWallet(mints, relaysToUse);
unsubscribeTx?.();
unsubscribeTx = walletController.subscribeTransactions(async () => {
await renderBalance();
await renderTransactions();
});
showWallet();
await refreshAllWalletViews();
setStatus('Wallet created successfully');
} catch (error) {
console.error('[cashu.html] Create wallet failed:', error);
setStatus(error.message || 'Failed to create wallet', true);
}
});
btnReceiveToken.addEventListener('click', async () => {
try {
const token = taReceiveToken.value.trim();
if (!token) {
setStatus('Paste a token first', true);
return;
}
setStatus('Receiving token...');
await walletController.receiveToken(token, 'Manual receive');
taReceiveToken.value = '';
if (divSendTokenOutput.textContent.trim()) {
const shouldDiscardSendOutput = confirmDiscardTransactionOutputs({ send: true });
if (shouldDiscardSendOutput) {
clearTransactionOutputs({ send: true, deposit: false, withdraw: false });
}
}
setStatus('Token received');
} catch (error) {
console.error('[cashu.html] Receive token failed:', error);
setStatus(error.message || 'Receive failed', true);
} finally {
await refreshAllWalletViews();
}
});
btnSendToken.addEventListener('click', async () => {
try {
const amount = Number(inpSendAmount.value);
const memo = inpSendMemo.value.trim() || 'Cashu token';
if (!amount || amount <= 0) {
setStatus('Enter a valid send amount', true);
return;
}
setStatus('Generating token...');
const result = await walletController.sendToken(amount, memo, selSendMint.value);
divSendTokenOutput.textContent = result.token;
divSendTokenOutput.classList.remove('clsHidden');
renderQrCode(divSendTokenQr, result.token);
if (divSendTokenPreview) {
divSendTokenPreview.classList.remove('clsHidden');
}
btnCopySendToken.classList.remove('clsHidden');
if (btnClearSendToken) {
btnClearSendToken.classList.remove('clsHidden');
}
await refreshAllWalletViews();
setStatus('Token generated (kept visible for scanning)');
} catch (error) {
console.error('[cashu.html] Send token failed:', error);
setStatus(error.message || 'Send failed', true);
}
});
btnCopySendToken.addEventListener('click', async () => {
await copyToClipboard(divSendTokenOutput.textContent, 'Token copied');
});
if (btnCloseSendTokenPreview) {
btnCloseSendTokenPreview.addEventListener('click', () => {
clearTransactionOutputs({ send: true, deposit: false, withdraw: false });
setStatus('Generated token closed');
});
}
if (btnClearSendToken) {
btnClearSendToken.addEventListener('click', () => {
clearTransactionOutputs({ send: true, deposit: false, withdraw: false });
setStatus('Generated token cleared');
});
}
btnCreateInvoice.addEventListener('click', async () => {
try {
const amount = Number(inpDepositAmount.value);
const mint = selDepositMint.value;
if (!amount || amount <= 0) {
setStatus('Enter a valid deposit amount', true);
return;
}
setStatus('Creating Lightning invoice...');
const { deposit, invoice } = await walletController.createDeposit(amount, mint);
divDepositInvoice.textContent = invoice;
divDepositInvoice.classList.remove('clsHidden');
renderQrCode(divDepositInvoiceQr, invoice);
btnCopyInvoice.classList.remove('clsHidden');
setStatus('Invoice created, waiting for payment...');
walletController.waitForDeposit(deposit)
.then(async () => {
await refreshAllWalletViews();
const shouldDiscardDepositOutput = confirmDiscardTransactionOutputs({ deposit: true });
if (shouldDiscardDepositOutput) {
clearTransactionOutputs({ send: false, deposit: true, withdraw: false });
}
setStatus('Deposit confirmed');
})
.catch((error) => {
console.error('[cashu.html] Deposit wait failed:', error);
setStatus(error.message || 'Deposit failed', true);
});
} catch (error) {
console.error('[cashu.html] Create invoice failed:', error);
setStatus(error.message || 'Deposit setup failed', true);
}
});
btnCopyInvoice.addEventListener('click', async () => {
await copyToClipboard(divDepositInvoice.textContent, 'Invoice copied');
});
btnPayInvoice.addEventListener('click', async () => {
try {
const invoice = taWithdrawInvoice.value.trim();
const mint = selWithdrawMint.value;
if (!invoice) {
setStatus('Paste a Lightning invoice to withdraw', true);
return;
}
setStatus('Paying Lightning invoice...');
await walletController.payInvoice(invoice, mint);
taWithdrawInvoice.value = '';
await refreshAllWalletViews();
const shouldDiscardWithdrawOutput = confirmDiscardTransactionOutputs({ withdraw: true });
if (shouldDiscardWithdrawOutput) {
clearTransactionOutputs({ send: false, deposit: false, withdraw: true });
}
setStatus('Lightning payment sent');
} catch (error) {
console.error('[cashu.html] Lightning withdrawal failed:', error);
setStatus(error.message || 'Lightning withdrawal failed', true);
}
});
btnAddMint.addEventListener('click', async () => {
const mintUrl = inpAddMint.value.trim();
if (!mintUrl) {
setStatus('Enter a mint URL to add', true);
return;
}
await handleAddDiscoveredMint(mintUrl);
if (!divWalletContent.classList.contains('clsHidden')) {
inpAddMint.value = '';
}
});
btnCheckProofs.addEventListener('click', async () => {
try {
if (btnCheckProofs.disabled) {
const disabledMessage = 'Wallet still syncing proofs, wait for sync to complete before checking';
setStatus(disabledMessage, true);
setCheckProofsStatus(disabledMessage, true);
return;
}
setStatus('Checking proofs with mints...');
setCheckProofsStatus('Checking proofs with mints...');
await ensureWalletStarted();
const result = await walletController.checkProofs();
await refreshAllWalletViews();
const proofCheck = result?.proofCheck;
if (proofCheck) {
const mintsChecked = Number(proofCheck?.mintsCheckedBefore || 0);
const checked = Number(proofCheck?.proofsChecked || 0);
const removed = Number(proofCheck?.proofsRemoved || 0);
const after = Number(proofCheck?.balanceAfter || 0);
const delta = Number(proofCheck?.balanceDelta || 0);
const source = String(proofCheck?.sync?.source || proofCheck?.balanceSource || 'unknown');
const warning = proofCheck?.warning ? `${proofCheck.warning}` : '';
const deltaText = delta === 0 ? 'Δ0' : `Δ${delta > 0 ? '+' : ''}${delta}`;
const summary = `Proof check: ${checked} proofs / ${mintsChecked} mints, removed ${removed}, balance ${formatSats(after)} (${deltaText}, ${source})${warning}`;
setStatus(summary);
setCheckProofsStatus(summary);
} else {
const doneMessage = 'Proof check complete';
setStatus(doneMessage);
setCheckProofsStatus(doneMessage);
}
} catch (error) {
console.error('[cashu.html] Check proofs failed:', error);
const errorMessage = error.message || 'Proof check failed';
setStatus(errorMessage, true);
setCheckProofsStatus(errorMessage, true);
}
});
btnRefreshWallet.addEventListener('click', async () => {
try {
setStatus('Refreshing wallet state...');
await ensureWalletStarted({ force: true });
await refreshAllWalletViews();
setStatus('Wallet refreshed');
} catch (error) {
console.error('[cashu.html] Wallet refresh failed:', error);
setStatus(error.message || 'Wallet refresh failed', true);
}
});
btnSaveZapsSettings?.addEventListener('click', async () => {
try {
const amount = Number(inpZapDefaultAmount?.value || 0);
const comment = String(taZapDefaultComment?.value || '').trim();
const preferredMethod = normalizePreferredZapMethod(selZapPreferredMethod?.value || 'auto');
if (!Number.isFinite(amount) || amount <= 0) {
setZapsStatus('Enter a valid default zap amount (sats)', true);
return;
}
const allowlistInput = parseNutzapReceiveAllowlistInput(taNutzapReceiveMints?.value || '');
if (allowlistInput.invalid.length > 0) {
setZapsStatus(`Invalid mint URL(s): ${allowlistInput.invalid[0]}`, true);
return;
}
await patchUserSettings({
zaps: {
defaultAmountSats: Math.floor(amount),
defaultComment: comment,
preferredMethod,
receiveMintAllowlist: allowlistInput.normalized
}
});
zapSettings = {
defaultAmountSats: Math.floor(amount),
defaultComment: comment,
preferredMethod,
receiveMintAllowlist: allowlistInput.normalized
};
applyZapSettingsForm();
renderNutzapStatusFromLocalState();
setZapsStatus('Zap defaults saved');
} catch (error) {
console.error('[cashu.html] Save zaps settings failed:', error);
setZapsStatus(error?.message || 'Failed to save zap defaults', true);
}
});
btnResetZapsSettings?.addEventListener('click', async () => {
try {
const reset = {
defaultAmountSats: 21,
defaultComment: '',
preferredMethod: 'auto',
receiveMintAllowlist: []
};
await patchUserSettings({ zaps: reset });
zapSettings = { ...reset };
applyZapSettingsForm();
setZapsStatus('Zap defaults reset');
} catch (error) {
console.error('[cashu.html] Reset zaps settings failed:', error);
setZapsStatus(error?.message || 'Failed to reset zap defaults', true);
}
});
btnUseWalletMintsForNutzap?.addEventListener('click', () => {
const walletMints = walletController?.hasWallet?.()
? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean)
: [];
if (taNutzapReceiveMints) {
taNutzapReceiveMints.value = walletMints.join('\n');
}
setZapsStatus(walletMints.length > 0 ? 'Loaded wallet mints into allowlist editor' : 'No wallet mints available yet', walletMints.length === 0);
});
btnRepublishMintList?.addEventListener('click', async () => {
try {
if (!walletController?.hasWallet?.()) {
setZapsStatus('Create a wallet before publishing nutzap mint list', true);
return;
}
const allowlistInput = parseNutzapReceiveAllowlistInput(taNutzapReceiveMints?.value || '');
if (allowlistInput.invalid.length > 0) {
setZapsStatus(`Invalid mint URL(s): ${allowlistInput.invalid[0]}`, true);
return;
}
setZapsStatus('Publishing kind 10019 mint list...');
await ensureWalletStarted();
await patchUserSettings({
zaps: {
receiveMintAllowlist: allowlistInput.normalized
}
});
zapSettings = {
...zapSettings,
receiveMintAllowlist: allowlistInput.normalized
};
const relaysToUse = Array.isArray(currentRelayUrls) ? [...currentRelayUrls] : [];
const result = await walletController.publishMintList(relaysToUse, allowlistInput.normalized);
latestMintListStatus = {
mints: Array.isArray(result?.mints) ? result.mints : [],
relays: Array.isArray(result?.relays) ? result.relays : [],
p2pk: result?.p2pk || null,
eventId: result?.eventId || null,
publishedRelayCount: Number(result?.publishedRelayCount || 0),
updatedAtSec: Math.floor(Date.now() / 1000)
};
renderNutzapStatusFromLocalState();
const relayCount = Number(result?.publishedRelayCount || 0);
const mintScopeText = allowlistInput.normalized.length > 0
? `${allowlistInput.normalized.length} selected mints`
: 'all wallet mints';
setZapsStatus(`Published nutzap mint list (${relayCount} relays, ${mintScopeText})`);
} catch (error) {
console.error('[cashu.html] Republish mint list failed:', error);
setZapsStatus(error?.message || 'Failed to publish nutzap mint list', true);
}
});
}
(async function main() {
console.log('[cashu.html] Starting initialization...');
try {
initHamburgerMenu();
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('[cashu.html] Logout failed:', error);
}
});
}
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
unsubscribeUserSettings?.();
unsubscribeUserSettings = onUserSettings((settings) => {
zapSettings = normalizeZapSettings(settings || {});
zapSettingsLoaded = true;
applyZapSettingsForm();
});
await hydrateZapSettingsFromStore({ silent: true });
window.addEventListener('ndkWalletBalance', (event) => {
const payload = event?.detail || {};
latestWorkerWalletBalance = payload;
if (payload?.balanceSource === 'full' && payload?.isApproximate === false) {
hasFullWalletBalanceReady = true;
}
if (payload?.hasWallet) {
walletStatusReady = true;
showWallet();
void activateWalletUiFromWorkerEvent('ndkWalletBalance');
}
renderImmediateWorkerBalance(payload);
updateCheckProofsButtonState();
});
window.addEventListener('ndkWalletStatus', (event) => {
const payload = event?.detail || {};
walletStatusReady = payload?.status === 'ready' || payload?.hasWallet === true;
if (payload?.hasWallet) {
showWallet();
void activateWalletUiFromWorkerEvent('ndkWalletStatus');
}
updateCheckProofsButtonState();
});
window.addEventListener('ndkWalletSendRedeemed', async (event) => {
const payload = event?.detail || {};
console.log('[cashu.html] ndkWalletSendRedeemed event received', payload);
const amount = Number(payload?.amount || 0);
const mint = String(payload?.mint || '').trim();
const memo = String(payload?.memo || '').trim();
const txId = String(payload?.txId || '').trim();
const mintLabel = mint ? ` on ${shortenMintName(mint)}` : '';
const memoLabel = memo ? ` (${memo})` : '';
if (txId) {
redeemedSendTxIds.add(txId);
}
clearTransactionOutputs({ send: true, deposit: false, withdraw: false });
await renderTransactions();
setStatus(`✓ Sent token redeemed${mintLabel}: ${formatSats(amount)}${memoLabel}`);
});
window.addEventListener('ndkWalletTransaction', async (event) => {
const payload = event?.detail || {};
console.log('[cashu.html] ndkWalletTransaction event received', payload);
await renderBalance();
await renderTransactions();
});
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
setRelayActivityState(relayUrl, activity);
}
});
const relayData = await getRelayData();
const relayUrls = (relayData || []).map((relay) => relay.url).filter(Boolean);
currentRelayUrls = [...relayUrls];
walletController = await createCashuWalletController({
pubkey: currentPubkey,
relayUrls
});
await walletController.loadWallet();
console.log('[cashu.html] mint-discovery:wallet-controller-ready', {
hasDiscoverMintsFromContacts: typeof walletController?.discoverMintsFromContacts,
hasDiscoverMintRecommendations: typeof walletController?.discoverMintRecommendations
});
bindTabs();
bindActions();
bindMintDiscoveryControls();
updateCheckProofsButtonState();
if (walletController.hasWallet()) {
console.log('[cashu.html] startup branch: walletController.hasWallet() === true');
showWallet();
unsubscribeTx?.();
unsubscribeTx = walletController.subscribeTransactions(async () => {
await renderBalance();
await renderTransactions();
});
walletController.onWalletEvent('balance_updated', async () => {
await renderBalance();
});
walletController.onWalletEvent('status_changed', async (payload) => {
if (payload?.status !== 'ready') return;
try {
await walletController.refreshBalance();
await refreshAllWalletViews();
} catch (error) {
console.error('[cashu.html] Status-based wallet refresh failed:', error);
}
});
await refreshAllWalletViews();
startWalletInBackground();
} else if (latestWorkerWalletBalance?.hasWallet) {
console.log('[cashu.html] startup branch: latestWorkerWalletBalance.hasWallet === true');
showWallet();
renderImmediateWorkerBalance(latestWorkerWalletBalance);
setStatus('Wallet detected, loading details...');
unsubscribeTx?.();
unsubscribeTx = walletController.subscribeTransactions(async () => {
await renderBalance();
await renderTransactions();
});
walletController.onWalletEvent('balance_updated', async () => {
await renderBalance();
});
walletController.onWalletEvent('status_changed', async (payload) => {
if (payload?.status !== 'ready') return;
try {
await walletController.refreshBalance();
await refreshAllWalletViews();
} catch (error) {
console.error('[cashu.html] Status-based wallet refresh failed:', error);
}
});
await renderTransactions();
startWalletInBackground();
} else {
console.log('[cashu.html] startup branch: no wallet detected');
showSetup();
setStatus('No wallet yet — add one or more mints to create one');
}
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
updateIntervalId = setInterval(UpdateFooter, 1000);
startTimestampUpdater();
await updateVersionDisplay();
console.log('[cashu.html] mint-discovery:post-init-scheduled');
setTimeout(() => {
console.log('[cashu.html] mint-discovery:post-init-trigger', {
hasWalletController: Boolean(walletController),
hasPromise: Boolean(mintDiscoveryPromise)
});
startMintDiscoveryInBackground();
}, 0);
console.log('[cashu.html] Initialization complete');
} catch (error) {
unsubscribeUserSettings?.();
unsubscribeUserSettings = null;
console.error('[cashu.html] Initialization failed:', error);
setStatus(error.message || 'Initialization failed', true);
divBody.innerHTML = `<div style="text-align: center; padding: 50px; color: var(--primary-color);">
<div style="font-size: 24px; margin-bottom: 20px; color: var(--accent-color);">❌ Cashu Initialization Error</div>
<div style="font-size: 16px; color: var(--muted-color);">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();