All checks were successful
check / check (push) Successful in 33s
The dust-threshold field was the only validated input in Settings that rejected without saying anything: the value silently changed back to the stored one with no explanation. It now flashes "Please enter a whole number of gwei, zero or greater." alongside the existing resync, matching the idiom the RPC URL field already uses. The parse moves to its own module and accepts plain decimal digits only, zero or greater. Hex and exponent notation are refused rather than accepted: Number() reads "0x10" as 16 and "1e3" as 1000, neither of which the previous parseInt produced, and storing a number the user did not type is the same silent substitution this change exists to remove. The message must fit one line of the reserved flash area -- a wrapped message pushes the settings view down, which the No Layout Shift policy forbids. That is pinned by an end-to-end test measuring the rendered line height and the position of the elements below it, in a single round trip because the flash clears after two seconds.
429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
const {
|
||
$,
|
||
showView,
|
||
updateDebugBanner,
|
||
showFlash,
|
||
escapeHtml,
|
||
flashCopyFeedback,
|
||
goBack,
|
||
pushCurrentView,
|
||
} = require("./helpers");
|
||
const { applyTheme } = require("../theme");
|
||
const {
|
||
DUST_THRESHOLD_MESSAGE,
|
||
parseDustThresholdGwei,
|
||
} = require("../dustThreshold");
|
||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||
const { log, debugFetch, 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 runtime =
|
||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||
|
||
let versionClickCount = 0;
|
||
let versionClickTimer = null;
|
||
|
||
function renderSiteList(containerId, siteMap, stateKey) {
|
||
const container = $(containerId);
|
||
const hostnames = [...new Set(Object.values(siteMap).flat())];
|
||
if (hostnames.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
hostnames.forEach((hostname) => {
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
html += `<span>${hostname}</span>`;
|
||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${stateKey}" data-hostname="${hostname}">[x]</button>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const key = btn.dataset.key;
|
||
const host = btn.dataset.hostname;
|
||
for (const addr of Object.keys(state[key])) {
|
||
state[key][addr] = state[key][addr].filter((h) => h !== host);
|
||
if (state[key][addr].length === 0) {
|
||
delete state[key][addr];
|
||
}
|
||
}
|
||
await saveState();
|
||
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
|
||
renderSiteList(containerId, state[key], key);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 label = token.name
|
||
? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
|
||
: escapeHtml(token.symbol);
|
||
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;
|
||
const networkSelect = $("settings-network");
|
||
if (networkSelect) {
|
||
networkSelect.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
|
||
const debugWell = $("settings-debug-well");
|
||
if (state.debugMode) {
|
||
debugWell.style.display = "";
|
||
} else {
|
||
debugWell.style.display = "none";
|
||
}
|
||
$("settings-debug-mode").checked = state.debugMode;
|
||
|
||
showView("settings");
|
||
}
|
||
|
||
function renderSiteLists() {
|
||
renderSiteList(
|
||
"settings-allowed-sites",
|
||
state.allowedSites,
|
||
"allowedSites",
|
||
);
|
||
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
|
||
}
|
||
|
||
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 error: " + json.error.message);
|
||
return;
|
||
}
|
||
const net = currentNetwork();
|
||
if (json.result !== net.chainId) {
|
||
showFlash(
|
||
"Wrong network (expected " +
|
||
net.name +
|
||
", got chain " +
|
||
json.result +
|
||
").",
|
||
);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
log.errorf("RPC validation fetch failed:", e.message);
|
||
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 (e) {
|
||
log.errorf("Blockscout validation failed:", e.message);
|
||
showFlash("Could not reach endpoint.");
|
||
return;
|
||
}
|
||
state.blockscoutUrl = url;
|
||
await saveState();
|
||
showFlash("Saved.");
|
||
});
|
||
|
||
const networkSelect = $("settings-network");
|
||
if (networkSelect) {
|
||
networkSelect.addEventListener("change", async () => {
|
||
const newId = networkSelect.value;
|
||
const net = await onChainSwitch(newId);
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
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").style.display = "";
|
||
}
|
||
});
|
||
|
||
// 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 };
|