diff --git a/client/src/components/asset-icon.js b/client/src/components/asset-icon.js new file mode 100644 index 00000000..1fe6a1b5 --- /dev/null +++ b/client/src/components/asset-icon.js @@ -0,0 +1,41 @@ +import { DefaultAssetIcon } from './icons' +import loader from './loading' + +const watchImage = vnode => { + const container = vnode.elm + , image = container.querySelector('img') + + if (!image) return + const settle = loaded => { + container.classList.remove('asset-icon-loading') + container.classList.toggle('asset-icon-loaded', loaded) + container.setAttribute('aria-busy', 'false') + } + image.onload = () => settle(image.naturalWidth > 0) + image.onerror = () => settle(false) + if (image.complete) settle(image.naturalWidth > 0) +} + +const stopWatchingImage = vnode => { + const image = vnode.elm.querySelector('img') + if (image) image.onload = image.onerror = null +} + +export default ({ href }) => { + const src = typeof href === 'string' && /^https?:\/\//i.test(href) ? href : null + , loading = !!src && process.browser + + return ( +
+ + {loading ? : null} + {src ? : null} +
+ ) +} diff --git a/client/src/components/loading.js b/client/src/components/loading.js index 27fdbd2c..5350dfbd 100644 --- a/client/src/components/loading.js +++ b/client/src/components/loading.js @@ -1,6 +1,6 @@ -export default (size) => +export default (size, { immediate } = {}) =>
-
+
\ No newline at end of file diff --git a/client/src/views/asset.js b/client/src/views/asset.js index 22674f6d..66bd8959 100644 --- a/client/src/views/asset.js +++ b/client/src/views/asset.js @@ -4,13 +4,13 @@ import layout from './layout' import { txBox } from './tx' import { maxMempoolTxs, assetTxsPerPage as perPage, nativeAssetName } from '../const' import loader from '../components/loading' +import AssetIcon from '../components/asset-icon' import { ConfidentialBadge, StatusBadge, StatusDot } from '../components/status-badge' import { InfoStat } from '../components/info-stat' import { AssetContractIcon, BurnIcon, CopyIcon, - DefaultAssetIcon, LinkIcon, TxArrowsIcon } from '../components/icons' @@ -47,7 +47,9 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin , has_contract = !!asset.contract , is_unregistered = !is_native_asset && !has_contract , contract = asset.contract || {} + , contract_issuer_pubkey = contract.version === 2 ? contract.initial_issuer_pubkey : contract.issuer_pubkey , contract_entity = contract.entity || {} + , registry = asset.registry || {} , precision = asset.precision != null ? asset.precision : 0 , has_blinded_issuances = chain_stats.has_blinded_issuances || mempool_stats.has_blinded_issuances @@ -108,9 +110,7 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin [
-
- -
+

{asset_title}

@@ -155,11 +155,11 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin
{detailField('DOMAIN', contract_entity.domain, null, t)} {detailField( - 'ISSUER PUBKEY', - contract.issuer_pubkey, + contract.version === 2 ? 'INITIAL ISSUER PUBKEY' : 'ISSUER PUBKEY', + contract_issuer_pubkey, null, t, - shortenValue(contract.issuer_pubkey) + shortenValue(contract_issuer_pubkey) )}
diff --git a/client/src/views/block.js b/client/src/views/block.js index ca1148f9..dda93a80 100644 --- a/client/src/views/block.js +++ b/client/src/views/block.js @@ -100,7 +100,7 @@ export default ({ {loading ? (
{t`Loading...`} -
{loader("small")}
+
{loader("small", { immediate: true })}
) : ( pagingNav(b, { ...S, t }) diff --git a/test/asset.test.js b/test/asset.test.js new file mode 100644 index 00000000..769e62a1 --- /dev/null +++ b/test/asset.test.js @@ -0,0 +1,105 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const render = require("snabbdom-to-html"); + +const assetView = require("../client/src/views/asset").default; +const t = require("../client/src/l10n").default.en; + +const issuerPubkey = `02${"a".repeat(64)}`; +const initialIssuerPubkey = `03${"b".repeat(64)}`; + +const makeAsset = (overrides = {}) => ({ + asset_id: "c".repeat(64), + issuance_txin: { txid: "d".repeat(64), vin: 0 }, + name: "Example asset", + precision: 0, + chain_stats: {}, + mempool_stats: {}, + contract: { + version: 1, + name: "Example asset", + precision: 0, + entity: { domain: "example.com" }, + // Distinct keys catch selecting the wrong field by property presence alone. + issuer_pubkey: issuerPubkey, + initial_issuer_pubkey: initialIssuerPubkey, + }, + ...overrides, +}); + +const viewAsset = (asset) => assetView({ + asset, + assetTxs: [], + goAsset: { last_txids: [], est_chain_seen_count: 0 }, + page: { pathname: `/asset/${asset.asset_id}`, query: {} }, + t, +}); + +const findVNode = (vnode, predicate) => { + if (!vnode) return null; + if (predicate(vnode)) return vnode; + for (const child of vnode.children || []) { + const match = findVNode(child, predicate); + if (match) return match; + } + return null; +}; + +const hasClass = (vnode, className) => vnode.data && vnode.data.props && + (vnode.data.props.className || "").split(/\s+/).includes(className); + +for (const version of [0, 1, 2]) { + test(`renders and copies the correct contract issuer key for version ${version}`, () => { + const asset = makeAsset(); + asset.contract.version = version; + const contract = findVNode(viewAsset(asset), vnode => hasClass(vnode, "asset-contract-table")); + assert.ok(contract); + + const label = version === 2 ? "INITIAL ISSUER PUBKEY" : "ISSUER PUBKEY"; + const expected = version === 2 ? initialIssuerPubkey : issuerPubkey; + const other = version === 2 ? issuerPubkey : initialIssuerPubkey; + const html = render(contract); + const shortKey = key => `${key.slice(0, 10)}...${key.slice(-10)}`; + assert.ok(html.includes(`>${label}

`)); + assert.ok(html.includes(shortKey(expected))); + assert.ok(!html.includes(shortKey(other))); + assert.ok(!html.includes(other)); + + const copy = findVNode(contract, vnode => vnode.data && vnode.data.attrs && + vnode.data.attrs["aria-label"] === `Copy ${label.toLowerCase()}`); + assert.ok(copy); + assert.equal(copy.data.dataset.clipboardCopy, expected); + }); +} + +test("does not substitute a legacy issuer key when a v2 contract's initial key is missing", () => { + const asset = makeAsset(); + asset.contract.version = 2; + delete asset.contract.initial_issuer_pubkey; + const contract = findVNode(viewAsset(asset), vnode => hasClass(vnode, "asset-contract-table")); + const html = render(contract); + + assert.match(html, /INITIAL ISSUER PUBKEY[\s\S]*?N\/A/); + assert.ok(!html.includes(issuerPubkey)); + assert.doesNotMatch(html, /Copy initial issuer pubkey/); +}); + +test("renders the asset image URL supplied by the registry response", () => { + const href = "https://registry.example.com/v2/assets/icon.png"; + const vnode = viewAsset(makeAsset({ registry: { icon: { href } } })); + const icon = findVNode(vnode, node => hasClass(node, "asset-icon-container")); + assert.ok(icon); + const image = findVNode(icon, node => node.sel === "img"); + + assert.ok(image); + assert.equal(image.data.props.src, href); +}); + +test("renders the default asset icon when the response has no image", () => { + const icon = findVNode(viewAsset(makeAsset()), vnode => hasClass(vnode, "asset-icon-container")); + + assert.ok(icon); + assert.equal(findVNode(icon, vnode => vnode.sel === "img"), null); + assert.ok(findVNode(icon, vnode => vnode.sel === "svg")); + assert.equal(icon.data.attrs["aria-busy"], "false"); +}); diff --git a/www/css/components/asset-icon.css b/www/css/components/asset-icon.css new file mode 100644 index 00000000..91c92fe4 --- /dev/null +++ b/www/css/components/asset-icon.css @@ -0,0 +1,59 @@ +.asset-icon-container { + position: relative; + flex-shrink: 0; + overflow: hidden; + width: 80px; + height: 80px; + background-color: var(--surface-secondary-color); + border-radius: var(--max-border-radius); + display: flex; + justify-content: center; + align-items: center; + color: var(--accent-color); +} + +.asset-icon-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0; +} + +.asset-icon-loaded .asset-icon-image { + opacity: 1; +} + +.asset-icon-loading > svg, +.asset-icon-loaded > svg { + visibility: hidden; +} + +.asset-icon-loading-indicator { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; +} + +.asset-icon-loading .asset-icon-loading-indicator { + display: flex; +} + +.asset-icon-loading-indicator .loading-container { + width: 100%; + margin: 0; +} + +.asset-icon-loading-indicator .ring { + border-top-color: var(--accent-color); +} + +@media (prefers-reduced-motion: reduce) { + .asset-icon-loading-indicator .ring { + animation: none; + } +} + diff --git a/www/css/layout/asset.css b/www/css/layout/asset.css new file mode 100644 index 00000000..63f3bb10 --- /dev/null +++ b/www/css/layout/asset.css @@ -0,0 +1,99 @@ +.asset-table { + display: flex; + align-items: center; + gap: 16px; +} + +.asset-table-body { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + min-width: 0; +} + +.asset-title { + font-size: 28px; + font-weight: 700; + font-family: 'Rigid Square'; + overflow-wrap: anywhere; +} + +.asset-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.asset-table-details { + display: flex; + align-items: center; + gap: 24px; +} + +.asset-table-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.asset-table-issuance-link { + margin-left: auto; + font-size: 16px; + font-family: 'Rigid Square'; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.asset-table-issuance-link p { + margin-top: 2px; + line-height: 22px; +} + +.asset-contract-table { + display: flex; + flex-direction: column; + gap: 12px; +} + +.asset-page > .transactions { + margin-top: 0; +} + +@media only screen and (max-width: 1328px) { + .asset-table { + 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; + } + + .asset-table-issuance-link { + margin-left: 0; + } +} diff --git a/www/css/manifest.json b/www/css/manifest.json index 5937544f..33d626e2 100644 --- a/www/css/manifest.json +++ b/www/css/manifest.json @@ -7,5 +7,7 @@ "foundation/utilities.css", "../style.css", "components/status-badge.css", - "layout/footer.css" + "components/asset-icon.css", + "layout/footer.css", + "layout/asset.css" ] diff --git a/www/style.css b/www/style.css index 1b69ea2f..ec91ec1f 100644 --- a/www/style.css +++ b/www/style.css @@ -196,6 +196,11 @@ a, a:link, a:visited, a:hover, a:focus { animation-fill-mode: forwards; } +.spinner-immediate { + visibility: visible; + animation: none; +} + .spinner .ring{ border: 4px solid #f3f3f3; border-radius: 50%; @@ -1159,11 +1164,6 @@ img.footer_container_content_onion-links_icon { margin-top: 0; } -.load-more-loading .spinner { - visibility: visible; - animation: none; -} - .load-more-loading .spinner .small { box-sizing: border-box; width: 22px; @@ -4019,81 +4019,6 @@ a.back-link img{ color: var(--blockstream-primary-brand-color); } -.asset-table { - display: flex; - align-items: center; - gap: 16px; -} - -.asset-table-body { - display: flex; - flex-direction: column; - gap: 12px; - flex: 1; -} - -.asset-title { - font-size: 28px; - font-weight: 700; - font-family: 'Rigid Square'; - overflow-wrap: anywhere; -} - -.asset-title-row { - display: flex; - align-items: center; - gap: 8px; -} - -.asset-icon-container { - width: 80px; - height: 80px; - background-color: var(--surface-secondary-color); - border-radius: var(--max-border-radius); - display: flex; - justify-content: center; - align-items: center; - color: var(--accent-color); -} - -.asset-table-details { - display: flex; - align-items: center; - gap: 24px; -} - -.asset-table-stats { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.asset-table-issuance-link { - margin-left: auto; - font-size: 16px; - font-family: 'Rigid Square'; - font-weight: 700; - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; -} - -.asset-table-issuance-link p { - margin-top: 2px; - line-height: 22px; -} - -.asset-contract-table { - display: flex; - flex-direction: column; - gap: 12px; -} - -.asset-page > .transactions { - margin-top: 0; -} - .high-value-assets-table { padding: 24px; border-radius: 12px; @@ -4165,39 +4090,6 @@ a.back-link img{ } @media only screen and (max-width: 1328px) { - .asset-table { - 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; - } - - .asset-table-issuance-link { - margin-left: 0; - } - .overview-fees { flex-direction: column; gap: 0px;