Files
AutistMask/src/popup/views/helpers.js
clawbot a22f33d511
All checks were successful
check / check (push) Successful in 25s
fix: implement proper view navigation stack (#146)
## 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>
2026-03-02 00:15:01 +01:00

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) || "&nbsp;" : "&nbsp;";
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,
};