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

1661 lines
60 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 } 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 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 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();
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 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 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 (!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();
}
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 = '';
await refreshAllWalletViews();
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);
}
});
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);
}
});
}
(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);
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}`);
});
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) {
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>`;
}
})();