640 lines
22 KiB
JavaScript
640 lines
22 KiB
JavaScript
|
|
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, """)
|
||
|
|
.replace(/'/g, "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
});
|