diff --git a/LICENSE b/LICENSE index 21df596..3794f6d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ -Zero-Clause BSD -=============== +BSD Zero Clause License + +Copyright (c) 2026 QuickMythril and Qortium Minting contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. diff --git a/src/App.tsx b/src/App.tsx index 5c6e90d..ec57ec7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ import { Blocks, type BlockOnlineState } from './views/Blocks'; import { Minters } from './views/Minters'; import { MyMinting, type SelectedMintingDetails } from './views/MyMinting'; import { Reference } from './views/Reference'; -import { NodeSyncPill, Notice } from './ui'; +import { AvatarActionsProvider, NodeSyncPill, Notice } from './ui'; import type { AccountEnrichment, BlockSummary, @@ -787,7 +787,8 @@ export default function App() { const canRemove = writeAvailable && hasAction(actions, 'REMOVE_MINTING_ACCOUNT'); return ( -
+ +

Minting {APP_VERSION}

@@ -924,6 +925,7 @@ export default function App() { ) : null} {tab === 'reference' ? : null} -
+
+ ); } diff --git a/src/avatarClient.test.ts b/src/avatarClient.test.ts new file mode 100644 index 0000000..fe970fe --- /dev/null +++ b/src/avatarClient.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AVATAR_MAX_BYTES, fetchAccountAvatar, revokeAvatarObjectUrl } from './avatarClient'; +import { qdnRequest } from './qdnRequest'; + +vi.mock('./qdnRequest', () => ({ + hasAction: (actions: string[], ...candidates: string[]) => + candidates.some((candidate) => actions.some((action) => action.toUpperCase() === candidate.toUpperCase())), + qdnRequest: vi.fn(), +})); + +const ADDRESS = `Q${'1'.repeat(33)}`; + +describe('pointer-aware account avatar client', () => { + const qdnRequestMock = vi.mocked(qdnRequest); + const createObjectURLMock = vi.fn((blob: Blob) => `blob:mock/${blob.type}`); + const revokeObjectURLMock = vi.fn(); + + beforeEach(() => { + qdnRequestMock.mockReset(); + createObjectURLMock.mockClear(); + revokeObjectURLMock.mockClear(); + (URL as unknown as { createObjectURL: typeof createObjectURLMock }).createObjectURL = createObjectURLMock; + (URL as unknown as { revokeObjectURL: typeof revokeObjectURLMock }).revokeObjectURL = revokeObjectURLMock; + }); + + it('feature-gates avatar reads and validates the address before requesting', async () => { + await expect(fetchAccountAvatar(ADDRESS, ['FETCH_NODE_API'])).resolves.toEqual({ kind: 'unavailable' }); + await expect(fetchAccountAvatar('not-an-address', ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'unavailable' }); + expect(qdnRequestMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['POINTER', { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }], + ['LEGACY', null], + ] as const)('creates a safe Blob URL for a ready %s avatar', async (source, descriptor) => { + qdnRequestMock.mockResolvedValueOnce({ + address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor, encoding: 'base64', source, + }); + + await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ + kind: 'ready', source, src: 'blob:mock/image/png', + }); + expect(qdnRequestMock).toHaveBeenCalledWith({ action: 'FETCH_ACCOUNT_AVATAR', address: ADDRESS, maxBytes: AVATAR_MAX_BYTES }); + }); + + it('accepts BMP images and exposes explicit pending retries', async () => { + qdnRequestMock + .mockResolvedValueOnce({ + address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/bmp', + descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }, encoding: 'base64', source: 'POINTER', + }) + .mockResolvedValueOnce({ + address: ADDRESS, descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }, + retryAfterSeconds: 99, source: 'POINTER', status: 'PENDING', + }); + + await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toMatchObject({ kind: 'ready', src: 'blob:mock/image/bmp' }); + await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'pending', retryAfterSeconds: 30, source: 'POINTER' }); + }); + + it.each([ + { address: `Q${'2'.repeat(33)}`, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' }, + { address: ADDRESS, body: 'not base64!', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' }, + { address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 9, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' }, + { address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/svg+xml', descriptor: null, encoding: 'base64', source: 'LEGACY' }, + { address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: AVATAR_MAX_BYTES + 1, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' }, + { address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'POINTER' }, + ])('rejects malformed or unsafe avatar responses', async (response) => { + qdnRequestMock.mockResolvedValueOnce(response); + await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'unavailable' }); + expect(createObjectURLMock).not.toHaveBeenCalled(); + }); + + it('revokes only Blob URLs', () => { + revokeAvatarObjectUrl('https://node.example/avatar.png'); + revokeAvatarObjectUrl('blob:mock/image/png'); + expect(revokeObjectURLMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/avatarClient.ts b/src/avatarClient.ts new file mode 100644 index 0000000..7a0882e --- /dev/null +++ b/src/avatarClient.ts @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react'; +import { hasAction, qdnRequest } from './qdnRequest'; +import type { QdnAction } from './types'; + +export const AVATAR_MAX_BYTES = 500 * 1024; + +const SAFE_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/webp']); +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +type AvatarSource = 'POINTER' | 'LEGACY'; +type AvatarDescriptor = { identifier: string; name: string; service: string }; + +export type AccountAvatarFetch = + | { kind: 'pending'; retryAfterSeconds: number; source: AvatarSource } + | { kind: 'ready'; source: AvatarSource; src: string } + | { kind: 'unavailable' }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function text(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isAccountAddress(value: string) { + return /^Q[1-9A-HJ-NP-Za-km-z]{20,80}$/.test(value); +} + +function parseDescriptor(value: unknown): AvatarDescriptor | null { + if (!isRecord(value)) return null; + const service = text(value.service); + const name = text(value.name); + return service && name && typeof value.identifier === 'string' ? { identifier: value.identifier, name, service } : null; +} + +function decodeBase64(value: string) { + if (!value || !BASE64_PATTERN.test(value)) return null; + try { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return null; + } +} + +function parseAccountAvatar(value: unknown, expectedAddress: string): AccountAvatarFetch { + if (!isRecord(value) || value.address !== expectedAddress || (value.source !== 'POINTER' && value.source !== 'LEGACY')) { + return { kind: 'unavailable' }; + } + + const source = value.source; + if (source === 'POINTER' && !parseDescriptor(value.descriptor)) return { kind: 'unavailable' }; + + if (value.status === 'PENDING') { + const requestedDelay = typeof value.retryAfterSeconds === 'number' && Number.isFinite(value.retryAfterSeconds) + ? value.retryAfterSeconds + : 5; + return { kind: 'pending', retryAfterSeconds: Math.min(Math.max(Math.floor(requestedDelay), 1), 30), source }; + } + + if (value.encoding !== 'base64' || typeof value.body !== 'string' || typeof value.contentType !== 'string') { + return { kind: 'unavailable' }; + } + + const contentType = value.contentType.toLowerCase().split(';', 1)[0]; + const contentLength = value.contentLength; + const bytes = decodeBase64(value.body); + + if ( + !SAFE_IMAGE_MIME_TYPES.has(contentType) + || typeof contentLength !== 'number' + || !Number.isSafeInteger(contentLength) + || contentLength < 1 + || contentLength > AVATAR_MAX_BYTES + || !bytes + || bytes.byteLength !== contentLength + ) return { kind: 'unavailable' }; + + return { kind: 'ready', source, src: URL.createObjectURL(new Blob([bytes.buffer], { type: contentType })) }; +} + +/** Read one avatar through Home. The host owns legacy fallback; apps never make thumbnail URLs. */ +export async function fetchAccountAvatar(address: string, actions?: QdnAction[]): Promise { + if (!isAccountAddress(address) || !hasAction(actions ?? [], 'FETCH_ACCOUNT_AVATAR')) return { kind: 'unavailable' }; + try { + return parseAccountAvatar( + await qdnRequest({ action: 'FETCH_ACCOUNT_AVATAR', address, maxBytes: AVATAR_MAX_BYTES }), + address, + ); + } catch { + return { kind: 'unavailable' }; + } +} + +export function revokeAvatarObjectUrl(src: string | null | undefined) { + if (typeof src === 'string' && src.startsWith('blob:')) URL.revokeObjectURL(src); +} + +/** Only mounted avatar controls call this hook, so list loading cannot batch-fetch image bytes. */ +export function useAccountAvatar(address: string, actions?: QdnAction[]) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let active = true; + let retryTimer: number | undefined; + + const replace = (next: string | null) => { + setSrc((current) => { + if (current && current !== next) revokeAvatarObjectUrl(current); + return next; + }); + }; + + const load = () => { + void fetchAccountAvatar(address, actions).then((result) => { + if (!active) { + if (result.kind === 'ready') revokeAvatarObjectUrl(result.src); + return; + } + if (result.kind === 'ready') { + replace(result.src); + } else if (result.kind === 'pending') { + retryTimer = window.setTimeout(load, result.retryAfterSeconds * 1_000); + } else { + replace(null); + } + }); + }; + + replace(null); + load(); + return () => { + active = false; + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + }; + }, [actions, address]); + + useEffect(() => () => revokeAvatarObjectUrl(src), [src]); + return src; +} diff --git a/src/coreApi.test.ts b/src/coreApi.test.ts index 0606b6f..4a9fca5 100644 --- a/src/coreApi.test.ts +++ b/src/coreApi.test.ts @@ -27,7 +27,7 @@ describe('core API bridge helpers', () => { ]); await expect(resolveIdentities(['Qabc', 'Qabc', ''], ['RESOLVE_IDENTITIES'])).resolves.toEqual([ - { address: 'Qabc', avatarSrc: 'http://node/avatar', name: 'alice' }, + { address: 'Qabc', name: 'alice' }, ]); expect(qdnRequestMock).toHaveBeenCalledWith({ action: 'RESOLVE_IDENTITIES', diff --git a/src/coreApi.ts b/src/coreApi.ts index 2ccfe82..8376b9e 100644 --- a/src/coreApi.ts +++ b/src/coreApi.ts @@ -42,13 +42,13 @@ const DEFAULT_RECENT_BLOCKS = 10; // FETCH_NODE_API calls at once. const BLOCK_FETCH_CONCURRENCY = 8; -// Cross-call caches for name/avatar lookups, which almost never change between +// Cross-call caches for name lookups, which almost never change between // refreshes. Shared across getRecentBlocks, getBlockOnlineAccounts, and // enrichMintingAccount so repeated refreshes/expands hit the cache instead of // refetching. Entries store the in-flight Promise so concurrent lookups for the // same address share a single request (true dedupe of the initial burst). -const AVATAR_CACHE_MAX_ENTRIES = 500; -const avatarProfileCache = new Map>(); +const IDENTITY_CACHE_MAX_ENTRIES = 500; +const identityProfileCache = new Map>(); // Resolve a list of tasks with a bounded concurrency pool while preserving the // input order in the returned array. @@ -199,9 +199,8 @@ export async function getAccountNames(address: string, actions?: QdnAction[]) { export const RESOLVE_IDENTITIES_LIMIT = 500; -// Home resolves account display identity (registered name + avatar URL) in one -// read-only bridge call. This keeps selected-account UI consistent with other -// QDN apps and avoids separate name/avatar requests. +// Home resolves account display identity in one read-only bridge call. Apps use +// its name field only; any legacy image hint never reaches UI state. export async function resolveIdentities(addresses: string[], actions?: QdnAction[]): Promise { if (!hasBridgeAction(actions, 'RESOLVE_IDENTITIES')) { throw new Error('RESOLVE_IDENTITIES is not available in this Home build.'); @@ -217,7 +216,9 @@ export async function resolveIdentities(addresses: string[], actions?: QdnAction }); if (Array.isArray(batch)) { - resolved.push(...batch); + resolved.push(...batch + .filter((identity): identity is ResolvedIdentity => !!identity && typeof identity.address === 'string') + .map((identity) => ({ address: identity.address, name: normalizeRegisteredName(identity.name) }))); } } @@ -368,11 +369,11 @@ function resolveMintingAccountAddress(account: NodeMintingAccount) { return normalizeRegisteredName(account.address) ?? normalizeRegisteredName(account.mintingAccount); } -// Avatar/name resolution is expensive (QDN resource fetch) and effectively -// static, so share one in-flight Promise per address+actions across refreshes. -function resolveAvatarProfile(address: string, actions?: QdnAction[]): Promise { +// Name resolution is shared across refreshes. Avatar bytes are intentionally +// not part of this cache: mounted Avatar controls fetch those through Home. +function resolveIdentityProfile(address: string, actions?: QdnAction[]): Promise { const cacheKey = `${address}\n${(actions ?? []).join(',')}`; - const cached = avatarProfileCache.get(cacheKey); + const cached = identityProfileCache.get(cacheKey); if (cached !== undefined) { return cached; @@ -380,12 +381,12 @@ function resolveAvatarProfile(address: string, actions?: QdnAction[]): Promise { // Do not cache failures; allow a later refresh to retry. - avatarProfileCache.delete(cacheKey); + identityProfileCache.delete(cacheKey); throw error; }); - setCapped(avatarProfileCache, cacheKey, pending, AVATAR_CACHE_MAX_ENTRIES); + setCapped(identityProfileCache, cacheKey, pending, IDENTITY_CACHE_MAX_ENTRIES); return pending; } @@ -414,20 +415,16 @@ async function enrichMintingAccount(account: NodeMintingAccount, actions?: QdnAc } let name: string | null = null; - let avatarSrc: string | null = null; - try { - const profile = await resolveAvatarProfile(address, actions); + const profile = await resolveIdentityProfile(address, actions); name = profile.name; - avatarSrc = profile.avatarSrc; } catch { - // Name/avatar resolution is best-effort. + // Name resolution is best-effort. } return { address, - avatarSrc, blocksMinted, level, name, @@ -478,7 +475,6 @@ function toBlockSummary( ): BlockSummary { return { height, - minterAvatarSrc: identity?.avatarSrc ?? null, minterAddress: normalizeRegisteredName(mintingInfo?.minterAddress), minterLevel: typeof mintingInfo?.minterLevel === 'number' ? mintingInfo.minterLevel : null, minterName: identity?.name ?? null, @@ -590,7 +586,6 @@ export async function getBlockOnlineAccounts( return raw.map((entry) => { const profile = identitiesByAddress.get(entry.minter); return { - avatarSrc: profile?.avatarSrc ?? null, level: typeof entry.level === 'number' ? entry.level : null, minter: entry.minter, name: profile?.name ?? normalizeRegisteredName(entry.name), @@ -640,7 +635,6 @@ export async function getCurrentOnlineAccounts(actions?: QdnAction[]): Promise { vi.mocked(hasHomeBridge).mockReturnValue(true); }); - it('uses the batch identity action and preserves missing entries', async () => { - vi.mocked(qdnRequest).mockResolvedValue([ - { address: 'Qone', name: 'one', avatarSrc: 'url' }, - ]); + it('uses the batch identity action for names and discards legacy avatar URLs', async () => { + vi.mocked(qdnRequest).mockResolvedValue([{ address: 'Qone', name: 'one', avatarSrc: 'https://node/avatar' }]); await expect(loadIdentityProfiles(['Qone', 'Qtwo'], ['RESOLVE_IDENTITIES'])).resolves.toEqual([ - { address: 'Qone', name: 'one', avatarSrc: 'url' }, - { address: 'Qtwo', name: null, avatarSrc: null }, + { address: 'Qone', name: 'one' }, + { address: 'Qtwo', name: null }, ]); }); @@ -27,35 +25,17 @@ describe('identity profiles', () => { const addresses = Array.from({ length: 501 }, (_, index) => `Q${index}`); vi.mocked(qdnRequest) .mockImplementationOnce(async (request) => { - const batch = 'addresses' in request && Array.isArray(request.addresses) - ? request.addresses - : []; - return [...batch].reverse().map((address) => ({ - address, - avatarSrc: `avatar:${address}`, - name: `name:${address}`, - })); + const batch = 'addresses' in request && Array.isArray(request.addresses) ? request.addresses : []; + return [...batch].reverse().map((address) => ({ address, avatarSrc: `legacy:${address}`, name: `name:${address}` })); }) - .mockResolvedValueOnce([ - { address: 'Q500', avatarSrc: 'avatar:Q500', name: 'name:Q500' }, - ]); + .mockResolvedValueOnce([{ address: 'Q500', avatarSrc: 'legacy:Q500', name: 'name:Q500' }]); const resolved = await loadIdentityProfiles(addresses, ['RESOLVE_IDENTITIES']); - expect(qdnRequest).toHaveBeenNthCalledWith(1, { - action: 'RESOLVE_IDENTITIES', - addresses: addresses.slice(0, 500), - }); - expect(qdnRequest).toHaveBeenNthCalledWith(2, { - action: 'RESOLVE_IDENTITIES', - addresses: ['Q500'], - }); + expect(qdnRequest).toHaveBeenNthCalledWith(1, { action: 'RESOLVE_IDENTITIES', addresses: addresses.slice(0, 500) }); + expect(qdnRequest).toHaveBeenNthCalledWith(2, { action: 'RESOLVE_IDENTITIES', addresses: ['Q500'] }); expect(resolved.map((profile) => profile.address)).toEqual(addresses); - expect(resolved[500]).toEqual({ - address: 'Q500', - avatarSrc: 'avatar:Q500', - name: 'name:Q500', - }); + expect(resolved[500]).toEqual({ address: 'Q500', name: 'name:Q500' }); }); it('normalizes empty names', () => expect(normalizeRegisteredName('')).toBeNull()); diff --git a/src/identityProfiles.ts b/src/identityProfiles.ts index 6ec0e84..71b2269 100644 --- a/src/identityProfiles.ts +++ b/src/identityProfiles.ts @@ -1,65 +1,117 @@ import { hasHomeBridge, qdnRequest } from './qdnRequest'; import type { NameSummary, QdnAction } from './types'; -const AVATAR_MAX_BYTES = 500 * 1024; const NEGATIVE_CACHE_COOLDOWN_MS = 5 * 60 * 1000; const NEGATIVE_CACHE_MAX_ENTRIES = 1000; -const RASTER_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const negativeCache = new Map(); const inFlight = new Map>(); -export type IdentityProfile = { address: string; avatarSrc: string | null; name: string | null }; - -export function normalizeRegisteredName(name: string | null | undefined) { return typeof name === 'string' && name.length > 0 ? name : null; } -export function getAvatarFallbackCharacter(name: string | null | undefined) { return normalizeRegisteredName(name) ? Array.from(name!)[0] ?? '?' : '?'; } -export function hasBridgeAction(actions: QdnAction[] | undefined, action: string) { return actions?.some((value) => value.toUpperCase() === action.toUpperCase()) ?? false; } -export function getFirstRegisteredName(names: NameSummary[]) { return names.map((entry) => normalizeRegisteredName(entry.name)).find(Boolean) ?? null; } -function record(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -function text(value: unknown, key: string) { return record(value) && typeof value[key] === 'string' ? value[key] as string : ''; } -function sniffMime(base64: string) { if (base64.startsWith('iVBORw0KGgo')) return 'image/png'; if (base64.startsWith('/9j/')) return 'image/jpeg'; if (base64.startsWith('R0lGOD')) return 'image/gif'; if (base64.startsWith('UklGR')) return 'image/webp'; return 'image/png'; } -async function localDataUri(name: string) { - const status = await qdnRequest({ action: 'FETCH_NODE_API', path: `/arbitrary/resource/status/THUMBNAIL/${encodeURIComponent(name)}/avatar`, maxBytes: 64 * 1024 }); - const statusData = record(status) && 'data' in status ? status.data : status; - if (text(statusData, 'status') === 'NOT_PUBLISHED') throw new Error('Avatar is not published.'); - const image = await qdnRequest({ action: 'FETCH_NODE_API', path: `/arbitrary/THUMBNAIL/${encodeURIComponent(name)}/avatar?encoding=base64`, maxBytes: AVATAR_MAX_BYTES }); - const payload: unknown = record(image) ? image['data'] : image; - const base64 = typeof payload === 'string' ? payload.trim() : ''; - if (!base64) throw new Error('Avatar was empty.'); - const declared = text(statusData, 'mimeType').toLowerCase(); - return `data:${RASTER_MIMES.has(declared) ? declared : sniffMime(base64)};base64,${base64}`; + +// Identity batching is deliberately names-only. Avatar bytes are fetched by a +// mounted Avatar control through the pointer-aware bridge client, never during +// background list enrichment. +export type IdentityProfile = { address: string; name: string | null }; + +export function normalizeRegisteredName(name: string | null | undefined) { + return typeof name === 'string' && name.length > 0 ? name : null; } -export async function fetchAvatarImage(name: string, actions?: QdnAction[]) { - if (hasHomeBridge() && hasBridgeAction(actions, 'GET_QDN_RESOURCE_URL')) { - const url = await qdnRequest({ action: 'GET_QDN_RESOURCE_URL', service: 'THUMBNAIL', name, identifier: 'avatar' }); - if (typeof url === 'string' && url) return url; - throw new Error('No avatar render URL.'); - } - return localDataUri(name); + +export function getAvatarFallbackCharacter(name: string | null | undefined) { + return normalizeRegisteredName(name) ? Array.from(name!)[0] ?? '?' : '?'; +} + +export function hasBridgeAction(actions: QdnAction[] | undefined, action: string) { + return actions?.some((value) => value.toUpperCase() === action.toUpperCase()) ?? false; +} + +export function getFirstRegisteredName(names: NameSummary[]) { + return names.map((entry) => normalizeRegisteredName(entry.name)).find(Boolean) ?? null; +} + +function record(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function rememberFailure(address: string) { + negativeCache.delete(address); + negativeCache.set(address, Date.now()); + while (negativeCache.size > NEGATIVE_CACHE_MAX_ENTRIES) negativeCache.delete(negativeCache.keys().next().value!); } -function rememberFailure(address: string) { negativeCache.delete(address); negativeCache.set(address, Date.now()); while (negativeCache.size > NEGATIVE_CACHE_MAX_ENTRIES) negativeCache.delete(negativeCache.keys().next().value!); } + async function getName(address: string, actions?: QdnAction[]) { - const names = await qdnRequest(hasBridgeAction(actions, 'GET_ACCOUNT_NAMES') ? { action: 'GET_ACCOUNT_NAMES', address } : { action: 'FETCH_NODE_API', path: `/names/address/${encodeURIComponent(address)}` }); + const names = await qdnRequest( + hasBridgeAction(actions, 'GET_ACCOUNT_NAMES') + ? { action: 'GET_ACCOUNT_NAMES', address } + : { action: 'FETCH_NODE_API', path: `/names/address/${encodeURIComponent(address)}` }, + ); const data = Array.isArray(names) ? names : record(names) && Array.isArray(names.data) ? names.data as NameSummary[] : []; return getFirstRegisteredName(data); } + async function resolveOne(address: string, actions?: QdnAction[]): Promise { - let name: string | null = null; - try { name = await getName(address, actions); } catch { /* keep nameless */ } - if (!name) return { address, avatarSrc: null, name: null }; const failedAt = negativeCache.get(address); - if (failedAt && Date.now() - failedAt < NEGATIVE_CACHE_COOLDOWN_MS) return { address, avatarSrc: null, name }; - try { const avatarSrc = await fetchAvatarImage(name, actions); negativeCache.delete(address); return { address, avatarSrc, name }; } - catch { rememberFailure(address); return { address, avatarSrc: null, name }; } + if (failedAt && Date.now() - failedAt < NEGATIVE_CACHE_COOLDOWN_MS) return { address, name: null }; + + try { + const name = await getName(address, actions); + negativeCache.delete(address); + return { address, name }; + } catch { + rememberFailure(address); + return { address, name: null }; + } } + export function loadIdentityProfile(address: string, actions?: QdnAction[]) { - const current = inFlight.get(address); if (current) return current; - const pending = resolveOne(address, actions).finally(() => inFlight.delete(address)); inFlight.set(address, pending); return pending; + const current = inFlight.get(address); + if (current) return current; + const pending = resolveOne(address, actions).finally(() => inFlight.delete(address)); + inFlight.set(address, pending); + return pending; +} + +async function parallel(items: T[], task: (item: T) => Promise, limit = 6) { + const results = new Array(items.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await task(items[index]); + } + })); + return results; } -async function parallel(items: T[], task: (item: T) => Promise, limit = 6) { const results = new Array(items.length); let cursor = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; results[index] = await task(items[index]); } })); return results; } + export async function loadIdentityProfiles(addresses: string[], actions?: QdnAction[]) { if (!addresses.length) return []; + if (hasHomeBridge() && hasBridgeAction(actions, 'RESOLVE_IDENTITIES')) { - try { const output: IdentityProfile[] = []; for (let offset = 0; offset < addresses.length; offset += 500) { const batch = addresses.slice(offset, offset + 500); const resolved = await qdnRequest<{ address?: string; avatarSrc?: string; name?: string }[]>({ action: 'RESOLVE_IDENTITIES', addresses: batch }); const byAddress = new Map((Array.isArray(resolved) ? resolved : []).filter((entry): entry is { address: string; avatarSrc?: string; name?: string } => !!entry.address).map((entry) => [entry.address, entry])); output.push(...batch.map((address) => { const entry = byAddress.get(address); const name = normalizeRegisteredName(entry?.name); return { address, name, avatarSrc: name && entry?.avatarSrc ? entry.avatarSrc : null }; })); } return output; } catch { /* fall through */ } + try { + const output: IdentityProfile[] = []; + for (let offset = 0; offset < addresses.length; offset += 500) { + const batch = addresses.slice(offset, offset + 500); + const resolved = await qdnRequest<{ address?: string; name?: string }[]>({ + action: 'RESOLVE_IDENTITIES', + addresses: batch, + }); + const byAddress = new Map( + (Array.isArray(resolved) ? resolved : []) + .filter((entry): entry is { address: string; name?: string } => !!entry.address) + .map((entry) => [entry.address, entry]), + ); + output.push(...batch.map((address) => ({ + address, + name: normalizeRegisteredName(byAddress.get(address)?.name), + }))); + } + return output; + } catch { + // A Home build without batch identities still gets the names-only fallback. + } } + return parallel(addresses, (address) => loadIdentityProfile(address, actions)); } -export function getIdentityLabel(profile: IdentityProfile | undefined, address: string) { return profile?.name ?? address; } + +export function getIdentityLabel(profile: IdentityProfile | undefined, address: string) { + return profile?.name ?? address; +} diff --git a/src/types.ts b/src/types.ts index 221a250..e4f3e9a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,6 @@ export type BridgeState = { export type QdnSelectedAccount = { address: string; - avatarUrl: string | null; id?: string; isUnlocked: boolean; name: string | null; @@ -46,7 +45,6 @@ export type NameSummary = { export type ResolvedIdentity = { address: string; - avatarSrc?: string | null; name?: string | null; }; @@ -123,10 +121,9 @@ export type NodeAccountInfo = { }; // A minting-key account known to the connected node, enriched with on-chain -// account info, its registered name, and (optionally) a resolved avatar. +// account info and its registered name. export type MintingAccountInfo = { address: string; - avatarSrc: string | null; blocksMinted: number | null; level: number | null; name: string | null; @@ -167,7 +164,6 @@ export type NodeBlockData = { // A recent block, enriched with minter address/level/name from mintinginfo. export type BlockSummary = { height: number; - minterAvatarSrc: string | null; minterAddress: string | null; minterLevel: number | null; minterName: string | null; @@ -179,7 +175,6 @@ export type BlockSummary = { // One signed online-account entry for a block. // Shape returned by GET /blocks/onlineaccounts/{height} (Core DecodedOnlineAccountData). export type OnlineAccountEntry = { - avatarSrc: string | null; level: number | null; minter: string; name: string | null; diff --git a/src/ui.tsx b/src/ui.tsx index 8c96804..510843b 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -1,9 +1,16 @@ -import { memo, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'; +import { createContext, memo, useContext, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'; import { ArrowDown, ArrowDownUp, ArrowUp, Copy, Users } from 'lucide-react'; +import { useAccountAvatar } from './avatarClient'; import { copyTextToClipboard } from './clipboard'; import { getAvatarFallbackCharacter } from './identityProfiles'; import { getAriaSort, type MinterSortKey, type SortState } from './minterSort'; -import type { NodeStatus } from './types'; +import type { NodeStatus, QdnAction } from './types'; + +const AvatarActionsContext = createContext(undefined); + +export function AvatarActionsProvider({ actions, children }: { actions: QdnAction[]; children: ReactNode }) { + return {children}; +} export function Notice({ children, @@ -16,16 +23,17 @@ export function Notice({ } export function Avatar({ + address, className = '', name, - src, }: { + address: string; className?: string; name: string | null; - src: string | null; }) { const [failed, setFailed] = useState(false); - useEffect(() => setFailed(false), [src]); + const src = useAccountAvatar(address, useContext(AvatarActionsContext)); + useEffect(() => setFailed(false), [address, src]); return src && !failed ? ( void; @@ -117,7 +123,7 @@ export function AccountLink({ title={address} type="button" > - + {label} ); diff --git a/src/views/Blocks.tsx b/src/views/Blocks.tsx index 0f391a5..157dd0e 100644 --- a/src/views/Blocks.tsx +++ b/src/views/Blocks.tsx @@ -53,7 +53,6 @@ function OnlineAccountsTable({ @@ -187,7 +186,6 @@ export function Blocks({ {block.minterAddress ? (
@@ -152,7 +153,6 @@ export function Minters({ diff --git a/src/views/MyMinting.tsx b/src/views/MyMinting.tsx index d4d2f56..76e44a8 100644 --- a/src/views/MyMinting.tsx +++ b/src/views/MyMinting.tsx @@ -65,10 +65,9 @@ function MintingAccount({ return (
-

@@ -164,7 +163,6 @@ export function MyMinting({

diff --git a/src/views/Reference.tsx b/src/views/Reference.tsx index 777115e..87b4bc4 100644 --- a/src/views/Reference.tsx +++ b/src/views/Reference.tsx @@ -32,10 +32,12 @@ const snippets = { " path: '/groups/members/2?limit=250&offset=0'", '})', ].join('\n'), - 'avatar-url': [ - "qdnRequest({ action: 'GET_QDN_RESOURCE_URL',", - " service: 'THUMBNAIL', name, identifier: 'avatar'", - '})', + 'account-avatar': [ + "const actions = await qdnRequest({ action: 'SHOW_ACTIONS' })", + "if (actions.includes('FETCH_ACCOUNT_AVATAR')) {", + " const avatar = await qdnRequest({ action: 'FETCH_ACCOUNT_AVATAR', address, maxBytes: 500 * 1024 })", + " // Render a validated base64 response as a Blob URL; retry PENDING only.", + '}', ].join('\n'), 'watch-tx': [ 'const tx = await qdnRequest({', @@ -152,7 +154,8 @@ export function Reference() {
  • GET_HOST_INFO failure means an older host. IS_USING_PUBLIC_NODE reports write availability.
  • START_MINTING, JOIN_GROUP, and REMOVE_MINTING_ACCOUNT prompt for approval.
  • RESOLVE_IDENTITIES is limited to 500 addresses per request.
  • -
  • GET_QDN_RESOURCE_URL returns avatar render URLs.
  • +
  • FETCH_ACCOUNT_AVATAR is feature-gated, returns pointer-aware image bytes, and may be PENDING.
  • +
  • Keep batch identity resolution names-only; fetch avatars only for visible account UI.
  • QAVS remains 1.4.x because all newer behavior is feature-detected.