// Shared DOM helpers used by all views. const { DEBUG } = require("../../shared/constants"); const { formatUsd, getPrice, getAddressValueUsd, } = require("../../shared/prices"); const { state, saveState, currentNetwork } = require("../../shared/state"); // When views are added, removed, or transitions between them change, // update the view-navigation documentation in README.md to match. const VIEWS = [ "welcome", "add-wallet", "main", "address", "address-token", "send", "confirm-tx", "wait-tx", "success-tx", "error-tx", "receive", "add-token", "settings", "delete-wallet-confirm", "settings-addtoken", "transaction", "approve-site", "approve-tx", "approve-sign", "export-privkey", ]; function $(id) { return document.getElementById(id); } function showError(id, msg) { const el = $(id); el.textContent = msg; el.style.visibility = "visible"; } function hideError(id) { const el = $(id); el.textContent = ""; el.style.visibility = "hidden"; } function showView(name) { for (const v of VIEWS) { const el = document.getElementById(`view-${v}`); if (el) { el.classList.toggle("hidden", v !== name); } } clearFlash(); state.currentView = name; saveState(); const net = currentNetwork(); if (DEBUG || net.isTestnet) { const banner = document.getElementById("debug-banner"); if (banner) { if (DEBUG && net.isTestnet) { banner.textContent = "DEBUG / INSECURE [TESTNET] (" + name + ")"; } else if (net.isTestnet) { banner.textContent = "[TESTNET]"; } else { banner.textContent = "DEBUG / INSECURE (" + name + ")"; } } } } // Callback to re-render the main/home view when navigating back to it. // Set once by index.js via setRenderMain(). let _renderMain = null; function setRenderMain(fn) { _renderMain = fn; } // Push the current view onto the navigation stack so goBack() can // return to it. Call this before any forward navigation. function pushCurrentView() { if (state.currentView) { state.viewStack.push(state.currentView); } } // Pop the navigation stack and show the previous view. If the stack // is empty, fall back to the main (home) view. function goBack() { let target; if (state.viewStack.length > 0) { target = state.viewStack.pop(); } else { target = "main"; } if (target === "main" && _renderMain) { _renderMain(); } showView(target); } // Clear the entire navigation stack (used when resetting to root, // e.g. after adding or deleting a wallet). function clearViewStack() { state.viewStack = []; } let flashTimer = null; function clearFlash() { if (flashTimer) { clearTimeout(flashTimer); flashTimer = null; } $("flash-msg").textContent = ""; } function showFlash(msg, duration = 2000) { clearFlash(); $("flash-msg").textContent = msg; flashTimer = setTimeout(() => { $("flash-msg").textContent = ""; flashTimer = null; }, duration); } function balanceLine(symbol, amount, price, tokenId) { const qty = amount.toFixed(4); const usd = price ? formatUsd(amount * price) || " " : " "; const tokenAttr = tokenId ? ` data-token="${tokenId}"` : ""; const clickClass = tokenId ? " cursor-pointer hover:bg-hover balance-row" : ""; return ( `
` + `` + `${symbol}` + `${qty}` + `` + `${usd}` + `
` ); } function balanceLinesForAddress(addr, trackedTokens, showZero) { let html = balanceLine( "ETH", parseFloat(addr.balance || "0"), getPrice("ETH"), "ETH", ); const seen = new Set(); for (const t of addr.tokenBalances || []) { const bal = parseFloat(t.balance || "0"); if (bal === 0 && !showZero) continue; html += balanceLine( t.symbol, bal, getPrice(t.symbol), t.address.toLowerCase(), ); seen.add(t.address.toLowerCase()); } if (showZero && trackedTokens) { for (const t of trackedTokens) { if (seen.has(t.address.toLowerCase())) continue; html += balanceLine( t.symbol, 0, getPrice(t.symbol), t.address.toLowerCase(), ); } } return html; } // Truncate the middle of a string, replacing removed characters with "…". // Safety: refuses to truncate more than 10 characters, which is the maximum // that still prevents address spoofing attacks (see Display Consistency in // README). Callers that need to display less should use a different UI // approach rather than silently making addresses insecure. function truncateMiddle(str, maxLen) { if (str.length <= maxLen) return str; const removed = str.length - maxLen + 1; // +1 for the ellipsis char if (removed > 10) { maxLen = str.length - 10 + 1; } if (maxLen >= str.length) return str; const half = Math.floor((maxLen - 1) / 2); return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half)); } // 16 colors evenly spaced around the hue wheel (22.5° apart), // all at HSL saturation 70%, lightness 50% for uniform vibrancy. const ADDRESS_COLORS = [ "#d92626", "#d96926", "#d9ac26", "#c2d926", "#80d926", "#3dd926", "#26d953", "#26d996", "#26d9d9", "#2696d9", "#2653d9", "#3d26d9", "#8026d9", "#c226d9", "#d926ac", "#d92669", ]; function addressColor(address) { const idx = parseInt(address.slice(2, 6), 16) % 16; return ADDRESS_COLORS[idx]; } function addressDotHtml(address) { const color = addressColor(address); return ``; } function escapeHtml(s) { const div = document.createElement("div"); div.textContent = s; return div.innerHTML; } // Look up an address across all wallets and return its title // (e.g. "Address 1.2") or null if it's not one of ours. function addressTitle(address, wallets) { const lower = address.toLowerCase(); for (let wi = 0; wi < wallets.length; wi++) { const addrs = wallets[wi].addresses; for (let ai = 0; ai < addrs.length; ai++) { if (addrs[ai].address.toLowerCase() === lower) { return wallets[wi].name + " \u2014 Address " + (ai + 1); } } } return null; } // Render an address with color dot, optional ENS name, optional title, // and optional truncation. Title and ENS are shown as bold labels above // the full address. // Delegates to renderAddressHtml for consistent output. function formatAddressHtml(address, ensName, maxLen, title) { return renderAddressHtml(address, { title, ensName, maxLen }); } function isoDate(timestamp) { const d = new Date(timestamp * 1000); const pad = (n) => String(n).padStart(2, "0"); if (state.utcTimestamps) { return ( d.getUTCFullYear() + "-" + pad(d.getUTCMonth() + 1) + "-" + pad(d.getUTCDate()) + "T" + pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()) + ":" + pad(d.getUTCSeconds()) + "Z" ); } const offsetMin = -d.getTimezoneOffset(); const sign = offsetMin >= 0 ? "+" : "-"; const absOff = Math.abs(offsetMin); const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60); return ( d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + "T" + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()) + tzStr ); } 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"; } // Shared external-link icon SVG used across all views. const EXT_ICON = `` + `` + `` + `` + ``; function etherscanAddressUrl(address) { return `${currentNetwork().explorerUrl}/address/${address}`; } function etherscanLinkHtml(url) { return ( `${EXT_ICON}` ); } // Render a copyable text span with dashed underline affordance. // The caller must attach click handlers via attachCopyHandlers() or // manually wire up [data-copy] elements after inserting the HTML. function copyableHtml(text, extraClass) { const cls = "underline decoration-dashed cursor-pointer" + (extraClass ? " " + extraClass : ""); return `${escapeHtml(text)}`; } // Attach click-to-copy handlers to all [data-copy] elements within // a container. Safe to call multiple times on the same container. function attachCopyHandlers(container) { const root = typeof container === "string" ? document.getElementById(container) : container; if (!root) return; root.querySelectorAll("[data-copy]").forEach((el) => { el.onclick = () => { navigator.clipboard.writeText(el.dataset.copy); showFlash("Copied!"); flashCopyFeedback(el); }; }); } // Unified address rendering. // // Produces consistent HTML for any Ethereum address: // • Color dot // • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address // • Optional ENS name shown bold above address // • Full address (or truncated via maxLen) with dashed-underline click-to-copy // • Etherscan external link icon // // Options object: // title — wallet title string (from addressTitle) // ensName — ENS name string // maxLen — if set, truncate address display (min 32 chars enforced) // noLink — if true, omit etherscan link // // After inserting the returned HTML into the DOM, call // attachCopyHandlers() on the parent to wire up click-to-copy. function renderAddressHtml(address, opts) { const { title, ensName, maxLen, noLink } = opts || {}; const dot = addressDotHtml(address); const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address; const link = etherscanAddressUrl(address); const extLink = noLink ? "" : etherscanLinkHtml(link); let html = ""; if (title) { html += `
${dot}${escapeHtml(title)}
`; } if (ensName) { html += `
${title ? "" : dot}${escapeHtml(ensName)}
`; } if (title || ensName) { html += `
${copyableHtml(displayAddr, "break-all")}${extLink}
`; } else { html += `
${dot}${copyableHtml(displayAddr, "break-all")}${extLink}
`; } return html; } function flashCopyFeedback(el) { if (!el) return; el.classList.remove("copy-flash-fade"); el.classList.add("copy-flash-active"); setTimeout(() => { el.classList.remove("copy-flash-active"); el.classList.add("copy-flash-fade"); setTimeout(() => { el.classList.remove("copy-flash-fade"); }, 275); }, 75); } module.exports = { $, showError, hideError, showView, setRenderMain, pushCurrentView, goBack, clearViewStack, showFlash, flashCopyFeedback, balanceLine, balanceLinesForAddress, addressColor, addressDotHtml, escapeHtml, addressTitle, formatAddressHtml, renderAddressHtml, copyableHtml, attachCopyHandlers, etherscanAddressUrl, etherscanLinkHtml, EXT_ICON, truncateMiddle, isoDate, timeAgo, };