When the background saves a network switch after the user approves a site's request, or another popup page saves one, an open popup now notices the stored record change and takes the new network from it, holding the lock, inside a save. It then leaves the page as a switch in Settings does: the stack is cut, every screen counts as not yet rendered, and the screen on top is drawn again. The cut and the redraw are now two functions in the Settings view that both paths call. A record already on the popup's network, such as its own Settings switch, changes nothing. The popup never switches the network itself. Model: opus-5-5
517 lines
19 KiB
JavaScript
517 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 ctx;
|
||
|
||
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,
|
||
);
|
||
}
|
||
|
||
// A network switch, made by the user here or saved by the background once the
|
||
// user approved a site's request (src/popup/index.js), leaves every screen on
|
||
// the page showing the previous network. These two leave the page as README's
|
||
// Screen Map describes, the first before the switch is saved, so that save
|
||
// keeps the cut for a popup closed straight after, the second once `state`
|
||
// holds the new network.
|
||
//
|
||
// The stack is cut at the first screen that cannot be rendered from saved
|
||
// state, such as Send or AddToken, and at AddressToken unless it shows the
|
||
// native token: any other token there was picked on the previous network, so
|
||
// it is no longer selected either. Without a wallet the stack starts at
|
||
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
|
||
function cutViewStack() {
|
||
if (!state.hasWallet) return;
|
||
if (state.selectedToken !== "ETH") {
|
||
const tokenView = state.viewStack.indexOf("address-token");
|
||
if (tokenView !== -1) {
|
||
state.viewStack = state.viewStack.slice(0, tokenView);
|
||
}
|
||
state.selectedToken = null;
|
||
}
|
||
state.viewStack = restorableStack(state.viewStack, state.currentView);
|
||
}
|
||
|
||
// Every screen counts as not yet rendered, so "Back" renders the one it lands
|
||
// on for the new network. Settings on top is brought up to date in place;
|
||
// any other screen is drawn again as a reopened popup draws it, which is Home
|
||
// for one that cannot be rendered from saved state. Without a wallet any other
|
||
// screen is Welcome or AddWallet, which show nothing from a network.
|
||
function showNewNetwork() {
|
||
resetRenderedViews();
|
||
if (state.currentView === "settings") {
|
||
markViewRendered("settings");
|
||
$("settings-network").value = state.networkId;
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
renderTrackedTokens();
|
||
} else if (state.hasWallet) {
|
||
ctx.restoreView();
|
||
}
|
||
updateDebugBanner(state.currentView);
|
||
}
|
||
|
||
function init(_ctx) {
|
||
ctx = _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;
|
||
cutViewStack();
|
||
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 });
|
||
showNewNetwork();
|
||
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,
|
||
cutViewStack,
|
||
showNewNetwork,
|
||
};
|