History rows show the ISO datetime and the relative age #773

Open
clawbot wants to merge 1 commits from issue-760-history-row-datetime into next
pull from: issue-760-history-row-datetime
9 changed files with 207 additions and 97 deletions

No files matched your search

+6 -7
View File
@@ -563,9 +563,10 @@ of a wallet is a block:
"Recent Transactions" lists up to 25 transactions across every address of every "Recent Transactions" lists up to 25 transactions across every address of every
wallet, deduplicated by hash and filtered (see Scam protection). Each row has 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 colour dot and label, with the amount on the right;
- the counterparty's full address, as plain text. - 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: These helpers write one kind of value on every screen that shows it:
- `formatUsd()` in `src/shared/prices.js`: USD values; - `formatUsd()` in `src/shared/prices.js`: USD values;
- `isoDate()` and `timeAgo()` in `src/popup/views/helpers.js`: transaction - `transactionTimeHtml()` in `src/popup/views/helpers.js`: transaction times;
times;
- `renderAddressHtml()` in the same file: most addresses. - `renderAddressHtml()` in the same file: most addresses.
Most screens show token and ETH amounts with four decimal places. History rows 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. `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. 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 TransactionDetail and every history row show the time as an ISO datetime and
history row shows the age, with the datetime as its tooltip. With "UTC then a relative age. With "UTC Timestamps" on, the datetime is in UTC.
Timestamps" on, the datetime is in UTC.
Every nonzero holding is stored exactly. A token holding below 0.000001 is left Every nonzero holding is stored exactly. A token holding below 0.000001 is left
out of: out of:
+4 -3
View File
@@ -415,9 +415,10 @@ the setting is off, each history list shows one line saying so.
Each row shows: Each row shows:
- the age and the direction, "Sent" or "Received". A transaction can also ask a - the time, as a date and time and as an age;
contract to do something; that is a contract call. A row for a contract call - the direction, "Sent" or "Received", on the line below. A transaction can also
shows the call's name instead, such as "Approve" or "Swap"; 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 - 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. 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 token came back. A row with no amount shows only a symbol, such as `USDC` or
+5 -5
View File
@@ -12,8 +12,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -236,10 +235,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+5 -5
View File
@@ -18,8 +18,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList"); const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
@@ -329,10 +328,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+12 -5
View File
@@ -615,9 +615,8 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Amounts): the ISO // The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age. // setting is on. Views show it through transactionTimeHtml().
// Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -674,6 +673,15 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; 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. // Shared external-link icon SVG used across all views.
const EXT_ICON = const EXT_ICON =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` + `<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
@@ -869,6 +877,5 @@ module.exports = {
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, transactionTimeHtml,
timeAgo,
}; };
+5 -5
View File
@@ -5,8 +5,7 @@ const {
balanceLinesForAddress, balanceLinesForAddress,
unknownableAmount, unknownableAmount,
markTrackedTokensNotFetched, markTrackedTokensNotFetched,
isoDate, transactionTimeHtml,
timeAgo,
addressDotHtml, addressDotHtml,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT, HISTORY_OFF_TEXT,
@@ -150,10 +149,11 @@ function renderHomeTxList(ctx) {
const titleStr = title ? escapeHtml(title) : ""; const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+4 -5
View File
@@ -10,8 +10,7 @@ const {
CONTRACT_CREATION_TEXT, CONTRACT_CREATION_TEXT,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
isoDate, transactionTimeHtml,
timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -204,9 +203,9 @@ function render() {
); );
loadFullTxDetails(tx.hash, tx.chainId); loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
$("tx-detail-time").innerHTML = copyable: true,
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; });
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
attachCopyHandlers("view-transaction"); attachCopyHandlers("view-transaction");
+65
View File
@@ -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) // ------------------------- the last of the #150 and #151 items (#295)
// //
// Add Token's confirm button, TransactionDetail opened from the token screen // Add Token's confirm button, TransactionDetail opened from the token screen
+101 -62
View File
@@ -1,15 +1,14 @@
// A transaction's time is written by isoDate() and timeAgo() in // A transaction's time shows the ISO datetime and the relative age on every
// src/popup/views/helpers.js on every screen that shows one (README, Amounts; // screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and // used to show only the age, with the datetime as a tooltip
// AddressToken used to define their own copies, so a fix to the shared pair // (https://git.eeqj.de/sneak/AutistMask/issues/760).
// would not have reached them.
// //
// The pair is replaced before the views are loaded, because a view takes it // The views run against a minimal DOM stub, as in
// when it loads. A view that writes the time with a copy of its own shows the // tests/historyAfterNetworkSwitch.test.js, and over
// real time instead of the replacement. // tests/support/storageStub.js.
//
// Driven against a minimal DOM stub in the shape const { makeStorageStub } = require("./support/storageStub");
// tests/contractCreation.test.js uses. const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/log", () => ({ jest.mock("../src/shared/log", () => ({
log: { log: {
@@ -26,33 +25,78 @@ jest.mock("../src/shared/log", () => ({
})); }));
// The history lists ask the explorer for their transactions and resolve ENS // 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. // resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({ jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"), ...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory, fetchRecentTransactions: async () => mockHistory(),
})); }));
jest.mock("../src/shared/ens", () => ({ jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"), ...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(), resolveEnsNames: async () => new Map(),
})); }));
globalThis.chrome = { const { state, loadState } = require("../src/shared/state");
storage: { local: { get: async () => ({}), set: async () => {} } }, const home = require("../src/popup/views/home");
};
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 addressDetail = require("../src/popup/views/addressDetail"); const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken"); const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11"; const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; 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) { function makeElement(id) {
const el = { const el = {
@@ -90,6 +134,7 @@ function makeDocument() {
return els.get(id); return els.get(id);
}, },
createElement: () => makeElement("created"), createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} }, body: { prepend: () => {} },
}; };
} }
@@ -98,64 +143,58 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// A transaction FROM sent, as the history lists hold it. // Lets every promise that is not held open settle.
function historyTx() { function settle() {
return { return new Promise((resolve) => setTimeout(resolve, 0));
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,
};
} }
beforeEach(() => { beforeEach(async () => {
const storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.wallets = [ jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
{ await loadState();
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; state.selectedToken = "ETH";
// The history is Blockscout's. const ctx = { pageClosed: new AbortController().signal };
state.useBlockscout = true; addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
jest.restoreAllMocks();
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
}); });
describe.each([ describe.each([
[
"Home",
"home-tx-list",
() => home.render({ pageClosed: new AbortController().signal }),
],
["AddressDetail", "tx-list", () => addressDetail.show()], ["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()], ["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => { ])("a transaction history row on %s", (_name, listId, open) => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => { test("shows the ISO datetime and then the relative age", async () => {
mockHistory = [historyTx()];
open(); open();
// The list is drawn once the history has been fetched. // 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; const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"'); expect(html).toContain(">" + SHOWN_TIME + "<");
expect(html).toContain(">SHARED-TIME-AGO<"); expect(html).not.toContain("title=");
}); });
}); });
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => { test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
transactionDetail.show(historyTx()); transactionDetail.show(mockHistory()[0]);
const html = node("tx-detail-time").innerHTML; const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE"); expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
expect(html).toContain("(SHARED-TIME-AGO)"); expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
}); });