A network switch now puts the outgoing network's tracked tokens away in a new persisted field, networkTrackedTokens, and brings back the incoming network's list, or an empty one, the way endpoints are kept per network. trackedTokens stays the active network's list, so its readers are unchanged, and the background's switch goes through the same function. The two add-token screens offer their common-token picks, which are mainnet contracts, on Ethereum Mainnet only. After a switch in Settings, Back finds the screens beneath as a reopened popup does: it renders the one it lands on for the new network, and the stack is cut at the first screen that cannot be rendered from saved state. Model: opus-5-5
246 lines
9.6 KiB
JavaScript
246 lines
9.6 KiB
JavaScript
// Rendering a view the popup lands on without having navigated to it
|
|
// forward: on restore, and on Back. In both cases the view may never have
|
|
// been rendered in this page load — a reopened popup renders only the
|
|
// wallet list and the view it restores onto, so every other view is still
|
|
// the blank static template from index.html — so unhiding it is not enough.
|
|
//
|
|
// Forward navigation renders as it goes and must NOT come through here:
|
|
// rendering a second time would re-fetch and clobber whatever the view has
|
|
// in flight.
|
|
//
|
|
// The view modules are injected and nothing here touches the DOM, so the
|
|
// dispatch and its data guards can be tested directly; src/popup/index.js
|
|
// cannot be required outside a browser.
|
|
|
|
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
|
|
|
// The views this page load has rendered.
|
|
//
|
|
// The Back path cannot otherwise tell its two cases apart. A view the popup
|
|
// never rendered is still the blank template from index.html and has to be
|
|
// rendered; a view already on the page must NOT be rendered again, because
|
|
// a second render re-fetches and overwrites whatever the user has typed
|
|
// into it and not yet saved.
|
|
//
|
|
// Registration is showView() in views/helpers.js, which is the last thing
|
|
// every render path runs — restoreView()'s, the Back path's, and every
|
|
// forward show(). That is the point of putting it there rather than in the
|
|
// individual views: a view added later registers itself with no one having
|
|
// to remember it, so this cannot decay.
|
|
//
|
|
// Module scope is page-load scope: the popup loads this module once per
|
|
// page load, and a reopened popup gets a fresh, empty set — which is
|
|
// exactly the state that makes the Back path render.
|
|
const renderedViews = new Set();
|
|
|
|
function markViewRendered(view) {
|
|
if (view) renderedViews.add(view);
|
|
}
|
|
|
|
// Begin a fresh page-load scope. The popup gets one by being loaded, and
|
|
// asks for one when the user switches the network, because every screen
|
|
// then on the page shows the previous network (views/settings.js); the
|
|
// unit tests, which simulate several page loads against one module
|
|
// instance, ask for one too.
|
|
function resetRenderedViews() {
|
|
renderedViews.clear();
|
|
}
|
|
|
|
// Home is the exception: Back re-renders it every time, which is what the
|
|
// popup did before this router existed (index.js registered
|
|
// renderWalletList() as setRenderMain(), and goBack() called it on every
|
|
// Back onto "main"). It must stay that way — the wallet list has to reflect
|
|
// what changed while the user was away from it, such as a wallet renamed or
|
|
// an address removed in Settings — and Home holds no unsaved input to lose.
|
|
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
|
|
|
// Views that render an address the user picked and cannot be rendered
|
|
// without one. "confirm-tx" is here because its Sign button dereferences
|
|
// `state.wallets[state.selectedWallet].encryptedSecret`
|
|
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
|
|
// can only throw when the user presses its one button must not be restored
|
|
// onto.
|
|
const ADDRESS_VIEWS = new Set([
|
|
"address",
|
|
"address-token",
|
|
"receive",
|
|
"transaction",
|
|
"confirm-tx",
|
|
]);
|
|
|
|
function needsAddress(view) {
|
|
return ADDRESS_VIEWS.has(view);
|
|
}
|
|
|
|
function hasValidAddress(state) {
|
|
return Boolean(
|
|
state.selectedWallet !== null &&
|
|
state.selectedAddress !== null &&
|
|
state.wallets[state.selectedWallet] &&
|
|
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
|
|
);
|
|
}
|
|
|
|
// The stored viewData ENTRIES each branch below dereferences, as opposed to
|
|
// the one field it gates on.
|
|
//
|
|
// A gate on a single truthy field checks the container, not the entries, and
|
|
// the dereference is one level below it: a stored `{"currentView":
|
|
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
|
|
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
|
|
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
|
|
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
|
|
// own branch's fields since it was written; these are the other four.
|
|
//
|
|
// Only what actually throws is required. Fields that are compared,
|
|
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
|
|
// String() their argument), so requiring them would refuse a restorable screen
|
|
// over a cosmetic value.
|
|
function isText(value) {
|
|
return typeof value === "string";
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
// An address handed to renderAddressHtml()/addressTitle(): both reach
|
|
// `address.slice()` and `address.toLowerCase()` with no guard.
|
|
function isAddressText(value) {
|
|
return isText(value);
|
|
}
|
|
|
|
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
|
|
// walks it: `for (const d of decoded.details)` needs an iterable, and each
|
|
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
|
|
// case and short-circuits before either.
|
|
function isRenderableDecoded(value) {
|
|
if (!value) return true;
|
|
if (!isRecord(value)) return false;
|
|
if (!value.details) return true;
|
|
if (!Array.isArray(value.details)) return false;
|
|
return value.details.every(
|
|
(entry) =>
|
|
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
|
|
);
|
|
}
|
|
|
|
// The pending transaction confirmTx.show() renders: `token` reaches
|
|
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
|
// addressTitle(), makeBlockie() and getLocalWarnings().
|
|
function isRenderablePendingTx(value) {
|
|
return (
|
|
isRecord(value) &&
|
|
isText(value.token) &&
|
|
isAddressText(value.from) &&
|
|
isAddressText(value.to)
|
|
);
|
|
}
|
|
|
|
// The stored transaction transactionDetail.render() shows. contractAddress is
|
|
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
|
function isRenderableTx(value) {
|
|
return (
|
|
isRecord(value) &&
|
|
isAddressText(value.from) &&
|
|
isAddressText(value.to) &&
|
|
(!value.contractAddress || isAddressText(value.contractAddress))
|
|
);
|
|
}
|
|
|
|
// Render `view` from persisted state. Each view module shows itself, so a
|
|
// true return means the view is both rendered and on screen.
|
|
//
|
|
// Returns false when the view is not one the popup renders from state, or
|
|
// when the state it would render is gone — a token no longer selected, a
|
|
// transaction no longer persisted. The caller falls back rather than
|
|
// putting an empty template on screen.
|
|
function renderView(view, state, views) {
|
|
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
|
|
if (needsAddress(view) && !hasValidAddress(state)) return false;
|
|
if (view === "address-token" && !state.selectedToken) return false;
|
|
|
|
const data = state.viewData || {};
|
|
switch (view) {
|
|
case "main":
|
|
views.main.show();
|
|
return true;
|
|
case "address":
|
|
views.addressDetail.show();
|
|
return true;
|
|
case "address-token":
|
|
views.addressToken.show();
|
|
return true;
|
|
case "receive":
|
|
views.receive.show();
|
|
return true;
|
|
case "settings":
|
|
views.settings.show();
|
|
return true;
|
|
case "settings-addtoken":
|
|
views.settingsAddToken.show();
|
|
return true;
|
|
case "confirm-tx":
|
|
if (!isRenderablePendingTx(data.pendingTx)) return false;
|
|
views.confirmTx.restore();
|
|
return true;
|
|
case "transaction":
|
|
if (!isRenderableTx(data.tx)) return false;
|
|
views.transactionDetail.render();
|
|
return true;
|
|
case "wait-tx":
|
|
// Resumes the receipt poll from the persisted broadcast time,
|
|
// and answers false when there is nothing resumable left.
|
|
return Boolean(views.txStatus.restoreWait());
|
|
case "success-tx":
|
|
if (!data.hash) return false;
|
|
if (!isAddressText(data.to)) return false;
|
|
if (!isRenderableDecoded(data.decoded)) return false;
|
|
views.txStatus.renderSuccess();
|
|
return true;
|
|
case "error-tx":
|
|
if (!data.message) return false;
|
|
if (!isAddressText(data.to)) return false;
|
|
views.txStatus.renderError();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The Back-path renderer, registered with setBackRenderer() in
|
|
// views/helpers.js.
|
|
//
|
|
// Returns false — leaving goBack() to unhide the view, as it always did —
|
|
// in the two cases where the view is known to be on the page already:
|
|
//
|
|
// - It is not one the popup renders from persisted state. The restored
|
|
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
|
|
// it the same way (views/settings.js), so such a view can only be on the
|
|
// stack from this page load, where forward navigation rendered it on the
|
|
// way in.
|
|
// - This page load has rendered it. Re-rendering would re-fetch and
|
|
// clobber what it holds; Home is rendered anyway, see above.
|
|
//
|
|
// What is left is the case the router exists for: a view on the stack that
|
|
// this page load has never rendered, whose template is still blank.
|
|
function makeBackRenderer(state, views) {
|
|
return function renderBack(view) {
|
|
if (!RESTORABLE_VIEWS.has(view)) return false;
|
|
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
|
|
return false;
|
|
}
|
|
if (!renderView(view, state, views)) {
|
|
views.main.show();
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
renderView,
|
|
makeBackRenderer,
|
|
markViewRendered,
|
|
resetRenderedViews,
|
|
};
|