@@ -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;