diff --git a/README.md b/README.md
index 9f38fe9..d192195 100644
--- a/README.md
+++ b/README.md
@@ -563,9 +563,10 @@ of a wallet is a block:
"Recent Transactions" lists up to 25 transactions across every address of every
wallet, deduplicated by hash and filtered (see Scam protection). Each row has
-three lines:
+four lines:
-- the age and the direction;
+- the ISO datetime and the relative age;
+- the direction, on the right;
- the counterparty's colour dot and label, with the amount on the right;
- the counterparty's full address, as plain text.
@@ -1185,8 +1186,7 @@ popup closes.
These helpers write one kind of value on every screen that shows it:
- `formatUsd()` in `src/shared/prices.js`: USD values;
-- `isoDate()` and `timeAgo()` in `src/popup/views/helpers.js`: transaction
- times;
+- `transactionTimeHtml()` in `src/popup/views/helpers.js`: transaction times;
- `renderAddressHtml()` in the same file: most addresses.
Most screens show token and ETH amounts with four decimal places. History rows
@@ -1274,9 +1274,8 @@ A native amount is labelled with the native currency of the transaction's chain:
`src/shared/networks.js`). The balance lists and Send use the active network's.
The decoded swap lines call the native currency `ETH` on both networks.
-TransactionDetail shows the time as an ISO datetime and a relative age. A
-history row shows the age, with the datetime as its tooltip. With "UTC
-Timestamps" on, the datetime is in UTC.
+TransactionDetail and every history row show the time as an ISO datetime and
+then a relative age. With "UTC Timestamps" on, the datetime is in UTC.
Every nonzero holding is stored exactly. A token holding below 0.000001 is left
out of:
diff --git a/docs/README.md b/docs/README.md
index c1f0f70..7168469 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -415,9 +415,10 @@ the setting is off, each history list shows one line saying so.
Each row shows:
-- the age and the direction, "Sent" or "Received". A transaction can also ask a
- contract to do something; that is a contract call. A row for a contract call
- shows the call's name instead, such as "Approve" or "Swap";
+- the time, as a date and time and as an age;
+- the direction, "Sent" or "Received", on the line below. A transaction can also
+ ask a contract to do something; that is a contract call. A row for a contract
+ call shows the call's name instead, such as "Approve" or "Swap";
- the amount. A contract call row shows an amount only when the call moved a
token. A swap row shows the amount received, or the amount paid in when no
token came back. A row with no amount shows only a symbol, such as `USDC` or
diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js
index 002f623..4fa1539 100644
--- a/src/popup/views/addressDetail.js
+++ b/src/popup/views/addressDetail.js
@@ -12,8 +12,7 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
- isoDate,
- timeAgo,
+ transactionTimeHtml,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -236,10 +235,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
- const ago = escapeHtml(timeAgo(tx.timestamp));
- const iso = escapeHtml(isoDate(tx.timestamp));
html += `
`;
- html += `
${ago}${dirLabel}${err}
`;
+ // The time has a line of its own, so a long time or direction label
+ // never pushes the other onto a second line.
+ html += `
${transactionTimeHtml(tx.timestamp)}
`;
+ html += `
${dirLabel}${err}
`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `
`;
i++;
diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js
index 9bfec5e..ffe455a 100644
--- a/src/popup/views/addressToken.js
+++ b/src/popup/views/addressToken.js
@@ -18,8 +18,7 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
- isoDate,
- timeAgo,
+ transactionTimeHtml,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
@@ -329,10 +328,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
- const ago = escapeHtml(timeAgo(tx.timestamp));
- const iso = escapeHtml(isoDate(tx.timestamp));
html += ``;
- html += `
${ago}${dirLabel}${err}
`;
+ // The time has a line of its own, so a long time or direction label
+ // never pushes the other onto a second line.
+ html += `
${transactionTimeHtml(tx.timestamp)}
`;
+ html += `
${dirLabel}${err}
`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `
`;
i++;
diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js
index 04f8490..c778f13 100644
--- a/src/popup/views/helpers.js
+++ b/src/popup/views/helpers.js
@@ -615,9 +615,8 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen });
}
-// A transaction's time as every screen shows it (README, Amounts): the ISO
-// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
-// Views import these two; they keep no copies.
+// The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
+// setting is on. Views show it through transactionTimeHtml().
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
@@ -674,6 +673,15 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
+// A transaction's time as every screen shows it (README, Amounts): the ISO
+// datetime, then the relative age in parentheses. With copyable set, the
+// datetime is click-to-copy, and the caller attaches the copy handlers.
+function transactionTimeHtml(timestamp, { copyable = false } = {}) {
+ const iso = isoDate(timestamp);
+ const isoHtml = copyable ? copyableHtml(iso) : escapeHtml(iso);
+ return isoHtml + " (" + escapeHtml(timeAgo(timestamp)) + ")";
+}
+
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`` +
@@ -869,6 +877,5 @@ module.exports = {
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
- isoDate,
- timeAgo,
+ transactionTimeHtml,
};
diff --git a/src/popup/views/home.js b/src/popup/views/home.js
index b1c9110..f7ffb28 100644
--- a/src/popup/views/home.js
+++ b/src/popup/views/home.js
@@ -5,8 +5,7 @@ const {
balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched,
- isoDate,
- timeAgo,
+ transactionTimeHtml,
addressDotHtml,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
@@ -150,10 +149,11 @@ function renderHomeTxList(ctx) {
const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
- const ago = escapeHtml(timeAgo(tx.timestamp));
- const iso = escapeHtml(isoDate(tx.timestamp));
html += ``;
- html += `
${ago}${dirLabel}${err}
`;
+ // The time has a line of its own, so a long time or direction label
+ // never pushes the other onto a second line.
+ html += `
${transactionTimeHtml(tx.timestamp)}
`;
+ html += `
${dirLabel}${err}
`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `
`;
i++;
diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js
index 5052dab..3215806 100644
--- a/src/popup/views/transactionDetail.js
+++ b/src/popup/views/transactionDetail.js
@@ -10,8 +10,7 @@ const {
CONTRACT_CREATION_TEXT,
addressDotHtml,
escapeHtml,
- isoDate,
- timeAgo,
+ transactionTimeHtml,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -204,9 +203,9 @@ function render() {
);
loadFullTxDetails(tx.hash, tx.chainId);
- const isoStr = isoDate(tx.timestamp);
- $("tx-detail-time").innerHTML =
- copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
+ $("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
+ copyable: true,
+ });
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");
attachCopyHandlers("view-transaction");
diff --git a/tests/e2e/run.js b/tests/e2e/run.js
index 634c6ea..8ea3ce9 100644
--- a/tests/e2e/run.js
+++ b/tests/e2e/run.js
@@ -951,6 +951,71 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
);
});
+// ------------------------------------------ history row time (#760)
+
+// A Home history row shows the ISO datetime and then the relative age, on a
+// line of its own. Measured at the popup's size: the time line is one line
+// high, and no line of a row is wider than the row or the popup.
+test("a Home history row shows the datetime and the age, one line high, without overflow (#760)", async (env) => {
+ await goHome(env.page);
+ env.routeOpts.seedNativeTransfer = true;
+ const page = await openPopup(env.ctx, env.popupUrl);
+ try {
+ await page.setViewportSize(POPUP_VIEWPORT);
+ await visible(page, "#view-main");
+ await page.waitForFunction(
+ () =>
+ document.querySelectorAll("#home-tx-list .home-tx-row")
+ .length === 2,
+ null,
+ { timeout: 30000 },
+ );
+ const rows = await page.evaluate(() => {
+ const popupRight = document.body.getBoundingClientRect().right;
+ return [
+ ...document.querySelectorAll("#home-tx-list .home-tx-row"),
+ ].map((row) => {
+ const time = row.firstElementChild;
+ return {
+ time: time.textContent,
+ timeHeight: time.getBoundingClientRect().height,
+ lineHeight: parseFloat(getComputedStyle(time).lineHeight),
+ overflowingLines: [...row.children].filter(
+ (line) =>
+ line.scrollWidth > line.clientWidth ||
+ line.getBoundingClientRect().right > popupRight,
+ ).length,
+ };
+ });
+ });
+ console.log("# Home history rows: " + JSON.stringify(rows));
+ for (const row of rows) {
+ assert(
+ /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(Z|[+-]\d\d:\d\d) \(\d+ \w+ ago\)$/.test(
+ row.time,
+ ),
+ "the time line is not the ISO datetime and then the age: " +
+ JSON.stringify(row.time),
+ );
+ assert(
+ row.timeHeight === row.lineHeight,
+ "the time line is " +
+ row.timeHeight +
+ "px high, not one line of " +
+ row.lineHeight +
+ "px",
+ );
+ assert(
+ row.overflowingLines === 0,
+ row.overflowingLines + " line(s) of a row overflow it",
+ );
+ }
+ } finally {
+ env.routeOpts.seedNativeTransfer = false;
+ await page.close();
+ }
+});
+
// ------------------------- the last of the #150 and #151 items (#295)
//
// Add Token's confirm button, TransactionDetail opened from the token screen
diff --git a/tests/timestampDisplay.test.js b/tests/timestampDisplay.test.js
index 201134e..6681a77 100644
--- a/tests/timestampDisplay.test.js
+++ b/tests/timestampDisplay.test.js
@@ -1,15 +1,14 @@
-// A transaction's time is written by isoDate() and timeAgo() in
-// src/popup/views/helpers.js on every screen that shows one (README, Amounts;
-// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
-// AddressToken used to define their own copies, so a fix to the shared pair
-// would not have reached them.
+// A transaction's time shows the ISO datetime and the relative age on every
+// screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
+// used to show only the age, with the datetime as a tooltip
+// (https://git.eeqj.de/sneak/AutistMask/issues/760).
//
-// The pair is replaced before the views are loaded, because a view takes it
-// when it loads. A view that writes the time with a copy of its own shows the
-// real time instead of the replacement.
-//
-// Driven against a minimal DOM stub in the shape
-// tests/contractCreation.test.js uses.
+// The views run against a minimal DOM stub, as in
+// tests/historyAfterNetworkSwitch.test.js, and over
+// tests/support/storageStub.js.
+
+const { makeStorageStub } = require("./support/storageStub");
+const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/log", () => ({
log: {
@@ -26,33 +25,78 @@ jest.mock("../src/shared/log", () => ({
}));
// The history lists ask the explorer for their transactions and resolve ENS
-// names for them; here the explorer answers with mockHistory and no name
+// names for them; here the explorer answers with mockHistory() and no name
// resolves.
-let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
- fetchRecentTransactions: async () => mockHistory,
+ fetchRecentTransactions: async () => mockHistory(),
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
-globalThis.chrome = {
- storage: { local: { get: async () => ({}), set: async () => {} } },
-};
-
-const helpers = require("../src/popup/views/helpers");
-jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
-jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
-
-const { state } = require("../src/shared/state");
+const { state, loadState } = require("../src/shared/state");
+const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
+const TIMESTAMP = 1790000000;
+// TIMESTAMP as shown with "UTC Timestamps" on, three hours after it.
+const SHOWN_TIME = "2026-09-21T14:13:20Z (3 hours ago)";
+
+// A profile on Ethereum Mainnet with one address, "Use Blockscout" and "UTC
+// Timestamps" on.
+function profile() {
+ return {
+ schemaVersion: STATE_SCHEMA_VERSION,
+ hasWallet: true,
+ wallets: [
+ {
+ type: "hd",
+ name: "Main",
+ xpub: "xpub-main",
+ encryptedSecret: "encrypted-secret",
+ nextIndex: 1,
+ addresses: [
+ { address: FROM, balance: "1.0", tokenBalances: [] },
+ ],
+ },
+ ],
+ activeAddress: FROM,
+ networkId: "mainnet",
+ rpcUrl: "https://ethereum-rpc.publicnode.com",
+ blockscoutUrl: "https://eth.blockscout.com/api/v2",
+ useBlockscout: true,
+ utcTimestamps: true,
+ };
+}
+
+// A transfer of ETH from FROM, as Blockscout answers it.
+function mockHistory() {
+ return [
+ {
+ hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
+ blockNumber: 1,
+ from: FROM,
+ to: RECIPIENT,
+ value: "0.0000",
+ exactValue: "0.0",
+ rawAmount: "0",
+ rawUnit: "wei",
+ symbol: "ETH",
+ timestamp: TIMESTAMP,
+ isError: false,
+ directionLabel: "Sent",
+ direction: "sent",
+ contractAddress: null,
+ chainId: "0x1",
+ },
+ ];
+}
function makeElement(id) {
const el = {
@@ -90,6 +134,7 @@ function makeDocument() {
return els.get(id);
},
createElement: () => makeElement("created"),
+ addEventListener: () => {},
body: { prepend: () => {} },
};
}
@@ -98,64 +143,58 @@ function node(id) {
return globalThis.document.getElementById(id);
}
-// A transaction FROM sent, as the history lists hold it.
-function historyTx() {
- return {
- hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
- from: FROM,
- to: RECIPIENT,
- value: "0.0000",
- exactValue: "0.0",
- rawAmount: "0",
- rawUnit: "wei",
- symbol: "ETH",
- timestamp: 1790000000,
- isError: false,
- directionLabel: "Sent",
- direction: "sent",
- contractAddress: null,
- };
+// Lets every promise that is not held open settle.
+function settle() {
+ return new Promise((resolve) => setTimeout(resolve, 0));
}
-beforeEach(() => {
+beforeEach(async () => {
+ const storage = makeStorageStub({ autistmask: profile() });
+ globalThis.chrome = {
+ storage: { local: storage.local, onChanged: storage.onChanged },
+ };
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
- state.wallets = [
- {
- name: "Main",
- type: "key",
- addresses: [{ address: FROM, balance: "0.0000" }],
- },
- ];
- state.trackedTokens = [];
- state.viewData = {};
+ jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
+ await loadState();
state.viewStack = [];
- state.currentView = null;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
- // The history is Blockscout's.
- state.useBlockscout = true;
+ const ctx = { pageClosed: new AbortController().signal };
+ addressDetail.init(ctx);
+ addressToken.init(ctx);
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ delete globalThis.chrome;
+ delete globalThis.document;
+ delete globalThis.window;
});
describe.each([
+ [
+ "Home",
+ "home-tx-list",
+ () => home.render({ pageClosed: new AbortController().signal }),
+ ],
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => {
- test("shows the time written by the shared isoDate() and timeAgo()", async () => {
- mockHistory = [historyTx()];
+ test("shows the ISO datetime and then the relative age", async () => {
open();
// The list is drawn once the history has been fetched.
- await new Promise((resolve) => setTimeout(resolve, 0));
+ for (let i = 0; i < 10; i++) await settle();
const html = node(listId).innerHTML;
- expect(html).toContain('title="SHARED-ISO-DATE"');
- expect(html).toContain(">SHARED-TIME-AGO<");
+ expect(html).toContain(">" + SHOWN_TIME + "<");
+ expect(html).not.toContain("title=");
});
});
-test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
- transactionDetail.show(historyTx());
+test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
+ transactionDetail.show(mockHistory()[0]);
const html = node("tx-detail-time").innerHTML;
- expect(html).toContain("SHARED-ISO-DATE");
- expect(html).toContain("(SHARED-TIME-AGO)");
+ expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
+ expect(html).toContain(">2026-09-21T14:13:20Z (3 hours ago)");
});