From 2467dfd09cb9983a45c0abaa3c76af21da8038bc Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 27 Feb 2026 12:16:33 +0700 Subject: [PATCH] Centralize view state into app ctx with viewData persistence Creates a centralized transactionDetail.js view module, replacing the duplicated showTxDetail/copyableHtml/blockieHtml/txDetailAddressHtml code that was in both addressDetail.js and addressToken.js (~120 lines removed). Transaction data is stored in state.viewData and persisted, so the transaction detail view survives popup close/reopen. Adds viewData to persisted state. Each view that needs data for restore stores it in state.viewData before rendering. The ctx object now has showTransactionDetail() alongside all other show methods. Restorable views expanded to include: transaction (via viewData.tx), success-tx (via viewData.hash/blockNumber), error-tx (via viewData.message). txStatus.js split into show (sets data) + render (reads data) for each screen, enabling restore. Non-restorable views (send, confirm-tx, wait-tx, add-wallet, import-key, add-token) fall back to the nearest parent since they involve active form state or network polling. --- src/popup/index.js | 59 ++++++++--- src/popup/views/addressDetail.js | 85 +-------------- src/popup/views/addressToken.js | 80 ++------------ src/popup/views/transactionDetail.js | 152 +++++++++++++++++++++++++++ src/popup/views/txStatus.js | 53 +++++++--- src/shared/state.js | 3 + 6 files changed, 254 insertions(+), 178 deletions(-) create mode 100644 src/popup/views/transactionDetail.js diff --git a/src/popup/index.js b/src/popup/index.js index c2e9069..df17fab 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -16,6 +16,7 @@ const addressToken = require("./views/addressToken"); const send = require("./views/send"); const confirmTx = require("./views/confirmTx"); const txStatus = require("./views/txStatus"); +const transactionDetail = require("./views/transactionDetail"); const receive = require("./views/receive"); const addToken = require("./views/addToken"); const settings = require("./views/settings"); @@ -53,6 +54,7 @@ const ctx = { showAddTokenView: () => addToken.show(), showConfirmTx: (txInfo) => confirmTx.show(txInfo), showReceive: () => receive.show(), + showTransactionDetail: (tx) => transactionDetail.show(tx), }; // Views that can be fully re-rendered from persisted state. @@ -63,26 +65,37 @@ const RESTORABLE_VIEWS = new Set([ "address-token", "receive", "settings", + "transaction", + "success-tx", + "error-tx", ]); +function needsAddress(view) { + return ( + view === "address" || + view === "address-token" || + view === "receive" || + view === "transaction" + ); +} + +function hasValidAddress() { + return ( + state.selectedWallet !== null && + state.selectedAddress !== null && + state.wallets[state.selectedWallet] && + state.wallets[state.selectedWallet].addresses[state.selectedAddress] + ); +} + function restoreView() { const view = state.currentView; if (!view || !RESTORABLE_VIEWS.has(view)) { return fallbackView(); } - // Validate that selectedWallet/selectedAddress still point to valid data - if (view === "address" || view === "address-token" || view === "receive") { - if ( - state.selectedWallet === null || - state.selectedAddress === null || - !state.wallets[state.selectedWallet] || - !state.wallets[state.selectedWallet].addresses[ - state.selectedAddress - ] - ) { - return fallbackView(); - } + if (needsAddress(view) && !hasValidAddress()) { + return fallbackView(); } if (view === "address-token" && !state.selectedToken) { @@ -102,6 +115,27 @@ function restoreView() { case "settings": settings.show(); break; + case "transaction": + if (state.viewData && state.viewData.tx) { + transactionDetail.render(); + } else { + fallbackView(); + } + break; + case "success-tx": + if (state.viewData && state.viewData.hash) { + txStatus.renderSuccess(); + } else { + fallbackView(); + } + break; + case "error-tx": + if (state.viewData && state.viewData.message) { + txStatus.renderError(); + } else { + fallbackView(); + } + break; default: fallbackView(); break; @@ -169,6 +203,7 @@ async function init() { send.init(ctx); confirmTx.init(ctx); txStatus.init(ctx); + transactionDetail.init(ctx); receive.init(ctx); addToken.init(ctx); settings.init(ctx); diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index b3de59f..e1c20cf 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -5,7 +5,6 @@ const { balanceLinesForAddress, addressDotHtml, escapeHtml, - formatAddressHtml, truncateMiddle, } = require("./helpers"); const { state, currentAddress, saveState } = require("../../shared/state"); @@ -32,10 +31,6 @@ function etherscanAddressLink(address) { return `https://etherscan.io/address/${address}`; } -function etherscanTxLink(hash) { - return `https://etherscan.io/tx/${hash}`; -} - function show() { state.selectedToken = null; const wallet = state.wallets[state.selectedWallet]; @@ -211,77 +206,15 @@ function renderTransactions(txs) { list.querySelectorAll(".tx-row").forEach((row) => { row.addEventListener("click", () => { const idx = parseInt(row.dataset.tx, 10); - showTxDetail(loadedTxs[idx]); + const tx = loadedTxs[idx]; + const counterparty = tx.direction === "sent" ? tx.to : tx.from; + tx.fromEns = ensNameMap.get(tx.from) || null; + tx.toEns = ensNameMap.get(tx.to) || null; + ctx.showTransactionDetail(tx); }); }); } -function copyableHtml(text, extraClass) { - const cls = - "underline decoration-dashed cursor-pointer" + - (extraClass ? " " + extraClass : ""); - return `${escapeHtml(text)}`; -} - -function blockieHtml(address) { - const src = makeBlockie(address); - return ``; -} - -function txDetailAddressHtml(address) { - const ensName = ensNameMap.get(address) || null; - const blockie = blockieHtml(address); - const dot = addressDotHtml(address); - const link = etherscanAddressLink(address); - const extLink = `${EXT_ICON}`; - let html = `
${blockie}
`; - if (ensName) { - html += - `
${dot}` + - copyableHtml(ensName, "") + - extLink + - `
` + - `
` + - copyableHtml(address, "break-all") + - `
`; - } else { - html += - `
${dot}` + - copyableHtml(address, "break-all") + - extLink + - `
`; - } - return html; -} - -function txDetailHashHtml(hash) { - const link = etherscanTxLink(hash); - const extLink = `${EXT_ICON}`; - return copyableHtml(hash, "break-all") + extLink; -} - -function showTxDetail(tx) { - $("tx-detail-hash").innerHTML = txDetailHashHtml(tx.hash); - $("tx-detail-from").innerHTML = txDetailAddressHtml(tx.from); - $("tx-detail-to").innerHTML = txDetailAddressHtml(tx.to); - $("tx-detail-value").textContent = tx.value + " " + tx.symbol; - $("tx-detail-time").textContent = - isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; - $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; - showView("transaction"); - - // Attach copy handlers - document - .getElementById("view-transaction") - .querySelectorAll("[data-copy]") - .forEach((el) => { - el.onclick = () => { - navigator.clipboard.writeText(el.dataset.copy); - showFlash("Copied!"); - }; - }); -} - function init(_ctx) { ctx = _ctx; $("address-full").addEventListener("click", () => { @@ -319,14 +252,6 @@ function init(_ctx) { }); $("btn-add-token").addEventListener("click", ctx.showAddTokenView); - - $("btn-tx-back").addEventListener("click", () => { - if (state.selectedToken) { - ctx.showAddressToken(); - } else { - show(); - } - }); } module.exports = { init, show }; diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 855bb88..43e4161 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -25,6 +25,8 @@ const { updateSendBalance, renderSendTokenSelect } = require("./send"); const { log } = require("../../shared/log"); const makeBlockie = require("ethereum-blockies-base64"); +let ctx; + const EXT_ICON = `` + `` + @@ -36,10 +38,6 @@ function etherscanAddressLink(address) { return `https://etherscan.io/address/${address}`; } -function etherscanTxLink(hash) { - return `https://etherscan.io/tx/${hash}`; -} - function isoDate(timestamp) { const d = new Date(timestamp * 1000); const pad = (n) => String(n).padStart(2, "0"); @@ -233,78 +231,16 @@ function renderTransactions(txs) { list.querySelectorAll(".tx-row").forEach((row) => { row.addEventListener("click", () => { const idx = parseInt(row.dataset.tx, 10); - showTxDetail(loadedTxs[idx]); + const tx = loadedTxs[idx]; + tx.fromEns = ensNameMap.get(tx.from) || null; + tx.toEns = ensNameMap.get(tx.to) || null; + ctx.showTransactionDetail(tx); }); }); } -function copyableHtml(text, extraClass) { - const cls = - "underline decoration-dashed cursor-pointer" + - (extraClass ? " " + extraClass : ""); - return `${escapeHtml(text)}`; -} - -function blockieHtml(address) { - const src = makeBlockie(address); - return ``; -} - -function txDetailAddressHtml(address) { - const ensName = ensNameMap.get(address) || null; - const blockie = blockieHtml(address); - const dot = addressDotHtml(address); - const link = etherscanAddressLink(address); - const extLink = `${EXT_ICON}`; - let html = `
${blockie}
`; - if (ensName) { - html += - `
${dot}` + - copyableHtml(ensName, "") + - extLink + - `
` + - `
` + - copyableHtml(address, "break-all") + - `
`; - } else { - html += - `
${dot}` + - copyableHtml(address, "break-all") + - extLink + - `
`; - } - return html; -} - -function txDetailHashHtml(hash) { - const link = etherscanTxLink(hash); - const extLink = `${EXT_ICON}`; - return copyableHtml(hash, "break-all") + extLink; -} - -function showTxDetail(tx) { - $("tx-detail-hash").innerHTML = txDetailHashHtml(tx.hash); - $("tx-detail-from").innerHTML = txDetailAddressHtml(tx.from); - $("tx-detail-to").innerHTML = txDetailAddressHtml(tx.to); - $("tx-detail-value").textContent = tx.value + " " + tx.symbol; - $("tx-detail-time").textContent = - isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; - $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; - showView("transaction"); - - // Attach copy handlers - document - .getElementById("view-transaction") - .querySelectorAll("[data-copy]") - .forEach((el) => { - el.onclick = () => { - navigator.clipboard.writeText(el.dataset.copy); - showFlash("Copied!"); - }; - }); -} - -function init(ctx) { +function init(_ctx) { + ctx = _ctx; $("address-token-full").addEventListener("click", () => { const addr = $("address-token-full").dataset.full; if (addr) { diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js new file mode 100644 index 0000000..6c4fb8a --- /dev/null +++ b/src/popup/views/transactionDetail.js @@ -0,0 +1,152 @@ +// Transaction detail view — shows full details for a single transaction. +// Shared by addressDetail and addressToken via ctx.showTransactionDetail(). + +const { + $, + showView, + showFlash, + addressDotHtml, + escapeHtml, +} = require("./helpers"); +const { state } = require("../../shared/state"); +const makeBlockie = require("ethereum-blockies-base64"); + +const EXT_ICON = + `` + + `` + + `` + + `` + + ``; + +let ctx; + +function isoDate(timestamp) { + const d = new Date(timestamp * 1000); + const pad = (n) => String(n).padStart(2, "0"); + return ( + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()) + + ":" + + pad(d.getSeconds()) + ); +} + +function timeAgo(timestamp) { + const seconds = Math.floor(Date.now() / 1000 - timestamp); + if (seconds < 60) return seconds + " seconds ago"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago"; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago"; + const days = Math.floor(hours / 24); + if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago"; + const months = Math.floor(days / 30); + if (months < 12) + return months + " month" + (months !== 1 ? "s" : "") + " ago"; + const years = Math.floor(days / 365); + return years + " year" + (years !== 1 ? "s" : "") + " ago"; +} + +function copyableHtml(text, extraClass) { + const cls = + "underline decoration-dashed cursor-pointer" + + (extraClass ? " " + extraClass : ""); + return `${escapeHtml(text)}`; +} + +function blockieHtml(address) { + const src = makeBlockie(address); + return ``; +} + +function txAddressHtml(address, ensName) { + const blockie = blockieHtml(address); + const dot = addressDotHtml(address); + const link = `https://etherscan.io/address/${address}`; + const extLink = `${EXT_ICON}`; + let html = `
${blockie}
`; + if (ensName) { + html += + `
${dot}` + + copyableHtml(ensName, "") + + extLink + + `
` + + `
` + + copyableHtml(address, "break-all") + + `
`; + } else { + html += + `
${dot}` + + copyableHtml(address, "break-all") + + extLink + + `
`; + } + return html; +} + +function txHashHtml(hash) { + const link = `https://etherscan.io/tx/${hash}`; + const extLink = `${EXT_ICON}`; + return copyableHtml(hash, "break-all") + extLink; +} + +function show(tx) { + state.viewData = { + tx: { + hash: tx.hash, + from: tx.from, + to: tx.to, + value: tx.value, + symbol: tx.symbol, + timestamp: tx.timestamp, + isError: tx.isError, + fromEns: tx.fromEns || null, + toEns: tx.toEns || null, + }, + }; + render(); +} + +function render() { + const tx = state.viewData.tx; + if (!tx) return; + $("tx-detail-hash").innerHTML = txHashHtml(tx.hash); + $("tx-detail-from").innerHTML = txAddressHtml(tx.from, tx.fromEns); + $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns); + $("tx-detail-value").textContent = tx.value + " " + tx.symbol; + $("tx-detail-time").textContent = + isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; + $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; + showView("transaction"); + + document + .getElementById("view-transaction") + .querySelectorAll("[data-copy]") + .forEach((el) => { + el.onclick = () => { + navigator.clipboard.writeText(el.dataset.copy); + showFlash("Copied!"); + }; + }); +} + +function init(_ctx) { + ctx = _ctx; + $("btn-tx-back").addEventListener("click", () => { + if (state.selectedToken) { + ctx.showAddressToken(); + } else { + ctx.showAddressDetail(); + } + }); +} + +module.exports = { init, show, render }; diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index cbce68c..f94f245 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -7,7 +7,7 @@ const { addressDotHtml, escapeHtml, } = require("./helpers"); -const { state } = require("../../shared/state"); +const { state, saveState } = require("../../shared/state"); const { getProvider } = require("../../shared/balances"); const { log } = require("../../shared/log"); @@ -107,26 +107,51 @@ function showSuccess(txInfo, txHash, blockNumber) { clearTimers(); const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; - $("success-tx-summary").textContent = txInfo.amount + " " + symbol; - $("success-tx-to").innerHTML = toAddressHtml(txInfo.to); - $("success-tx-block").textContent = String(blockNumber); - $("success-tx-hash").innerHTML = txHashHtml(txHash); - attachCopyHandlers("view-success-tx"); - - showView("success-tx"); + state.viewData = { + amount: txInfo.amount, + symbol: symbol, + to: txInfo.to, + hash: txHash, + blockNumber: blockNumber, + }; + renderSuccess(); ctx.doRefreshAndRender(); } +function renderSuccess() { + const d = state.viewData; + if (!d || !d.hash) return; + $("success-tx-summary").textContent = d.amount + " " + d.symbol; + $("success-tx-to").innerHTML = toAddressHtml(d.to); + $("success-tx-block").textContent = String(d.blockNumber); + $("success-tx-hash").innerHTML = txHashHtml(d.hash); + attachCopyHandlers("view-success-tx"); + showView("success-tx"); +} + function showError(txInfo, txHash, message) { clearTimers(); const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?"; - $("error-tx-summary").textContent = txInfo.amount + " " + symbol; - $("error-tx-to").innerHTML = toAddressHtml(txInfo.to); - $("error-tx-message").textContent = message; + state.viewData = { + amount: txInfo.amount, + symbol: symbol, + to: txInfo.to, + hash: txHash || null, + message: message, + }; + renderError(); +} - if (txHash) { - $("error-tx-hash").innerHTML = txHashHtml(txHash); +function renderError() { + const d = state.viewData; + if (!d || !d.message) return; + $("error-tx-summary").textContent = d.amount + " " + d.symbol; + $("error-tx-to").innerHTML = toAddressHtml(d.to); + $("error-tx-message").textContent = d.message; + + if (d.hash) { + $("error-tx-hash").innerHTML = txHashHtml(d.hash); $("error-tx-hash-section").classList.remove("hidden"); attachCopyHandlers("view-error-tx"); } else { @@ -151,4 +176,4 @@ function init(_ctx) { $("btn-error-tx-done").addEventListener("click", navigateBack); } -module.exports = { init, showWait, showError }; +module.exports = { init, showWait, showError, renderSuccess, renderError }; diff --git a/src/shared/state.js b/src/shared/state.js index 86d2728..a98d84c 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -33,6 +33,7 @@ const state = { selectedWallet: null, selectedAddress: null, selectedToken: null, + viewData: {}, }; async function saveState() { @@ -58,6 +59,7 @@ async function saveState() { selectedWallet: state.selectedWallet, selectedAddress: state.selectedAddress, selectedToken: state.selectedToken, + viewData: state.viewData, }; await storageApi.set({ autistmask: persisted }); } @@ -114,6 +116,7 @@ async function loadState() { state.selectedAddress = saved.selectedAddress !== undefined ? saved.selectedAddress : null; state.selectedToken = saved.selectedToken || null; + state.viewData = saved.viewData || {}; } }