diff --git a/client/src/app.js b/client/src/app.js index bc2627c1..03f81385 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -30,6 +30,11 @@ import * as views from './views' const apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '') , bitcoinMarketChartUrl = process.env.BITCOIN_MARKET_CHART_URL || 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=usd&days=1&interval=hourly' + , blockTemplatePollIntervalMs = 30000 + // Wait one electrs cache window after a new tip before requesting the next + // template. If a refresh is still in progress, electrs holds the request + // until fresh data is ready, so no additional client-side jitter is needed. + , blockTemplatePollAfterNewBlockMs = 15000 , setBase = ({ path, ...r }) => ({ ...r, url: path.includes('://') || path.startsWith('./') ? path : apiBase + path }) const reservedPaths = [ 'mempool', 'assets', 'search' ] @@ -73,6 +78,32 @@ const trackNewEntries = (items$, getId, getNewIds=defaultNewIds) => { .scan((current, mod) => mod(current), {}) } +export const scheduleBlockTemplatePolls = (start$, newBlock$, scheduler) => + O.merge( + start$.mapTo(0), + newBlock$.mapTo(blockTemplatePollAfterNewBlockMs) + ) + .switchMap(delay => O.timer( + delay, + blockTemplatePollIntervalMs, + scheduler + )) + +export const scheduleDashboardBlockTemplatePolls = ( + view$, + newBlock$, + scheduler +) => scheduleBlockTemplatePolls( + view$ + .distinctUntilChanged() + .filter(view => view == 'dashBoard'), + newBlock$, + scheduler +) + .withLatestFrom(view$, (pollIndex, view) => ({ pollIndex, view })) + .filter(({ view }) => view == 'dashBoard') + .map(({ pollIndex }) => pollIndex) + const trackPendingBlockTemplateUpdate = (previous, template) => { if (!template || !Array.isArray(template.transactions)) { return { template: null, key: null, transactionCount: null, delta: null } @@ -283,6 +314,11 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , tx$ = reply('tx').merge(goTx$.mapTo(null)).startWith(null) , txBlock$ = reply('tx-block').merge(goTx$.mapTo(null)).startWith(null) + // Predecessor metadata for confirmed block interval calculations + , previousBlock$ = reply('previous-block') + .merge(O.merge(goBlock$, goTx$).mapTo(null)) + .startWith(null) + // Currently collapsed tx/block ("details") , openTx$ = togTx$.startWith(null).scan((prev, txid) => prev == txid ? null : txid) , openBlock$ = togBlock$.startWith(null).scan((prev, blockhash) => prev == blockhash ? null : blockhash) @@ -422,6 +458,18 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search .distinctUntilChanged((a, b) => a.height == b.height) : O.empty() + , dashboardNewBlock$ = latestBlock$.skip(1) + .withLatestFrom(view$) + .filter(([ _, view ]) => view == 'dashBoard') + + // In the browser, wait for the ready dashboard view. Liquid remains in the + // loading view until its asset map arrives, so starting from goHome$ would + // discard the initial template request. + , blockTemplatePoll$ = !process.browser + ? goHome$ + : scheduleDashboardBlockTemplatePolls(view$, dashboardNewBlock$) + .filter(pollIndex => pollIndex == 0 || document.hasFocus()) + , dashboardEpochStartHeight$ = dashboardLatestBlock$ .map(block => block.height - (block.height % difficultyPeriod)) .distinctUntilChanged() @@ -451,7 +499,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , newBlockEntries$, newTxEntries$ , goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$ , mempool$, mempoolRecent$, feeEst$, bitcoinMarketChart$ - , tx$, txBlock$, txAnalysis$, openTx$ + , tx$, txBlock$, previousBlock$, txAnalysis$, openTx$ , goAddr$, addr$, addrTxs$, addrQR$ , assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$, unblinded$ , isReady$, loading$, page$, view$, title$ @@ -483,6 +531,11 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , tx$.filter(tx => tx && tx.status && tx.status.confirmed && tx.status.block_hash) .map(tx => ({ category: 'tx-block', method: 'GET', path: `/block/${tx.status.block_hash}` })) + // fetch the predecessor needed to calculate a confirmed block's interval + , O.merge(block$, txBlock$) + .filter(block => block && block.previousblockhash) + .map(block => ({ category: 'previous-block', method: 'GET', path: `/block/${block.previousblockhash}`, bg: true })) + // fetch address and its txs , goAddr$.flatMap(d => [{ category: 'address', method: 'GET', path: `/address/${d.addr}` } , d.last_txids.length @@ -563,14 +616,9 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , { category: 'dashboard-peg-chain-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/chain`, bg: true } , { category: 'dashboard-peg-mempool-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/mempool`, bg: true }])) - // refresh the pending block template while the dashboard remains open - , O.merge( - O.merge(goHome$, tickWhileViewing(30000, 'dashBoard', view$)) - .throttleTime(1000) - , latestBlock$.skip(1) - .withLatestFrom(view$) - .filter(([ _, view ]) => view == 'dashBoard') - ) + // Refresh the pending block template while the dashboard remains open. A new + // tip resets the cadence and waits for electrs' block cache to refresh. + , blockTemplatePoll$ .mapTo({ category: 'block-template', method: 'GET', path: '/block-template', bg: true }) , goHome$.flatMap(_ => [{ category: 'blocks', method: 'GET', path: '/blocks' } @@ -670,7 +718,22 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search }) on('.table-copy-button', 'click', { preventDefault: true }).subscribe(e => e.stopPropagation()) - on('.tooltip', 'click', { preventDefault: true }).subscribe(e => e.stopPropagation()) + + const closeOpenTooltips = except => { + document.querySelectorAll('.tooltip.tooltip-open').forEach(tooltip => { + if (tooltip != except) tooltip.classList.remove('tooltip-open') + }) + } + on('.tooltip', 'click', { preventDefault: true }).subscribe(e => { + e.stopPropagation() + const tooltip = e.ownerTarget + , shouldOpen = !tooltip.classList.contains('tooltip-open') + closeOpenTooltips(tooltip) + tooltip.classList.toggle('tooltip-open', shouldOpen) + if (!shouldOpen) tooltip.blur() + }) + on('.tooltip', 'blur').subscribe(({ ownerTarget: tooltip }) => + tooltip.classList.remove('tooltip-open')) on('[data-scroll-top]', 'click').subscribe(_ => window.scrollTo(0, 0)) const keepTooltipInViewport = ({ ownerTarget: tooltip }) => { @@ -709,6 +772,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search if (activeTooltip && activeTooltip.classList.contains('tooltip') && !e.target.closest('.tooltip')) { activeTooltip.blur() } + if (!e.target.closest('.tooltip')) closeOpenTooltips() if (e.target.closest('.main-nav-container')) return closeNetworkMenus() }) @@ -716,6 +780,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search document.addEventListener('keydown', e => { if (e.key == 'Escape') { closeNetworkMenus() + closeOpenTooltips() document.activeElement && document.activeElement.classList.contains('tooltip') && document.activeElement.blur() } diff --git a/client/src/components/block-details-card.js b/client/src/components/block-details-card.js index 0994b574..7c8fa0c8 100644 --- a/client/src/components/block-details-card.js +++ b/client/src/components/block-details-card.js @@ -4,7 +4,7 @@ import { InfoStat } from "./info-stat"; import { MinusIcon, PlusIcon } from "./icons"; import { MetricBar } from "./metric-bar"; import { StatusBadge } from "./status-badge"; -import { ElapsedTime } from "./elapsed-time"; +import { ElapsedTime, formatDuration } from "./elapsed-time"; import { Tooltip } from "./tooltip"; import { maxBlockWeight } from "../const"; import { @@ -42,16 +42,34 @@ const formatVirtualSize = (weight) => { return formatScaledValue(virtualSize, 1_000_000, "vMB"); }; -const ExpandedBlockDetails = ({ block, t }) => { +const formatBlockInterval = (block, previousBlock) => { + if ( + !block || + !previousBlock || + previousBlock.id !== block.previousblockhash || + !Number.isFinite(block.timestamp) || + !Number.isFinite(previousBlock.timestamp) + ) { + return "N/A"; + } + + return formatDuration( + (block.timestamp - previousBlock.timestamp) * 1000, + true, + ); +}; + +const ExpandedBlockDetails = ({ block, previousBlock, t }) => { const weightPercentage = getBlockPercentageUsed(block.weight); + const blockInterval = formatBlockInterval(block, previousBlock); return (
} + tooltip={t`Time elapsed between this block and the previous block.`} + value={blockInterval} footer={t`Block #${block.height.toLocaleString()}`} /> - ) : ( - "N/A" - ) - } + value={blockInterval} />
- {detailsOpen && block ? : null} + {detailsOpen && block ? ( + + ) : null} ); }; diff --git a/client/src/components/elapsed-time.js b/client/src/components/elapsed-time.js index df6a0df7..1ba42fa3 100644 --- a/client/src/components/elapsed-time.js +++ b/client/src/components/elapsed-time.js @@ -3,12 +3,10 @@ const MINUTES_PER_DAY = 24 * 60; const MINUTES_PER_YEAR = 365 * MINUTES_PER_DAY; const MINUTES_PER_MONTH = MINUTES_PER_YEAR / 12; -const formatElapsedTime = (timestamp, compact) => { - const fromDate = - timestamp < 1e12 ? new Date(timestamp * 1000) : new Date(timestamp); +export const formatDuration = (durationMilliseconds, compact = false) => { const diffMinutes = Math.max( 0, - Math.floor((new Date() - fromDate) / UPDATE_INTERVAL_MS), + Math.floor(durationMilliseconds / UPDATE_INTERVAL_MS), ); const years = Math.floor(diffMinutes / MINUTES_PER_YEAR); const minutesAfterYears = diffMinutes % MINUTES_PER_YEAR; @@ -39,7 +37,17 @@ const formatElapsedTime = (timestamp, compact) => { if (compact) return parts.length ? parts.join(" ") : "< 1m"; - return parts.length ? `${parts.join(" ")} AGO` : "< 1 MINUTE AGO"; + return parts.length ? parts.join(" ") : "< 1 MINUTE"; +}; + +const formatElapsedTime = (timestamp, compact) => { + const fromDate = + timestamp < 1e12 ? new Date(timestamp * 1000) : new Date(timestamp); + const duration = formatDuration(new Date() - fromDate, compact); + + if (compact) return duration; + + return `${duration} AGO`; }; const updateElapsedTime = (element) => { diff --git a/client/src/views/asset-list.js b/client/src/views/asset-list.js index 48885143..b72b5e6a 100644 --- a/client/src/views/asset-list.js +++ b/client/src/views/asset-list.js @@ -46,23 +46,31 @@ export default ({ assetList, goAssetList, loading, t, ...S }) => {
{assets.map((asset) => ( -
-
-
- - {asset.name} -
-
- {asset.ticker || None} -
-
- {getSupply(asset, t)} -
-
- {asset.entity.domain} +
+
+
+ + + {asset.name} + +
+
+ + {asset.ticker || None} + +
+
+ + {getSupply(asset, t)} + +
+
+ + {asset.entity.domain} + +
-
))}
diff --git a/client/src/views/asset.js b/client/src/views/asset.js index e99bdf56..22674f6d 100644 --- a/client/src/views/asset.js +++ b/client/src/views/asset.js @@ -157,7 +157,7 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin {detailField( 'ISSUER PUBKEY', contract.issuer_pubkey, - 'mono', + null, t, shortenValue(contract.issuer_pubkey) )} diff --git a/client/src/views/block.js b/client/src/views/block.js index 7fd84da7..ca1148f9 100644 --- a/client/src/views/block.js +++ b/client/src/views/block.js @@ -25,6 +25,7 @@ export default ({ tipHeight, loading, page, + previousBlock, txsStatus = makeStatus(b), ...S }) => @@ -60,6 +61,7 @@ export default ({

Developer Tools

    diff --git a/client/src/views/overview.js b/client/src/views/overview.js index 61408c27..17ea5116 100644 --- a/client/src/views/overview.js +++ b/client/src/views/overview.js @@ -4,6 +4,7 @@ import { InfoCard } from "../components/info-card"; import { MempoolCongestion } from "../components/mempool-congestion"; import { ReferenceLineChart } from "../components/reference-line-chart"; import { estimateTypicalTransactionFeeUsd } from "../lib/fees"; +import { isBitcoinNetwork } from "../lib/network"; import { getBitcoinPrices, getLatestBitcoinPrice, @@ -37,7 +38,11 @@ export const overview = ({
    diff --git a/client/src/views/tx.js b/client/src/views/tx.js index fd6cf6ac..8823e742 100644 --- a/client/src/views/tx.js +++ b/client/src/views/tx.js @@ -45,6 +45,7 @@ export default ({ page, unblinded, txBlock, + previousBlock, ...S }) => { if (!tx || !S.txAnalysis) return; @@ -93,6 +94,7 @@ export default ({ .table-title-row { + display: none; + } + .assets-table-row { + flex-direction: column; + padding: 12px; + gap: 12px; + } + .asset-list-name, + .asset-list-ticker, + .asset-list-total-supply, + .asset-list-issuer-domain { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + overflow: visible; + white-space: normal; + text-align: right; + } -/* ASSET TABLE MOBILE */ + .asset-list-name::before, + .asset-list-ticker::before, + .asset-list-total-supply::before, + .asset-list-issuer-domain::before { + content: attr(data-label); + flex: 0 0 auto; + color: #B5BDC2; + font-size: 10px; + font-weight: 400; + text-align: left; + text-transform: uppercase; + } + .asset-list-field-value, + .asset-list-name .asset-list-field-value { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 0; + overflow: visible; + overflow-wrap: anywhere; + white-space: normal; + } -/* Pagination remove numbers on Mobile - show Next and Prev instead */ -@media only screen and (max-width: 500px) { - .pagination .numbers{ - display: none; + .asset-list-pagination { + flex-wrap: wrap; } - .pagination .next, .pagination .prev{ - width: 85px; - display: flex; + + .pagination-dropdown-container { + flex: 1 1 260px; + flex-wrap: wrap; } - .pagination .next::before { - content: "Next"; - line-height: 1.7; - padding: 0 4px; + + .pagination { + flex: 1 1 360px; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; + max-width: 100%; } - .pagination .prev::after { - content: "Prev"; - line-height: 1.7; - padding: 0 4px; + + .pagination .numbers { + flex-wrap: wrap; } } diff --git a/lang/strings.txt b/lang/strings.txt index 21f57f14..ff5ea665 100644 --- a/lang/strings.txt +++ b/lang/strings.txt @@ -181,7 +181,7 @@ This transaction saved %s on fees by upgrading to native SegWit-Bech32 This transaction saved %s on fees by upgrading to SegWit and could save %s more by fully upgrading to native SegWit-Bech32 Ticker Time Since Last Block -Time elapsed since this block was mined. +Time elapsed between this block and the previous block. %s MINUTE PAST EXPECTED INTERVAL %s MINUTES PAST EXPECTED INTERVAL Timestamp diff --git a/test/app.test.js b/test/app.test.js index b19941ed..50d85eb5 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,5 +1,9 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const { Subject } = require("../client/node_modules/rxjs/Subject"); +const { + TestScheduler, +} = require("../client/node_modules/rxjs/testing"); process.env.IS_ELEMENTS = "1"; process.env.MENU_ACTIVE = "Liquid"; @@ -10,6 +14,8 @@ const { } = require("../client/src/const"); const { default: main, + scheduleBlockTemplatePolls, + scheduleDashboardBlockTemplatePolls, trackPendingBlockTemplateEvent, } = require("../client/src/app"); @@ -30,8 +36,28 @@ const makeRoute = () => { return route; }; +const makeBlockRoute = (hash) => { + const location = { + hash: "", + key: "block", + params: { hash }, + pathname: `/block/${hash}`, + query: {}, + }; + const location$ = O.of(location); + const route = (pattern) => + pattern === undefined || pattern === "/block/:hash" + ? location$ + : empty$; + + route.all$ = location$; + return route; +}; + const makeSources = ({ blockGridEvent$ = empty$, + responseStreams = {}, + route = makeRoute(), selectedCategories = [], } = {}) => ({ DOM: { @@ -47,11 +73,11 @@ const makeSources = ({ HTTP: { select: (category) => { selectedCategories.push(category); - return empty$; + return responseStreams[category] || empty$; }, }, blinding: empty$, - route: makeRoute(), + route, scanner: empty$, search: empty$, storage: { @@ -81,6 +107,65 @@ test("requests and consumes block templates on an Elements dashboard", () => { ]); }); +test("delays a new-tip poll and resets the regular template cadence", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const start$ = new Subject(); + const newBlock$ = new Subject(); + const polls = []; + + scheduler.maxFrames = 57_000; + scheduleBlockTemplatePolls(start$, newBlock$, scheduler) + .subscribe((pollIndex) => polls.push([scheduler.frame, pollIndex])); + scheduler.schedule(() => start$.next(), 0); + scheduler.schedule(() => newBlock$.next(), 12_000); + scheduler.flush(); + + assert.deepEqual(polls, [ + [0, 0], + [27_000, 0], + [57_000, 1], + ]); +}); + +test("starts template polling when a delayed Liquid dashboard becomes ready", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const view$ = new Subject(); + const newBlock$ = new Subject(); + const polls = []; + + scheduler.maxFrames = 1_000; + scheduleDashboardBlockTemplatePolls(view$, newBlock$, scheduler) + .subscribe((pollIndex) => polls.push([scheduler.frame, pollIndex])); + scheduler.schedule(() => view$.next("loading"), 0); + scheduler.schedule(() => view$.next("dashBoard"), 250); + scheduler.flush(); + + assert.deepEqual(polls, [[250, 0]]); +}); + +test("restarts template polling when the dashboard is reopened", () => { + const scheduler = new TestScheduler((actual, expected) => + assert.deepEqual(actual, expected)); + const view$ = new Subject(); + const newBlock$ = new Subject(); + const polls = []; + + scheduler.maxFrames = 1_000; + scheduleDashboardBlockTemplatePolls(view$, newBlock$, scheduler) + .subscribe((pollIndex) => polls.push([scheduler.frame, pollIndex])); + scheduler.schedule(() => view$.next("dashBoard"), 0); + scheduler.schedule(() => view$.next("tx"), 250); + scheduler.schedule(() => view$.next("dashBoard"), 500); + scheduler.flush(); + + assert.deepEqual(polls, [ + [0, 0], + [500, 0], + ]); +}); + test("navigates a selected pending-block transaction in app history", () => { const txid = "a".repeat(64); const routeUpdates = []; @@ -98,6 +183,27 @@ test("navigates a selected pending-block transaction in app history", () => { ]); }); +test("requests predecessor metadata for confirmed block intervals", () => { + const hash = "a".repeat(64); + const previousHash = "b".repeat(64); + const blockResponses = new Subject(); + const requests = []; + const sources = makeSources({ + responseStreams: { block: blockResponses }, + route: makeBlockRoute(hash), + }); + + main(sources).HTTP.subscribe((request) => requests.push(request)); + blockResponses.next(O.of({ + body: { id: hash, previousblockhash: previousHash }, + })); + + assert.ok(requests.some((request) => + request.category === "previous-block" && + request.url === `/api/block/${previousHash}` + )); +}); + test("ignores block template responses for an older tip", () => { const firstTip = "a".repeat(64); const secondTip = "b".repeat(64); diff --git a/test/block-details-card.test.js b/test/block-details-card.test.js index 4a09b1be..24f88eca 100644 --- a/test/block-details-card.test.js +++ b/test/block-details-card.test.js @@ -23,6 +23,7 @@ const t = (parts, ...values) => parts.reduce( const block = { id: "a".repeat(64), height: 100, + previousblockhash: "c".repeat(64), timestamp: 1_700_000_000, tx_count: 2, size: 1_000_000, @@ -32,6 +33,11 @@ const block = { merkle_root: "b".repeat(64), }; +const previousBlock = { + id: block.previousblockhash, + timestamp: block.timestamp - 10 * 60, +}; + const emptyTemplateMetrics = { averageFeeRate: null, feeBuckets: { low: null, medium: null, high: null }, @@ -85,7 +91,7 @@ test("passes block detail copy through localization", () => { "Number of transactions included in this block.", "Size", "Time Since Last Block", - "Time elapsed since this block was mined.", + "Time elapsed between this block and the previous block.", "Transactions", "Version", "Version bits recorded in the block header.", @@ -132,6 +138,17 @@ test("disables block details until block metadata is available", () => { ); }); +test("shows the interval from the displayed block to its predecessor", () => { + const html = render(BlockDetailsCard({ + block, + detailsOpen: true, + previousBlock, + t, + })); + + assert.equal((html.match(/>10m { const props = { bitcoinMarketChart: null, diff --git a/www/style.css b/www/style.css index 9da6f397..beee795e 100644 --- a/www/style.css +++ b/www/style.css @@ -2590,7 +2590,7 @@ dl.mempool-histogram .bar:before { } .toggle-menu .section1 .wallets-link .store-icons a:hover{ - color: #00ccff; + color: var(--accent-color); } .toggle-menu .section1, .toggle-menu .section2{ @@ -2619,7 +2619,7 @@ dl.mempool-histogram .bar:before { } .toggle-menu .section2 a:hover{ - color: #00ccff; + color: var(--accent-color); } .sub-navbar{ @@ -3502,7 +3502,8 @@ a.back-link img{ } .tooltip:hover .tooltip-dialogue, -.tooltip:focus .tooltip-dialogue { +.tooltip:focus .tooltip-dialogue, +.tooltip.tooltip-open .tooltip-dialogue { display: initial; } @@ -4133,7 +4134,7 @@ a.back-link img{ .mempool-congestion { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; margin-top: 10px; } @@ -4814,12 +4815,32 @@ a.back-link img{ border-radius: var(--max-border-radius); } -@media only screen and (max-width: 820px) { +@media only screen and (max-width: 1328px) { .asset-table { - align-items: flex-start; + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + align-items: center; + } + + .asset-table-body { + display: contents; + } + + .asset-icon-container { + grid-column: 1; + grid-row: 1; + } + + .asset-title-row { + grid-column: 2; + grid-row: 1; + min-width: 0; + flex-wrap: wrap; } .asset-table-details { + grid-column: 1 / -1; + grid-row: 2; align-items: flex-start; flex-direction: column; } @@ -4953,7 +4974,7 @@ a.back-link img{ .pending-block-container { width: 100%; - --pending-block-grid-size: 580px; + --pending-block-grid-size: 559px; height: var(--pending-block-grid-size); box-sizing: border-box; padding: 8px;