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
8 changed files with 199 additions and 90 deletions

No files matched your search

+2 -3
View File
@@ -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:
+5 -5
View File
@@ -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 += `<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 += `</div>`;
i++;
+5 -5
View File
@@ -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 += `<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 += `</div>`;
i++;
+12 -5
View File
@@ -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 =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
@@ -869,6 +877,5 @@ module.exports = {
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
timeAgo,
transactionTimeHtml,
};
+5 -5
View File
@@ -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 += `<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 += `</div>`;
i++;
+4 -5
View File
@@ -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");
+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)
//
// 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
// 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</span> (3 hours ago)");
});