All checks were successful
check / check (push) Successful in 25s
## Summary Fixes the view stack pop bug where pressing Back in Settings (or any view) always returned to Main instead of the previous view. Closes [issue #134](#134) ## Problem The popup UI had no navigation stack. Every back button was hardcoded to a specific destination (usually Main). The reported path: > Main → Address → Transaction → Settings (gear icon) → Back ...would go to Main instead of returning to the Transaction view. ## Solution Implemented a proper view navigation stack (like iOS) as already described in the README: - **`viewStack`** array added to persisted state — survives popup close/reopen - **`pushCurrentView()`** — pushes the current view name onto the stack before any forward navigation - **`goBack()`** — pops the stack and shows the previous view; falls back to Main if the stack is empty; re-renders the wallet list when returning to Main - **`clearViewStack()`** — resets the stack for root transitions (e.g., after adding/deleting a wallet) ### What Changed 1. **helpers.js** — Added navigation stack functions (`pushCurrentView`, `goBack`, `clearViewStack`, `setRenderMain`) 2. **state.js** — Added `viewStack` to persisted state 3. **index.js** — All `ctx.show*()` wrappers now push before navigating forward; gear button uses stack for toggle behavior 4. **All view back buttons** — Replaced hardcoded destinations with `goBack()` (settings, addressDetail, addressToken, transactionDetail, send, receive, addToken, confirmTx, addWallet, settingsAddToken, deleteWallet, export-privkey) 5. **Direct `showView()` forward navigations** — Added `pushCurrentView()` calls before `showView("send")` in addressDetail, addressToken, and home; before `showView("export-privkey")` in addressDetail; before `deleteWallet.show()` in settings 6. **Reset-to-root transitions** — `clearViewStack()` called after adding a wallet (all 3 import types), after deleting the last wallet, and after transaction completion (Done button) ### Navigation Paths Verified - **Main → Settings → Back** → returns to Main ✓ - **Main → Address → Settings → Back** → returns to Address ✓ - **Main → Address → Transaction → Settings → Back** → returns to Transaction ✓ (the reported bug) - **Main → Address → Token → Send → ConfirmTx → Back → Back → Back → Back** → unwinds correctly through each view back to Main ✓ - **Main → Address → Token → Transaction → Settings → Back** → returns to Transaction ✓ - **Settings → Add Wallet → (add) → Main** → stack cleared, fresh root ✓ - **Settings → Delete Wallet → Back** → returns to Settings ✓ - **Settings → Delete Wallet → (confirm)** → stack reset to [main], settings shown ✓ - **Address → Send → ConfirmTx → (broadcast) → SuccessTx → Done** → stack reset, returns to address context ✓ - **Popup close/reopen** → viewStack persisted, back navigation still works ✓ Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #146 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
443 lines
13 KiB
JavaScript
443 lines
13 KiB
JavaScript
// 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 (
|
|
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
|
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
|
`<span>${symbol}</span>` +
|
|
`<span>${qty}</span>` +
|
|
`</span>` +
|
|
`<span class="text-right text-muted flex-1">${usd}</span>` +
|
|
`</div>`
|
|
);
|
|
}
|
|
|
|
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 `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
|
|
}
|
|
|
|
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 =
|
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
|
`</svg></span>`;
|
|
|
|
function etherscanAddressUrl(address) {
|
|
return `${currentNetwork().explorerUrl}/address/${address}`;
|
|
}
|
|
|
|
function etherscanLinkHtml(url) {
|
|
return (
|
|
`<a href="${url}" target="_blank" rel="noopener" ` +
|
|
`class="inline-flex items-center">${EXT_ICON}</a>`
|
|
);
|
|
}
|
|
|
|
// 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 `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
|
|
}
|
|
|
|
// 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 += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
|
}
|
|
if (ensName) {
|
|
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
|
}
|
|
if (title || ensName) {
|
|
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
|
} else {
|
|
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
|
}
|
|
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,
|
|
};
|