1661 lines
60 KiB
JavaScript
1661 lines
60 KiB
JavaScript
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>`;
|
||
}
|
||
})();
|
||
|