The Settings network handler now redraws the red banner once the switch is saved, so it appears on a switch to Sepolia and goes on a switch back to mainnet while Settings is still on screen. Before, the banner changed only when the next screen came up. The Chrome browser suite switches both ways on Settings and reads the banner each time. Model: opus-5-5
488 lines
19 KiB
JavaScript
488 lines
19 KiB
JavaScript
const {
|
||
$,
|
||
showView,
|
||
updateDebugBanner,
|
||
showFlash,
|
||
escapeHtml,
|
||
displaySymbol,
|
||
flashCopyFeedback,
|
||
goBack,
|
||
pushCurrentView,
|
||
} = require("./helpers");
|
||
const { applyTheme } = require("../theme");
|
||
const {
|
||
DUST_THRESHOLD_MESSAGE,
|
||
parseDustThresholdGwei,
|
||
} = require("../dustThreshold");
|
||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||
const { restorableStack } = require("../../shared/persistedState");
|
||
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
|
||
const {
|
||
log,
|
||
debugFetch,
|
||
urlOrigin,
|
||
setRuntimeDebug,
|
||
} = require("../../shared/log");
|
||
const deleteWallet = require("./deleteWallet");
|
||
const showPhrase = require("./showPhrase");
|
||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||
const {
|
||
BUILD_VERSION,
|
||
BUILD_LICENSE,
|
||
BUILD_AUTHOR,
|
||
BUILD_COMMIT,
|
||
BUILD_DATE,
|
||
GITEA_COMMIT_URL,
|
||
} = require("../../shared/buildInfo");
|
||
|
||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||
|
||
let versionClickCount = 0;
|
||
let versionClickTimer = null;
|
||
|
||
// One row per site origin, however many addresses it appears under, each with
|
||
// an [x] that hands it to onRemove.
|
||
function renderSiteList(containerId, origins, onRemove) {
|
||
const container = $(containerId);
|
||
const unique = [...new Set(origins)];
|
||
if (unique.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
unique.forEach((origin) => {
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
// An origin the URL parser produced cannot carry a delimiter, so
|
||
// this is escaped for the rule rather than for a known hole — the
|
||
// rule being that nothing reaches innerHTML unescaped.
|
||
html += `<span>${escapeHtml(origin)}</span>`;
|
||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
||
});
|
||
}
|
||
|
||
// Drop a site origin from a remembered site list under every address.
|
||
function forgetOrigin(siteMap, origin) {
|
||
for (const addr of Object.keys(siteMap)) {
|
||
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
|
||
if (siteMap[addr].length === 0) {
|
||
delete siteMap[addr];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||
// no longer allowed under any address, and the background ends its
|
||
// connections approved without "Remember" and tells its open tabs. The
|
||
// background answers once those have ended, so the lists drawn after it no
|
||
// longer show the site.
|
||
async function removeAllowedSite(origin) {
|
||
forgetOrigin(state.allowedSites, origin);
|
||
await saveState();
|
||
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
||
await renderSiteLists();
|
||
}
|
||
|
||
// Removing a denied site only forgets the refusal; it connects nothing.
|
||
async function removeDeniedSite(origin) {
|
||
forgetOrigin(state.deniedSites, origin);
|
||
await saveState();
|
||
await renderSiteLists();
|
||
}
|
||
|
||
function renderTrackedTokens() {
|
||
const container = $("settings-tracked-tokens");
|
||
if (state.trackedTokens.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
state.trackedTokens.forEach((token, idx) => {
|
||
const sym = escapeHtml(displaySymbol(token.symbol));
|
||
const label = token.name
|
||
? escapeHtml(token.name) + " (" + sym + ")"
|
||
: sym;
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
html += `<span>${label}</span>`;
|
||
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-remove-token").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
state.trackedTokens.splice(idx, 1);
|
||
await saveState();
|
||
renderTrackedTokens();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderWalletListSettings() {
|
||
const container = $("settings-wallet-list");
|
||
if (state.wallets.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">No wallets.</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
state.wallets.forEach((wallet, idx) => {
|
||
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
|
||
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
|
||
// Key and xprv wallets have no recovery phrase, so they are never
|
||
// offered the action at all.
|
||
if (walletHasRecoveryPhrase(wallet)) {
|
||
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
|
||
}
|
||
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
|
||
html += `</span>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
pushCurrentView();
|
||
deleteWallet.show(idx);
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
// No pushCurrentView() here: showPhrase.show() refuses
|
||
// non-HD wallets and pushes only when it navigates.
|
||
showPhrase.show(idx);
|
||
});
|
||
});
|
||
|
||
// Inline rename on click
|
||
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
|
||
span.addEventListener("click", () => {
|
||
const idx = parseInt(span.dataset.idx, 10);
|
||
const wallet = state.wallets[idx];
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.className =
|
||
"border border-border p-0 text-xs bg-bg text-fg w-full";
|
||
input.value = wallet.name || "Wallet " + (idx + 1);
|
||
span.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
const finish = async () => {
|
||
const val = input.value.trim();
|
||
if (val && val !== wallet.name) {
|
||
wallet.name = val;
|
||
await saveState();
|
||
}
|
||
renderWalletListSettings();
|
||
};
|
||
input.addEventListener("blur", finish);
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") input.blur();
|
||
if (e.key === "Escape") {
|
||
input.value = wallet.name || "Wallet " + (idx + 1);
|
||
input.blur();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function show() {
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
$("settings-network").value = state.networkId;
|
||
renderTrackedTokens();
|
||
renderSiteLists();
|
||
renderWalletListSettings();
|
||
|
||
// Populate About well
|
||
$("about-license").textContent = BUILD_LICENSE;
|
||
// Show only the name part of the author field (strip email)
|
||
const authorName = BUILD_AUTHOR.replace(/\s*<[^>]+>/, "");
|
||
$("about-author").textContent = authorName;
|
||
$("about-version").textContent = BUILD_VERSION;
|
||
$("about-release-date").textContent = BUILD_DATE;
|
||
$("about-commit-link").textContent = BUILD_COMMIT;
|
||
$("about-commit-link").href = GITEA_COMMIT_URL;
|
||
|
||
// Reset version click counter each time settings opens
|
||
versionClickCount = 0;
|
||
|
||
// Show debug well if debug mode is already enabled
|
||
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
||
$("settings-debug-mode").checked = state.debugMode;
|
||
|
||
showView("settings");
|
||
}
|
||
|
||
async function renderSiteLists() {
|
||
renderSiteList(
|
||
"settings-allowed-sites",
|
||
Object.values(state.allowedSites).flat(),
|
||
removeAllowedSite,
|
||
);
|
||
renderSiteList(
|
||
"settings-denied-sites",
|
||
Object.values(state.deniedSites).flat(),
|
||
removeDeniedSite,
|
||
);
|
||
// Sites allowed without "Remember" are held only in session storage, which
|
||
// the background keeps, so it is asked for them.
|
||
renderSiteList(
|
||
"settings-connected-sites",
|
||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||
removeAllowedSite,
|
||
);
|
||
}
|
||
|
||
function init(ctx) {
|
||
deleteWallet.init(ctx);
|
||
showPhrase.init();
|
||
|
||
$("btn-save-rpc").addEventListener("click", async () => {
|
||
const url = $("settings-rpc").value.trim();
|
||
if (!url) {
|
||
showFlash("Please enter an RPC URL.");
|
||
return;
|
||
}
|
||
showFlash("Testing endpoint...");
|
||
try {
|
||
const resp = await debugFetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
jsonrpc: "2.0",
|
||
id: 1,
|
||
method: "eth_chainId",
|
||
params: [],
|
||
}),
|
||
});
|
||
const json = await resp.json();
|
||
if (json.error) {
|
||
log.errorf("RPC validation error:", json.error);
|
||
showFlash("Endpoint returned an error.");
|
||
return;
|
||
}
|
||
const net = currentNetwork();
|
||
if (json.result !== net.chainId) {
|
||
showFlash("Wrong network: expected " + net.name + ".");
|
||
return;
|
||
}
|
||
} catch {
|
||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||
// src/popup/index.js.
|
||
if (ctx.pageClosed.aborted) return;
|
||
// Not the error's message: fetch puts the whole URL, password and
|
||
// key included, in the message of the error it throws for a URL
|
||
// with a user name and password or one it cannot parse.
|
||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
||
showFlash("Could not reach endpoint.");
|
||
return;
|
||
}
|
||
state.rpcUrl = url;
|
||
await saveState();
|
||
showFlash("Saved.");
|
||
});
|
||
|
||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||
const url = $("settings-blockscout").value.trim();
|
||
if (!url) {
|
||
showFlash("Please enter a Blockscout API URL.");
|
||
return;
|
||
}
|
||
showFlash("Testing endpoint...");
|
||
try {
|
||
const resp = await debugFetch(url + "/stats");
|
||
if (!resp.ok) {
|
||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||
return;
|
||
}
|
||
} catch {
|
||
if (ctx.pageClosed.aborted) return;
|
||
// Not the error's message, as for the RPC check above.
|
||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||
showFlash("Could not reach endpoint.");
|
||
return;
|
||
}
|
||
state.blockscoutUrl = url;
|
||
await saveState();
|
||
showFlash("Saved.");
|
||
});
|
||
|
||
const networkSelect = $("settings-network");
|
||
networkSelect.addEventListener("change", async () => {
|
||
const newId = networkSelect.value;
|
||
if (newId === state.networkId) return;
|
||
// The screens beneath Settings show the previous network, so Back
|
||
// finds them as it does in a reopened popup: each is rendered again
|
||
// when Back lands on it, and the stack is cut at the first one that
|
||
// cannot be rendered from saved state, such as Send or AddToken. It
|
||
// is cut at AddressToken too, unless that shows the native token: any
|
||
// other token there was picked on the previous network. Without a
|
||
// wallet the stack starts at Welcome, which that cut would drop, and
|
||
// Welcome shows nothing from a network. The cut comes before the
|
||
// switch so that the switch's save keeps it for a popup closed
|
||
// straight after.
|
||
if (state.hasWallet) {
|
||
const tokenView = state.viewStack.indexOf("address-token");
|
||
if (tokenView !== -1 && state.selectedToken !== "ETH") {
|
||
state.viewStack = state.viewStack.slice(0, tokenView);
|
||
}
|
||
state.viewStack = restorableStack(state.viewStack, "settings");
|
||
}
|
||
const net = await onChainSwitch(newId);
|
||
// Open pages are told by the background, as after a site's approved
|
||
// switch request.
|
||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||
resetRenderedViews();
|
||
// Settings itself, and the testnet banner above it, are brought up
|
||
// to date for the new network here.
|
||
markViewRendered("settings");
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
renderTrackedTokens();
|
||
updateDebugBanner(state.currentView);
|
||
showFlash("Switched to " + net.name + ".");
|
||
});
|
||
|
||
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
|
||
$("settings-show-zero-balances").addEventListener("change", async () => {
|
||
state.showZeroBalanceTokens = $("settings-show-zero-balances").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-theme").value = state.theme;
|
||
$("settings-theme").addEventListener("change", async () => {
|
||
state.theme = $("settings-theme").value;
|
||
await saveState();
|
||
applyTheme(state.theme);
|
||
});
|
||
|
||
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
|
||
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
|
||
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
|
||
$("settings-hide-low-holders").addEventListener("change", async () => {
|
||
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-fraud-contracts").checked = state.hideFraudContracts;
|
||
$("settings-hide-fraud-contracts").addEventListener("change", async () => {
|
||
state.hideFraudContracts = $("settings-hide-fraud-contracts").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-dust").checked = state.hideDustTransactions;
|
||
$("settings-hide-dust").addEventListener("change", async () => {
|
||
state.hideDustTransactions = $("settings-hide-dust").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||
$("settings-dust-threshold").addEventListener("change", async () => {
|
||
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
|
||
// Rejected input is never coerced. The field is put back to the
|
||
// stored threshold so it never shows a value the wallet is not
|
||
// using, and the message says what the field wants so the snap-back
|
||
// is explained rather than silent.
|
||
if (val === null) {
|
||
showFlash(DUST_THRESHOLD_MESSAGE);
|
||
} else {
|
||
state.dustThresholdGwei = val;
|
||
await saveState();
|
||
}
|
||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||
});
|
||
|
||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||
$("settings-utc-timestamps").addEventListener("change", async () => {
|
||
state.utcTimestamps = $("settings-utc-timestamps").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
||
|
||
$("btn-settings-add-token").addEventListener(
|
||
"click",
|
||
ctx.showSettingsAddTokenView,
|
||
);
|
||
|
||
// Bright saturated colors for easter egg flashes (clicks 6–10)
|
||
const easterEggColors = [
|
||
"#ff0055", // hot pink
|
||
"#00cc44", // vivid green
|
||
"#3366ff", // electric blue
|
||
"#ff9900", // bright orange
|
||
"#aa00ff", // vivid purple
|
||
];
|
||
|
||
// Easter egg: click version 10 times to reveal the debug well.
|
||
// Each click does a copy-flash animation. After 5 clicks, each
|
||
// additional click flashes a different bright saturated color.
|
||
$("about-version").addEventListener("click", () => {
|
||
versionClickCount++;
|
||
clearTimeout(versionClickTimer);
|
||
// Reset counter if user stops clicking for 3 seconds
|
||
versionClickTimer = setTimeout(() => {
|
||
versionClickCount = 0;
|
||
}, 3000);
|
||
|
||
const el = $("about-version");
|
||
|
||
if (versionClickCount > 5) {
|
||
// Colored flash for clicks 6–10
|
||
const colorIdx = versionClickCount - 6;
|
||
const color = easterEggColors[colorIdx % easterEggColors.length];
|
||
el.classList.remove("copy-flash-fade");
|
||
el.style.backgroundColor = color;
|
||
el.style.color = "#ffffff";
|
||
setTimeout(() => {
|
||
el.style.backgroundColor = "";
|
||
el.style.color = "";
|
||
el.classList.add("copy-flash-fade");
|
||
setTimeout(() => {
|
||
el.classList.remove("copy-flash-fade");
|
||
}, 275);
|
||
}, 75);
|
||
} else {
|
||
// Standard copy-flash for clicks 1–5
|
||
flashCopyFeedback(el);
|
||
}
|
||
|
||
if (versionClickCount >= 10) {
|
||
versionClickCount = 0;
|
||
clearTimeout(versionClickTimer);
|
||
$("settings-debug-well").classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
// Debug mode toggle — update runtime flag, persist, and re-render banner
|
||
$("settings-debug-mode").addEventListener("change", async () => {
|
||
state.debugMode = $("settings-debug-mode").checked;
|
||
setRuntimeDebug(state.debugMode);
|
||
await saveState();
|
||
updateDebugBanner(state.currentView);
|
||
});
|
||
|
||
// Sync runtime debug flag on init
|
||
setRuntimeDebug(state.debugMode);
|
||
|
||
$("btn-settings-back").addEventListener("click", () => {
|
||
goBack();
|
||
});
|
||
}
|
||
|
||
module.exports = { init, show, renderSiteLists };
|