Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions client/src/components/asset-icon.js
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`asset-icon-container${loading ? ' asset-icon-loading' : src ? ' asset-icon-loaded' : ''}`}
key={src || 'default'}
aria-busy={loading ? 'true' : 'false'}
hook-insert={watchImage}
hook-destroy={stopWatchingImage}
>
<DefaultAssetIcon />
{loading ? <div className="asset-icon-loading-indicator" aria-hidden="true">{loader('small', { immediate: true })}</div> : null}
{src ? <img className="asset-icon-image" src={src} alt="" attrs-referrerpolicy="no-referrer" /> : null}
</div>
)
}
4 changes: 2 additions & 2 deletions client/src/components/loading.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default (size) =>
export default (size, { immediate } = {}) =>
<div className="loading-container">
<div className="spinner">
<div className={`spinner${immediate ? ' spinner-immediate' : ''}`}>
<div className={`ring ${size}`}></div>
</div>
</div>
14 changes: 7 additions & 7 deletions client/src/views/asset.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -108,9 +110,7 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin
[
<div className="asset-page">
<div className="table asset-table">
<div className="asset-icon-container">
<DefaultAssetIcon/>
</div>
<AssetIcon href={registry.icon && registry.icon.href} />
<div className="asset-table-body">
<div className="asset-title-row">
<h1 className="asset-title">{asset_title}</h1>
Expand Down Expand Up @@ -155,11 +155,11 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin
<div className="detail-grid-row">
{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)
)}
</div>
<div className="detail-grid-row">
Expand Down
2 changes: 1 addition & 1 deletion client/src/views/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default ({
{loading ? (
<div className="load-more load-more-loading g-btn primary-btn font-btn-2 disabled">
<span>{t`Loading...`}</span>
<div>{loader("small")}</div>
<div>{loader("small", { immediate: true })}</div>
</div>
) : (
pagingNav(b, { ...S, t })
Expand Down
105 changes: 105 additions & 0 deletions test/asset.test.js
Original file line number Diff line number Diff line change
@@ -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}</p>`));
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");
});
59 changes: 59 additions & 0 deletions www/css/components/asset-icon.css
Original file line number Diff line number Diff line change
@@ -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;
}
}

99 changes: 99 additions & 0 deletions www/css/layout/asset.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 3 additions & 1 deletion www/css/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Loading