function setHistoryState(message, mode = "") { if (!historyState) { return; } historyState.textContent = message; historyState.classList.toggle("hidden", !message); historyState.classList.toggle("loading", mode === "loading"); } function clearHistoryRows() { if (historyTransactionsBody) { historyTransactionsBody.innerHTML = ""; } if (historyCards) { historyCards.innerHTML = ""; } } function setHistoryFooter( visibleCount = 0, totalCount = 0, overallCount = totalCount, searchTerm = "", page = 1, totalPages = 1, pageStart = 0 ) { if (historyCount) { const normalizedSearchTerm = String(searchTerm || "").trim(); const rangeStart = visibleCount ? pageStart + 1 : 0; const rangeEnd = pageStart + visibleCount; if (normalizedSearchTerm && overallCount !== totalCount) { historyCount.textContent = totalCount ? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)} matches (${formatNumber(overallCount)} total)` : `No matches (${formatNumber(overallCount)} total)`; } else { historyCount.textContent = totalCount ? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)}` : ""; } } if (historyPagination) { historyPagination.hidden = totalPages <= 1; historyPagination.querySelectorAll("[data-history-page]").forEach((button) => { const action = button.dataset.historyPage; button.disabled = action === "first" || action === "previous" ? page <= 1 : page >= totalPages; }); } if (historyPageInput) { historyPageInput.value = String(page); historyPageInput.max = String(Math.max(1, totalPages)); historyPageInput.disabled = totalPages <= 1; } if (historyPageTotal) { historyPageTotal.textContent = formatNumber(Math.max(1, totalPages)); } } function normalizedHistorySearchTerm(value) { return String(value || "").trim().toLowerCase(); } function compactHistorySearchText(value) { return normalizedHistorySearchTerm(value).replace(/[\s_-]+/g, ""); } function historyTypeSearchLabels(type) { const numeric = Number(type || 0); const key = txTypeKey(numeric); const aliases = { 0: ["genesis"], 1: ["reward", "mining reward"], 2: ["transfer", "transfer in", "transfer out"], 3: ["token create", "create token"], 4: ["nft create", "create nft"], 5: ["marketing"], 6: ["swap"], 7: ["loan create", "loan contract"], 8: ["loan payment", "contract payment"], 9: ["collateral claim"], 10: ["burn"], 11: ["token issue", "issue token"], 12: ["vanity", "vanity address"] }; return [String(numeric), key, key.replace(/_/g, " "), ...(aliases[numeric] || [])]; } function historyTransactionMatchesSearch(index, txid, searchTerm) { const term = normalizedHistorySearchTerm(searchTerm); if (!term) { return true; } const compactTerm = compactHistorySearchText(term); if (String(txid || "").toLowerCase().includes(term)) { return true; } const type = Number(index?.txid_to_type?.[txid] ?? 0); return historyTypeSearchLabels(type).some((label) => { const normalized = normalizedHistorySearchTerm(label); return normalized.includes(term) || compactHistorySearchText(normalized).includes(compactTerm); }); } function historyTxidsForSearch(index, orderedTxids, searchTerm) { const term = normalizedHistorySearchTerm(searchTerm); if (!term) { return orderedTxids; } return orderedTxids.filter((txid) => historyTransactionMatchesSearch(index, txid, term)); } function historyExportRows(transactions) { return transactions.map((transaction) => ({ date: formatHistoryDate(transaction.timestamp), timestamp: Number(transaction.timestamp || 0), type: recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"), asset: recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()), counterparty: recentTransactionField(transaction, ["counterparty", "to", "from", "address"], ""), status: recentTransactionField(transaction, ["status"], "Pending"), amount: recentTransactionField(transaction, ["amount", "value", "display_amount"], ""), fee: recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--"), txid: recentTransactionField(transaction, ["txid"], ""), block: Number(transaction.block_height || transaction.blockHeight || transaction.height || 0) || null })); } function csvCell(value) { const text = value === null || value === undefined ? "" : String(value); return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function historyRowsToCsv(rows) { const headers = ["date", "timestamp", "type", "asset", "counterparty", "status", "amount", "fee", "txid", "block"]; return [ headers.join(","), ...rows.map((row) => headers.map((header) => csvCell(row[header])).join(",")) ].join("\r\n"); } function xmlEscape(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function historyRowsToXml(rows) { const body = rows.map((row) => ` ${xmlEscape(row.date)} ${xmlEscape(row.timestamp)} ${xmlEscape(row.type)} ${xmlEscape(row.asset)} ${xmlEscape(row.counterparty)} ${xmlEscape(row.status)} ${xmlEscape(row.amount)} ${xmlEscape(row.fee)} ${xmlEscape(row.txid)} ${xmlEscape(row.block ?? "")} `).join("\n"); return `\n\n${body}\n\n`; } function historyRowsToExport(rows, format) { const normalizedFormat = String(format || "").toLowerCase(); if (normalizedFormat === "json") { return JSON.stringify({ exported_at: new Date().toISOString(), address: activeWalletAddressShort || activeWalletAddressHex || "", transactions: rows }, null, 2); } if (normalizedFormat === "xml") { return historyRowsToXml(rows); } if (normalizedFormat === "csv") { return historyRowsToCsv(rows); } throw new Error("Unsupported export format."); } function setHistoryExportMessage(message, mode = "") { if (!historyExportMessage) { return; } historyExportMessage.textContent = message; historyExportMessage.classList.toggle("error", mode === "error"); historyExportMessage.classList.toggle("success", mode === "success"); } function toggleHistoryExportModal(open) { if (!historyExportModal) { return; } historyExportModal.classList.toggle("hidden", !open); if (open) { setHistoryExportMessage(""); } } async function exportFullHistory(format) { const invoke = window.__TAURI__?.core?.invoke; if (!invoke || !walletLoaded || !registrationRegistered) { setHistoryExportMessage("Load a registered wallet before exporting history.", "error"); return; } const normalizedFormat = String(format || "").toLowerCase(); const walletToken = activeWalletCacheToken(); setHistoryExportMessage("Preparing export. This may take several minutes."); historyExportFormatButtons.forEach((button) => { button.disabled = true; }); try { const cache = await readTransactionCacheObject(walletToken); if (!isActiveWalletCacheToken(walletToken)) { throw new Error("Wallet changed before export completed."); } const rows = historyExportRows(normalizeHistoryTransactions(cache)); if (!rows.length) { throw new Error("No local transaction history is available to export."); } const contents = historyRowsToExport(rows, normalizedFormat); const address = activeWalletAddressShort || activeWalletAddressHex || "address"; const safeAddress = String(address).replace(/[^a-z0-9._-]/gi, "_"); const savedPath = await invoke("save_history_export", { defaultFileName: `${safeAddress}-history.${normalizedFormat}`, format: normalizedFormat, contents }); if (savedPath) { setHistoryExportMessage(`Saved to ${savedPath}`, "success"); } else { setHistoryExportMessage("Export cancelled."); } } catch (error) { setHistoryExportMessage(String(error), "error"); } finally { historyExportFormatButtons.forEach((button) => { button.disabled = false; }); } } function historyTransactionTimestamp(record) { const transaction = record?.transaction; if (Array.isArray(transaction)) { const timestamp = Number(readU32LE(transaction, 1)); return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0; } const candidates = [ transaction?.unsigned?.timestamp, transaction?.unsigned?.time, transaction?.unsigned_transfer?.timestamp, transaction?.unsigned_transfer?.time, transaction?.unsigned_create_token?.timestamp, transaction?.unsigned_create_token?.time, transaction?.unsigned_create_nft?.timestamp, transaction?.unsigned_create_nft?.time, transaction?.unsigned_marketing?.timestamp, transaction?.unsigned_marketing?.time, transaction?.unsigned_swap?.timestamp, transaction?.unsigned_swap?.time, transaction?.unsigned_loan_contract?.timestamp, transaction?.unsigned_loan_contract?.time, transaction?.unsigned_contract_payment?.timestamp, transaction?.unsigned_contract_payment?.time, transaction?.unsigned_collateral_claim?.timestamp, transaction?.unsigned_collateral_claim?.time, transaction?.unsigned_burn?.timestamp, transaction?.unsigned_burn?.time, transaction?.unsigned_issue_token?.timestamp, transaction?.unsigned_issue_token?.time, transaction?.unsigned_vanity_address?.timestamp, transaction?.unsigned_vanity_address?.time ]; const timestamp = candidates.map(Number).find((value) => Number.isFinite(value) && value > 0); return timestamp || 0; } function formatHistoryDate(timestamp) { const numeric = Number(timestamp || 0); if (!numeric) { return "--"; } return new Date(numeric * 1000).toLocaleString(); } function historyStatusClass(status) { return String(status || "").trim().toLowerCase() === "confirmed" ? "confirmed" : "pending"; } function historyBlockLabel(block) { const numeric = Number(block || 0); return numeric > 0 ? formatNumber(numeric) : "Pending"; } function normalizeHistoryTransactions(cache) { const normalized = normalizeTransactionCache(cache); const orderedTxids = normalizeOrderedTxids(normalized); const records = transactionRecordsForTxids(normalized, orderedTxids); return records .flatMap((record, recordIndex) => { const repaired = repairCachedTransactionRecord(record.txid, record); const block = blockValueFromRecord(repaired); const timestamp = historyTransactionTimestamp(repaired); const status = repaired.status || walletStatusForBlock(block); return parseDashboardTransactionRows( repaired.txid, block, repaired.transaction, repaired.linked_transactions || {}, repaired.miner_earnings || [] ).map((row, rowIndex) => ({ ...row, txid: row.txid || repaired.txid, block_height: row.block_height ?? block, status, timestamp, __order: recordIndex + rowIndex / 1000 })); }) .sort((left, right) => { const leftPending = historyStatusClass(left.status) === "pending" ? 1 : 0; const rightPending = historyStatusClass(right.status) === "pending" ? 1 : 0; if (rightPending !== leftPending) { return rightPending - leftPending; } const leftBlock = Number(left.block_height || 0); const rightBlock = Number(right.block_height || 0); if (rightBlock !== leftBlock) { return rightBlock - leftBlock; } const leftTimestamp = Number(left.timestamp || 0); const rightTimestamp = Number(right.timestamp || 0); if (rightTimestamp !== leftTimestamp) { return rightTimestamp - leftTimestamp; } return right.__order - left.__order; }) .map(({ __order: _order, ...transaction }) => transaction); } function renderHistoryTransactions(transactions) { clearHistoryRows(); if (!historyTransactionsBody || !historyCards) { return; } transactions.forEach((transaction) => { const type = recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"); const asset = recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()); const counterparty = recentTransactionField(transaction, ["counterparty", "to", "from", "address"], shortenAddress(transaction.txid)); const status = recentTransactionField(transaction, ["status"], "Pending"); const amount = recentTransactionField(transaction, ["amount", "value", "display_amount"], ""); const fee = recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--"); const txid = recentTransactionField(transaction, ["txid"], ""); const block = historyBlockLabel(recentTransactionField(transaction, ["block_height", "blockHeight", "height"], "0")); const date = formatHistoryDate(transaction.timestamp); const statusClass = historyStatusClass(status); const shortTxid = shortenAddress(txid); const row = document.createElement("tr"); row.innerHTML = ` `; row.children[0].textContent = date; row.children[1].textContent = type; row.children[2].textContent = asset; row.children[3].textContent = counterparty; row.querySelector(".table-status").textContent = status; row.children[5].textContent = amount; row.children[6].textContent = fee; row.querySelector("[data-copy-history-hash]").textContent = shortTxid; row.querySelector("[data-copy-history-hash]").dataset.hash = txid; row.children[8].textContent = block; historyTransactionsBody.appendChild(row); const card = document.createElement("article"); card.className = "history-card"; card.innerHTML = `
`; card.querySelector("strong").textContent = `${type} · ${asset}`; card.querySelector(".table-status").textContent = status; card.querySelector("b").textContent = amount || "--"; card.querySelector("span:not(.table-status)").textContent = `${counterparty} · Block ${block} · ${date}`; card.querySelector("[data-copy-history-hash]").textContent = shortTxid; card.querySelector("[data-copy-history-hash]").dataset.hash = txid; card.querySelector("strong").textContent = `${type} - ${asset}`; card.querySelector("span:not(.table-status)").textContent = `${counterparty} - Fee ${fee} - Block ${block} - ${date}`; historyCards.appendChild(card); }); } async function refreshHistoryFromCache() { if (!walletLoaded || currentView !== "history") { return; } if (historyRefreshInFlight) { historyRefreshPending = true; return; } const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { clearHistoryRows(); setHistoryState("Transaction history requires the desktop app build."); return; } const readId = ++historyRefreshReadId; if (!registrationRegistered) { clearHistoryRows(); setHistoryFooter(0, 0); setHistoryState("Address registration required before transactions can appear."); return; } historyRefreshInFlight = true; historyRefreshPending = false; try { const walletToken = activeWalletCacheToken(); const index = await readTransactionIndexObject(walletToken); if (readId !== historyRefreshReadId || currentView !== "history") { return; } const orderedTxids = normalizeOrderedTxids(index); const searchTerm = normalizedHistorySearchTerm(historySearch?.value || ""); const matchedTxids = historyTxidsForSearch(index, orderedTxids, searchTerm); const totalCount = orderedTxids.length; const resultCount = matchedTxids.length; const totalPages = Math.max(1, Math.ceil(resultCount / HISTORY_PAGE_SIZE)); historyCurrentPage = Math.min(Math.max(1, historyCurrentPage), totalPages); const newestOffset = (historyCurrentPage - 1) * HISTORY_PAGE_SIZE; const pageEnd = Math.max(0, resultCount - newestOffset); const pageStart = Math.max(0, pageEnd - HISTORY_PAGE_SIZE); const visibleTxids = matchedTxids.slice(pageStart, pageEnd); const visibleCount = visibleTxids.length; const visibleStart = visibleTxids[0] || ""; const visibleEnd = visibleTxids[visibleTxids.length - 1] || ""; const signature = `${searchTerm}:${totalCount}:${resultCount}:${historyCurrentPage}:${visibleStart}:${visibleEnd}:${index.updated_at || ""}`; if (signature === historyLastRenderSignature) { setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset); return; } const cache = await readTransactionCacheRecords(index, visibleTxids, walletToken); if (readId !== historyRefreshReadId || currentView !== "history") { return; } const broadcastNode = activeBroadcastNode(); if (broadcastNode) { for (const txid of visibleTxids) { const record = cache.transactions?.[txid]; if (!cachedRecordNeedsRefresh(record)) { continue; } const repaired = await hydrateLinkedTransactionsForRecord( txid, record, invoke, broadcastNode, cache, () => readId === historyRefreshReadId && currentView === "history" && walletLoaded, 0 ); if (readId !== historyRefreshReadId || currentView !== "history") { return; } cache.transactions[txid] = repaired; const fileName = index.txid_to_file?.[txid]; if (fileName) { await writeTransactionRecordFile(fileName, repaired, walletToken); } } } const transactions = normalizeHistoryTransactions(cache); renderHistoryTransactions(transactions); historyLastRenderSignature = signature; setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset); setHistoryState(transactions.length ? "" : (searchTerm ? "No transactions match this search." : "No transaction history has synced yet.")); } catch (error) { if (readId !== historyRefreshReadId || currentView !== "history") { return; } clearHistoryRows(); setHistoryState(String(error) || "Unable to read local transaction history."); } finally { historyRefreshInFlight = false; if (historyRefreshPending && walletLoaded && currentView === "history") { historyRefreshPending = false; refreshHistoryFromCache(); } } } function startHistoryRefresh() { if (historyRefreshTimer || currentView !== "history") { return; } historyCurrentPage = Math.max(1, historyCurrentPage); refreshHistoryFromCache(); historyRefreshTimer = setInterval(refreshHistoryFromCache, HISTORY_REFRESH_MS); } function stopHistoryRefresh() { historyRefreshReadId += 1; historyRefreshInFlight = false; historyRefreshPending = false; historyCurrentPage = 1; historyLastRenderSignature = ""; if (historySearchDebounceTimer) { clearTimeout(historySearchDebounceTimer); historySearchDebounceTimer = null; } setHistoryFooter(0, 0); if (historyRefreshTimer) { clearInterval(historyRefreshTimer); historyRefreshTimer = null; } } function updateHistoryRefreshState() { if (walletLoaded && currentView === "history") { startHistoryRefresh(); return; } stopHistoryRefresh(); } if (historySearch) { historySearch.addEventListener("input", () => { historyCurrentPage = 1; historyLastRenderSignature = ""; if (historySearchDebounceTimer) { clearTimeout(historySearchDebounceTimer); } historySearchDebounceTimer = setTimeout(() => { historySearchDebounceTimer = null; refreshHistoryFromCache(); }, 180); }); } historyExportOpen?.addEventListener("click", () => toggleHistoryExportModal(true)); historyExportClose?.addEventListener("click", () => toggleHistoryExportModal(false)); historyExportModal?.addEventListener("click", (event) => { if (event.target === historyExportModal) { toggleHistoryExportModal(false); } }); historyExportFormatButtons.forEach((button) => { button.addEventListener("click", () => exportFullHistory(button.dataset.historyExportFormat)); }); document.addEventListener("click", async (event) => { const pageButton = event.target.closest("[data-history-page]"); if (pageButton) { const totalPages = Math.max(1, Number(historyPageInput?.max || 1)); const action = pageButton.dataset.historyPage; if (action === "first") { historyCurrentPage = 1; } else if (action === "previous") { historyCurrentPage = Math.max(1, historyCurrentPage - 1); } else if (action === "next") { historyCurrentPage = Math.min(totalPages, historyCurrentPage + 1); } else if (action === "last") { historyCurrentPage = totalPages; } historyLastRenderSignature = ""; await refreshHistoryFromCache(); return; } const button = event.target.closest("[data-copy-history-hash]"); if (!button) { return; } const hash = button.dataset.hash || button.textContent; if (hash) { await writeClipboardText(hash); } const original = button.textContent; button.textContent = "Copied"; setTimeout(() => { button.textContent = original; }, 1200); }); historyPageInput?.addEventListener("change", async () => { const totalPages = Math.max(1, Number(historyPageInput.max || 1)); const requestedPage = Number.parseInt(historyPageInput.value, 10); historyCurrentPage = Number.isFinite(requestedPage) ? Math.min(Math.max(1, requestedPage), totalPages) : 1; historyLastRenderSignature = ""; await refreshHistoryFromCache(); }); historyPageInput?.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); historyPageInput.blur(); } });