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 || {}; } }