Contractless-GUI-Wallet/frontend/js/history_page.js

640 lines
22 KiB
JavaScript
Raw Normal View History

2026-07-01 16:01:30 +00:00
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function historyRowsToXml(rows) {
const body = rows.map((row) => ` <transaction>
<date>${xmlEscape(row.date)}</date>
<timestamp>${xmlEscape(row.timestamp)}</timestamp>
<type>${xmlEscape(row.type)}</type>
<asset>${xmlEscape(row.asset)}</asset>
<counterparty>${xmlEscape(row.counterparty)}</counterparty>
<status>${xmlEscape(row.status)}</status>
<amount>${xmlEscape(row.amount)}</amount>
<fee>${xmlEscape(row.fee)}</fee>
<txid>${xmlEscape(row.txid)}</txid>
<block>${xmlEscape(row.block ?? "")}</block>
</transaction>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<history>\n${body}\n</history>\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 = `
<td></td>
<td></td>
<td></td>
<td></td>
<td><span class="table-status ${statusClass}"></span></td>
<td></td>
<td></td>
<td><button class="hash-button" type="button" data-copy-history-hash></button></td>
<td></td>
`;
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 = `
<div><strong></strong><span class="table-status ${statusClass}"></span></div>
<b></b>
<span></span>
<button class="hash-button" type="button" data-copy-history-hash></button>
`;
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();
}
});