From b77b04b714ab8d5cb1b8f94d631f0699da791a08 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 11 Nov 2026 07:53:41 +0100 Subject: [PATCH] fix: history rows show the ISO datetime and the age (closes #760) Home, AddressDetail and AddressToken showed only the relative age, with the datetime as a tooltip. Each row now shows the datetime and then the age in parentheses, on a line of its own, through transactionTimeHtml() in helpers.js, which TransactionDetail also uses. The direction label moves to its own right-aligned line below. README.md and docs/README.md describe the new row. Model: opus-5-5 --- README.md | 13 +-- docs/README.md | 7 +- src/popup/views/addressDetail.js | 10 +- src/popup/views/addressToken.js | 10 +- src/popup/views/helpers.js | 17 ++- src/popup/views/home.js | 10 +- src/popup/views/transactionDetail.js | 9 +- tests/e2e/run.js | 65 +++++++++++ tests/timestampDisplay.test.js | 163 +++++++++++++++++---------- 9 files changed, 207 insertions(+), 97 deletions(-) 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)"); });