diff --git a/packages/gui/src/@types/CacheService.ts b/packages/gui/src/@types/CacheService.ts index ad03ce30a9..3ccdc4883a 100644 --- a/packages/gui/src/@types/CacheService.ts +++ b/packages/gui/src/@types/CacheService.ts @@ -1,3 +1,5 @@ +import type CacheInfo from './CacheInfo'; + type CacheRequestOptions = { maxSize?: number; timeout?: number; @@ -22,6 +24,8 @@ type CacheService = { getChecksum: (url: string, options?: CacheRequestOptions) => Promise; getURI: (url: string, options?: CacheRequestOptions) => Promise; invalidate: (url: string) => Promise; + // Read-only lookup of the persisted cache state of each url — never downloads + getCacheInfos: (urls: string[]) => Promise; // Event subscriptions subscribeToDirectoryChange: (callback: (newDirectory: string) => void) => () => void; diff --git a/packages/gui/src/@types/NFTPreviewAvailability.ts b/packages/gui/src/@types/NFTPreviewAvailability.ts new file mode 100644 index 0000000000..9ad7f8b3a9 --- /dev/null +++ b/packages/gui/src/@types/NFTPreviewAvailability.ts @@ -0,0 +1,8 @@ +enum NFTPreviewAvailability { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', + ALL = 'all', + NONE = 'none', +} + +export default NFTPreviewAvailability; diff --git a/packages/gui/src/@types/NFTPreviewStatus.ts b/packages/gui/src/@types/NFTPreviewStatus.ts new file mode 100644 index 0000000000..5e17ec4cae --- /dev/null +++ b/packages/gui/src/@types/NFTPreviewStatus.ts @@ -0,0 +1,9 @@ +// Whether an NFT's gallery tile can show its media. UNAVAILABLE covers every +// placeholder a tile renders instead of content: no file to verify against, a +// file that failed to download, and a file that does not match its hash. +enum NFTPreviewStatus { + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', +} + +export default NFTPreviewStatus; diff --git a/packages/gui/src/@types/NFTsDataStatistics.ts b/packages/gui/src/@types/NFTsDataStatistics.ts index be03dd1483..66d392ed48 100644 --- a/packages/gui/src/@types/NFTsDataStatistics.ts +++ b/packages/gui/src/@types/NFTsDataStatistics.ts @@ -1,5 +1,8 @@ import type FileType from '../constants/FileType'; -type NFTsDataStatistics = Record; +type NFTsDataStatistics = Record< + FileType | 'visible' | 'hidden' | 'total' | 'sensitive' | 'previewAvailable' | 'previewUnavailable', + number +>; export default NFTsDataStatistics; diff --git a/packages/gui/src/components/nfts/NFTFilterProvider.tsx b/packages/gui/src/components/nfts/NFTFilterProvider.tsx index 1c59d33706..18859182b3 100644 --- a/packages/gui/src/components/nfts/NFTFilterProvider.tsx +++ b/packages/gui/src/components/nfts/NFTFilterProvider.tsx @@ -1,5 +1,6 @@ import React, { createContext, useMemo, useState, type ReactNode } from 'react'; +import NFTPreviewAvailability from '../../@types/NFTPreviewAvailability'; import NFTVisibility from '../../@types/NFTVisibility'; import FileType from '../../constants/FileType'; @@ -7,11 +8,13 @@ export interface NFTFilterContextData { walletIds: number[]; types: FileType[]; visibility: NFTVisibility; + previewAvailability: NFTPreviewAvailability; search: string | undefined; setWalletIds: (value: number[]) => void; setTypes: (value: FileType[]) => void; setVisibility: (value: NFTVisibility) => void; + setPreviewAvailability: (value: NFTPreviewAvailability) => void; setSearch: (value: string | undefined) => void; } @@ -34,6 +37,7 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { FileType.UNKNOWN, ]); const [visibility, setVisibility] = useState(NFTVisibility.ALL); + const [previewAvailability, setPreviewAvailability] = useState(NFTPreviewAvailability.ALL); const [search, setSearch] = useState(''); const value = useMemo( @@ -41,14 +45,27 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { walletIds, types, visibility, + previewAvailability, search, setWalletIds, setTypes, setVisibility, + setPreviewAvailability, setSearch, }), - [walletIds, types, visibility, search, setWalletIds, setTypes, setVisibility, setSearch], + [ + walletIds, + types, + visibility, + previewAvailability, + search, + setWalletIds, + setTypes, + setVisibility, + setPreviewAvailability, + setSearch, + ], ); return {children}; diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx index 96e715cf0c..0e58f2b40b 100644 --- a/packages/gui/src/components/nfts/NFTPreview.tsx +++ b/packages/gui/src/components/nfts/NFTPreview.tsx @@ -5,6 +5,7 @@ import { alpha, Box, IconButton, Tooltip } from '@mui/material'; import React, { useMemo, useRef, Fragment, useCallback, useEffect, type ReactNode } from 'react'; import styled from 'styled-components'; +import NFTPreviewStatus from '../../@types/NFTPreviewStatus'; import AudioSmallIcon from '../../assets/img/audio-small.svg'; import DocumentBlobIcon from '../../assets/img/document-blob.svg'; import DocumentSmallIcon from '../../assets/img/document-small.svg'; @@ -30,6 +31,7 @@ import useHideObjectionableContent from '../../hooks/useHideObjectionableContent import useNFT from '../../hooks/useNFT'; import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode'; import useNFTMetadata from '../../hooks/useNFTMetadata'; +import useNFTProvider from '../../hooks/useNFTProvider'; import useNFTVerifyHash from '../../hooks/useNFTVerifyHash'; import { useNFTVideoLoopGlobal, useNFTVideoLoopForNFT } from '../../hooks/useNFTVideoLoop'; import useStateAbort from '../../hooks/useStateAbort'; @@ -139,6 +141,7 @@ export default function NFTPreview(props: NFTPreviewProps) { } = props; const { getURI } = useCache(); + const { setPreviewStatus } = useNFTProvider(); const nftId = useMemo(() => getNFTId(id), [id]); const iframeRef = useRef(null); const { isDarkMode } = useDarkMode(); @@ -513,6 +516,46 @@ export default function NFTPreview(props: NFTPreviewProps) { !icon && ![FileType.MODEL, FileType.DOCUMENT].includes(previewFileType); + // The verdict behind the gallery's preview filter. It follows the + // verification state rather than the render path: a document or model + // tile draws its type icon even when the file could not be fetched, and a + // compact tile never opens the iframe, yet the file is just as unavailable + // (the hash badge says so) — so every preview-mode tile of an NFT reaches + // the same verdict, whichever of them happens to mount. Undefined while + // anything that could still change the verdict is in flight. + const previewStatus = useMemo(() => { + if (isLoading || isLoadingVerifyHash) { + return undefined; + } + + if (!preview?.isVerified) { + // no file, a settled mismatch, or a file that failed to download — but + // a thumbnail in metadata that has not arrived yet may still verify + return isLoadingMetadata ? undefined : NFTPreviewStatus.UNAVAILABLE; + } + + if (prepareError) { + // the verified file could not be served from the cache + return NFTPreviewStatus.UNAVAILABLE; + } + + if (!previewContent) { + // preparePreview has not settled on this uri yet + return undefined; + } + + return NFTPreviewStatus.AVAILABLE; + }, [isLoading, isLoadingVerifyHash, isLoadingMetadata, preview, prepareError, previewContent]); + + useEffect(() => { + // Only preview-mode tiles report: the detail view verifies the full data + // file rather than the thumbnail and can legitimately disagree with the + // gallery tile for the same NFT. + if (isPreview && previewStatus) { + setPreviewStatus(nftId, previewStatus); + } + }, [isPreview, previewStatus, nftId, setPreviewStatus]); + return ( {isLoading ? ( diff --git a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx index ad30c71ed8..6795f595aa 100644 --- a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx +++ b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx @@ -35,6 +35,7 @@ import { xor, intersection /* , sortBy */ } from 'lodash'; import React, { useMemo, useCallback, useRef, useEffect } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; +import NFTPreviewAvailability from '../../../@types/NFTPreviewAvailability'; import NFTVisibility from '../../../@types/NFTVisibility'; import FileType from '../../../constants/FileType'; import useFilteredNFTs from '../../../hooks/useFilteredNFTs'; @@ -111,6 +112,9 @@ export default function NFTGallery() { visibility, setVisibility, + previewAvailability, + setPreviewAvailability, + statistics, } = useFilteredNFTs(); @@ -245,6 +249,40 @@ export default function NFTGallery() { } } + function togglePreviewAvailable() { + switch (previewAvailability) { + case NFTPreviewAvailability.ALL: + setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE); + return; + case NFTPreviewAvailability.AVAILABLE: + setPreviewAvailability(NFTPreviewAvailability.NONE); + return; + case NFTPreviewAvailability.NONE: + setPreviewAvailability(NFTPreviewAvailability.AVAILABLE); + return; + case NFTPreviewAvailability.UNAVAILABLE: + default: + setPreviewAvailability(NFTPreviewAvailability.ALL); + } + } + + function togglePreviewUnavailable() { + switch (previewAvailability) { + case NFTPreviewAvailability.ALL: + setPreviewAvailability(NFTPreviewAvailability.AVAILABLE); + return; + case NFTPreviewAvailability.AVAILABLE: + setPreviewAvailability(NFTPreviewAvailability.ALL); + return; + case NFTPreviewAvailability.NONE: + setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE); + return; + case NFTPreviewAvailability.UNAVAILABLE: + default: + setPreviewAvailability(NFTPreviewAvailability.NONE); + } + } + function renderNFTCard(index: number, nft: NFTInfo) { return ( + + + + Any preview   + } size="extraSmall" /> + + ) : previewAvailability === NFTPreviewAvailability.AVAILABLE ? ( + + Preview available   + } size="extraSmall" /> + + ) : previewAvailability === NFTPreviewAvailability.UNAVAILABLE ? ( + + Preview not available   + } + size="extraSmall" + /> + + ) : ( + None (0) + ) + } + > + + + + } + label={ + + + Preview available + + } + size="extraSmall" + /> + + } + /> + + } + label={ + + + Preview not available + + } + size="extraSmall" + /> + + } + /> + + + + + diff --git a/packages/gui/src/components/nfts/provider/NFTProvider.tsx b/packages/gui/src/components/nfts/provider/NFTProvider.tsx index 40beb5faef..7b3e9ff7e5 100644 --- a/packages/gui/src/components/nfts/provider/NFTProvider.tsx +++ b/packages/gui/src/components/nfts/provider/NFTProvider.tsx @@ -9,6 +9,7 @@ import useMetadataData from './hooks/useMetadataData'; import useNFTData from './hooks/useNFTData'; import useNFTDataNachos from './hooks/useNFTDataNachos'; import useNFTDataOnDemand from './hooks/useNFTDataOnDemand'; +import useNFTPreviewStatuses from './hooks/useNFTPreviewStatuses'; const log = debug('nft:NFTProvider'); @@ -98,6 +99,7 @@ export default function NFTProvider(props: NFTProviderProps) { getMetadata, fetchMetadata, subscribeToMetadataChanges, + subscribeToChanges: subscribeToMetadataDataChanges, invalidate: invalidateMetadata, } = useMetadataData({ fetchNFT, @@ -131,6 +133,15 @@ export default function NFTProvider(props: NFTProviderProps) { [subscribeToDataChanges, subscribeToNachosChanges], ); + const { getPreviewStatus, setPreviewStatus, invalidatePreviewStatus, subscribeToPreviewStatusChanges } = + useNFTPreviewStatuses({ + nfts, + nachos, + getMetadata, + subscribeToChanges, + subscribeToMetadataChanges: subscribeToMetadataDataChanges, + }); + const invalidateNFT = useCallback( async (id: string | undefined) => { log(`Invalidating ${id}`); @@ -146,6 +157,15 @@ export default function NFTProvider(props: NFTProviderProps) { // invalidate nft files const promises = []; const { dataUris, metadataUris } = nft; + const invalidatedUris: string[] = [...dataUris]; + + // Drop the preview verdict right away, together with what is known about + // the data files: the filter must not keep classifying an NFT that is + // being refreshed from what its files used to be while the metadata + // round-trip below is still running. Repeated once the files are gone — + // the preview uris are only known after that round-trip, and a lookup + // that overlaps the deletions could memoize outcomes they remove. + invalidatePreviewStatus(id, invalidatedUris); dataUris.forEach((uri) => promises.push(invalidate(uri))); @@ -163,21 +183,40 @@ export default function NFTProvider(props: NFTProviderProps) { if (previewVideoUris) { previewVideoUris.forEach((uri: string) => promises.push(invalidate(uri))); + invalidatedUris.push(...previewVideoUris); } if (previewImageUris) { previewImageUris.forEach((uri: string) => promises.push(invalidate(uri))); + invalidatedUris.push(...previewImageUris); } } } catch (e) { log(`Error loading metadata for ${id}: ${(e as Error).message}`); - } finally { - await Promise.all(promises); + } + + // Wait for every deletion, even when one of them fails (a uri the cache + // cannot key), so the reset below cannot race a deletion still in + // flight; the first failure still propagates afterwards as before. + const results = await Promise.allSettled(promises); + invalidatePreviewStatus(id, invalidatedUris); + + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failure) { + throw failure.reason; } await Promise.all([invalidateNachos(), invalidateMetadata(id), invalidateNFTOnDemand(id)]); }, - [fetchNFT, fetchMetadata, invalidate, invalidateNachos, invalidateMetadata, invalidateNFTOnDemand], + [ + fetchNFT, + fetchMetadata, + invalidate, + invalidateNachos, + invalidateMetadata, + invalidateNFTOnDemand, + invalidatePreviewStatus, + ], ); const context = useMemo( @@ -192,6 +231,10 @@ export default function NFTProvider(props: NFTProviderProps) { getMetadata, subscribeToMetadataChanges, + getPreviewStatus, + setPreviewStatus, + subscribeToPreviewStatusChanges, + subscribeToChanges, invalidate: invalidateNFT, @@ -214,6 +257,9 @@ export default function NFTProvider(props: NFTProviderProps) { subscribeToNFTChanges, getMetadata, subscribeToMetadataChanges, + getPreviewStatus, + setPreviewStatus, + subscribeToPreviewStatusChanges, count, loaded, progress, diff --git a/packages/gui/src/components/nfts/provider/NFTProviderContext.ts b/packages/gui/src/components/nfts/provider/NFTProviderContext.ts index 120e9f767c..68ab12797d 100644 --- a/packages/gui/src/components/nfts/provider/NFTProviderContext.ts +++ b/packages/gui/src/components/nfts/provider/NFTProviderContext.ts @@ -2,6 +2,7 @@ import { type NFTInfo } from '@chia-network/api'; import { createContext } from 'react'; import type MetadataState from '../../../@types/MetadataState'; +import type NFTPreviewStatus from '../../../@types/NFTPreviewStatus'; import type NFTState from '../../../@types/NFTState'; const NFTProviderContext = createContext< @@ -29,6 +30,10 @@ const NFTProviderContext = createContext< id: string | undefined, callback: (metadataState: MetadataState) => void, ) => () => void; + + getPreviewStatus: (id: string | undefined) => NFTPreviewStatus | undefined; + setPreviewStatus: (id: string, status: NFTPreviewStatus) => void; + subscribeToPreviewStatusChanges: (callback: () => void) => () => void; } | undefined >(undefined); diff --git a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts index c25ade0370..3dc20b1816 100644 --- a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts +++ b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts @@ -45,6 +45,8 @@ export default function useMetadataData(props: UseMetadataDataProps) { error: metadataOnDemand.error, isLoading: !!metadataOnDemand.promise, }); + + events.emit('changed'); }, [events /* immutable */, metadatasOnDemand /* immutable */], ); @@ -177,10 +179,23 @@ export default function useMetadataData(props: UseMetadataDataProps) { [events /* immutable */], ); + // immutable function + const subscribeToChanges = useCallback( + (callback: () => void) => { + events.on('changed', callback); + + return () => { + events.off('changed', callback); + }; + }, + [events /* immutable */], + ); + return { getMetadata, fetchMetadata, subscribeToMetadataChanges, + subscribeToChanges, invalidate, } as const; } diff --git a/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts new file mode 100644 index 0000000000..13674c937d --- /dev/null +++ b/packages/gui/src/components/nfts/provider/hooks/useNFTPreviewStatuses.ts @@ -0,0 +1,244 @@ +import { EventEmitter } from 'events'; + +import { type NFTInfo } from '@chia-network/api'; +import debug from 'debug'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type CacheInfo from '../../../../@types/CacheInfo'; +import type MetadataState from '../../../../@types/MetadataState'; +import type NFTPreviewStatus from '../../../../@types/NFTPreviewStatus'; +import useCache from '../../../../hooks/useCache'; +import getNFTPreviewStatusFromCache, { getNFTPreviewUrls } from '../../../../util/getNFTPreviewStatusFromCache'; + +const log = debug('chia-gui:NFTProvider:useNFTPreviewStatuses'); + +// Cache infos are looked up in batches so a large collection does not hand +// the main process thousands of file reads in one IPC call. +const LOOKUP_BATCH_SIZE = 200; +// NFT pages and metadata results arrive in bursts; one sweep per window. +const LOOKUP_DELAY = 250; + +type UseNFTPreviewStatusesProps = { + nfts: Map; // should be immutable + nachos: Map; // should be immutable + getMetadata: (id: string) => MetadataState; // should be immutable + subscribeToChanges: (callback: () => void) => () => void; // should be immutable + subscribeToMetadataChanges: (callback: () => void) => () => void; // should be immutable +}; + +// warning: only used by NFTProvider +// +// Tracks, per NFT, whether its gallery tile can show a preview. Tiles report +// the verdict they settle on; NFTs that are not on screen (the gallery is +// virtualized, so most never mount) are classified from the outcomes the +// cache persisted during earlier visits and sessions, without downloading +// anything. A live report always wins over a cache lookup. +export default function useNFTPreviewStatuses(props: UseNFTPreviewStatusesProps) { + const { nfts, nachos, getMetadata, subscribeToChanges, subscribeToMetadataChanges } = props; + + const { getCacheInfos } = useCache(); + + const [statuses /* immutable */] = useState(() => new Map()); + // NFTs that need no further lookup: a tile reported them, the cache settled + // them, or every input is known and only a download (which a tile would + // then report) can decide them. + const [settled /* immutable */] = useState(() => new Set()); + // Persisted outcomes already fetched. A url's outcome only changes through + // a download — which the tile then reports live — or an invalidation, + // which forgets it here. + const [cacheInfos /* immutable */] = useState(() => new Map()); + + const events = useMemo(() => { + const eventEmitter = new EventEmitter(); + eventEmitter.setMaxListeners(Infinity); + return eventEmitter; + }, []); + + // immutable function + const getPreviewStatus = useCallback( + (nftId: string | undefined): NFTPreviewStatus | undefined => (nftId ? statuses.get(nftId) : undefined), + [statuses /* immutable */], + ); + + // immutable function + const setPreviewStatus = useCallback( + (nftId: string, status: NFTPreviewStatus) => { + settled.add(nftId); + + if (statuses.get(nftId) === status) { + return; + } + + statuses.set(nftId, status); + events.emit('changed'); + }, + [events /* immutable */, statuses /* immutable */, settled /* immutable */], + ); + + // Bumped by every invalidation. A lookup whose IPC round-trip spans one may + // have read files the invalidation deleted in the meantime, so it discards + // its result instead of memoizing it. + const invalidationGeneration = useRef(0); + + // immutable function + const invalidatePreviewStatus = useCallback( + (nftId: string, urls: string[]) => { + invalidationGeneration.current += 1; + settled.delete(nftId); + urls.forEach((url) => cacheInfos.delete(url)); + + if (statuses.delete(nftId)) { + events.emit('changed'); + } + }, + [events /* immutable */, statuses /* immutable */, settled /* immutable */, cacheInfos /* immutable */], + ); + + // immutable function + const subscribeToPreviewStatusChanges = useCallback( + (callback: () => void) => { + events.on('changed', callback); + + return () => { + events.off('changed', callback); + }; + }, + [events /* immutable */], + ); + + const isLookingUpRef = useRef(false); + const lookUpAgainRef = useRef(false); + + // Classifies every NFT not yet settled from the cache's persisted state. + // Runs serialized: a sweep that finds the flag set simply sweeps once more + // when it finishes. + const lookUpFromCache = useCallback(async () => { + if (isLookingUpRef.current) { + lookUpAgainRef.current = true; + return; + } + + isLookingUpRef.current = true; + try { + do { + lookUpAgainRef.current = false; + + const pending: [string, NFTInfo][] = []; + const collect = (nft: NFTInfo, nftId: string) => { + if (!settled.has(nftId)) { + pending.push([nftId, nft]); + } + }; + + nfts.forEach(collect); + nachos.forEach((nft, nftId) => { + if (!nfts.has(nftId)) { + collect(nft, nftId); + } + }); + + for (let start = 0; start < pending.length; start += LOOKUP_BATCH_SIZE) { + // The metadata store already fetches every NFT's metadata for the + // gallery's search and statistics; reading it here adds no requests. + const batch = pending + .slice(start, start + LOOKUP_BATCH_SIZE) + .map(([nftId, nft]) => ({ nftId, nft, metadataState: getMetadata(nftId) })); + + const urls = Array.from( + new Set(batch.flatMap(({ nft, metadataState }) => getNFTPreviewUrls(nft, metadataState))), + ).filter((url) => !cacheInfos.has(url)); + + if (urls.length) { + const generation = invalidationGeneration.current; + // eslint-disable-next-line no-await-in-loop -- batches are sequential on purpose, to pace the main process + const fetchedInfos = await getCacheInfos(urls); + + if (generation !== invalidationGeneration.current) { + // an invalidation ran while this lookup was in flight — the + // outcomes may describe files that are gone now, and the NFTs + // it reset are unsettled again, so start the sweep over + lookUpAgainRef.current = true; + break; + } + + fetchedInfos.forEach((cacheInfo) => cacheInfos.set(cacheInfo.url, cacheInfo)); + } + + let changed = false; + batch.forEach(({ nftId, nft, metadataState }) => { + if (settled.has(nftId)) { + // a tile reported live while the lookup was in flight + return; + } + + const status = getNFTPreviewStatusFromCache(nft, metadataState, (url) => cacheInfos.get(url)); + if (status) { + statuses.set(nftId, status); + settled.add(nftId); + changed = true; + } else if (!metadataState.isLoading) { + // every input is known and the cache cannot decide — only a + // download can, and the tile that performs it reports it + settled.add(nftId); + } + // otherwise the metadata is still loading: swept again once it settles + }); + + if (changed) { + events.emit('changed'); + } + } + } while (lookUpAgainRef.current); + } catch (e) { + log(`Error looking up preview statuses from the cache: ${(e as Error).message}`); + } finally { + isLookingUpRef.current = false; + } + }, [ + nfts /* immutable */, + nachos /* immutable */, + getMetadata /* immutable */, + getCacheInfos /* immutable */, + statuses /* immutable */, + settled /* immutable */, + cacheInfos /* immutable */, + events /* immutable */, + ]); + + const lookUpTimeoutRef = useRef | undefined>(undefined); + + const scheduleLookUp = useCallback(() => { + if (lookUpTimeoutRef.current) { + return; + } + + lookUpTimeoutRef.current = setTimeout(() => { + lookUpTimeoutRef.current = undefined; + lookUpFromCache(); + }, LOOKUP_DELAY); + }, [lookUpFromCache]); + + useEffect(() => { + scheduleLookUp(); + + const unsubscribeNFTs = subscribeToChanges(scheduleLookUp); + const unsubscribeMetadata = subscribeToMetadataChanges(scheduleLookUp); + + return () => { + unsubscribeNFTs(); + unsubscribeMetadata(); + + if (lookUpTimeoutRef.current) { + clearTimeout(lookUpTimeoutRef.current); + lookUpTimeoutRef.current = undefined; + } + }; + }, [scheduleLookUp, subscribeToChanges, subscribeToMetadataChanges]); + + return { + getPreviewStatus, // immutable + setPreviewStatus, // immutable + invalidatePreviewStatus, // immutable + subscribeToPreviewStatusChanges, // immutable + } as const; +} diff --git a/packages/gui/src/electron/CacheManager.test.ts b/packages/gui/src/electron/CacheManager.test.ts index 00a5371b25..48046d141a 100644 --- a/packages/gui/src/electron/CacheManager.test.ts +++ b/packages/gui/src/electron/CacheManager.test.ts @@ -219,3 +219,57 @@ describe('CacheManager eviction', () => { expect(mockDownloadFile).toHaveBeenCalledTimes(1); }); }); + +describe('CacheManager getCacheInfos', () => { + let cacheDirectory: string; + + beforeEach(async () => { + mockDownloadFile.mockReset(); + cacheDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'chia-cache-manager-')); + }); + + afterEach(async () => { + await fs.rm(cacheDirectory, { recursive: true, force: true }); + }); + + it('reports persisted outcomes per url without downloading anything', async () => { + const payload = Buffer.from('cached payload'); + mockDownloadFile.mockImplementation(async (url, localPath) => { + if (url === 'https://example.com/broken.png') { + throw new Error('getaddrinfo ENOTFOUND example.com'); + } + await fs.writeFile(localPath, payload); + return { + 'content-type': 'image/png', + }; + }); + + const cacheManager = new CacheManager({ + cacheDirectory, + maxCacheSize: 1024, + }); + await cacheManager.init(); + + await expect(cacheManager.getContent('https://example.com/ok.png')).resolves.toEqual(payload); + await expect(cacheManager.getContent('https://example.com/broken.png')).rejects.toThrow('ENOTFOUND'); + mockDownloadFile.mockClear(); + + const infos = await cacheManager.getCacheInfos([ + 'https://example.com/ok.png', + 'https://example.com/broken.png', + 'https://example.com/never-requested.png', + 'not a url', + ]); + + expect(infos.map((info) => [info.url, info.state])).toEqual([ + ['https://example.com/ok.png', 'CACHED'], + ['https://example.com/broken.png', 'ERROR'], + ['https://example.com/never-requested.png', 'NOT_CACHED'], + ['not a url', 'ERROR'], + ]); + expect(infos[0]).toMatchObject({ checksum: expect.any(String) }); + expect(infos[1]).toMatchObject({ error: 'getaddrinfo ENOTFOUND example.com' }); + expect(infos[3]).toMatchObject({ error: 'Invalid URL: not a url' }); + expect(mockDownloadFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gui/src/electron/CacheManager.ts b/packages/gui/src/electron/CacheManager.ts index 745cd4899d..abe302d413 100644 --- a/packages/gui/src/electron/CacheManager.ts +++ b/packages/gui/src/electron/CacheManager.ts @@ -235,6 +235,7 @@ export default class CacheManager extends EventEmitter { this.getURI(url, options), ); ipcMainHandle(CacheAPI.INVALIDATE, (url: string) => this.invalidate(url)); + ipcMainHandle(CacheAPI.GET_CACHE_INFOS, (urls: string[]) => this.getCacheInfos(urls)); ipcMainHandle(CacheAPI.GET_CACHE_DIRECTORY, () => this.cacheDirectory); ipcMainHandle(CacheAPI.GET_MAX_CACHE_SIZE, () => this.maxCacheSize); @@ -642,6 +643,29 @@ export default class CacheManager extends EventEmitter { throw new Error('Unknown cache state'); } + // Reports what the cache already knows about each url without fetching + // anything: a download that never happened stays NOT_CACHED, and a url the + // cache cannot key at all is reported as an error instead of failing the + // whole batch. This lets the renderer classify NFTs that are not on screen + // (and so never verify their files) from outcomes persisted by earlier + // visits and sessions. + async getCacheInfos(urls: string[]): Promise { + return Promise.all( + urls.map(async (url) => { + try { + return await this.getCacheInfoByURL(url); + } catch (error) { + return { + url, + state: CacheState.ERROR, + error: (error as Error).message, + timestamp: Date.now(), + }; + } + }), + ); + } + async clearCache() { // cancel all ongoing requests for (const ongoingRequest of this.ongoingRequests.values()) { diff --git a/packages/gui/src/electron/constants/CacheAPI.ts b/packages/gui/src/electron/constants/CacheAPI.ts index 7d81a804e1..9a25425745 100644 --- a/packages/gui/src/electron/constants/CacheAPI.ts +++ b/packages/gui/src/electron/constants/CacheAPI.ts @@ -18,6 +18,7 @@ enum CacheAPI { GET_HEADERS = `${API.CACHE}:getHeaders`, GET_CHECKSUM = `${API.CACHE}:getChecksum`, GET_URI = `${API.CACHE}:getUri`, + GET_CACHE_INFOS = `${API.CACHE}:getCacheInfos`, INVALIDATE = `${API.CACHE}:invalidate`, // Event subscriptions diff --git a/packages/gui/src/electron/preload.ts b/packages/gui/src/electron/preload.ts index fbff3191ba..b7a61fb4ff 100644 --- a/packages/gui/src/electron/preload.ts +++ b/packages/gui/src/electron/preload.ts @@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld(API.CACHE, { getURI: (url: string, options?: { maxSize?: number; timeout?: number }) => invokeWithCustomErrors(CacheAPI.GET_URI, url, options), invalidate: (url: string) => invokeWithCustomErrors(CacheAPI.INVALIDATE, url), + getCacheInfos: (urls: string[]) => invokeWithCustomErrors(CacheAPI.GET_CACHE_INFOS, urls), subscribeToDirectoryChange: (callback: (...args: unknown[]) => void) => onIpcEvent(CacheAPI.ON_CACHE_DIRECTORY_CHANGED, callback), subscribeToMaxSizeChange: (callback: (...args: unknown[]) => void) => diff --git a/packages/gui/src/hooks/useFilteredNFTs.ts b/packages/gui/src/hooks/useFilteredNFTs.ts index 730b033ec7..ec0f5d0b01 100644 --- a/packages/gui/src/hooks/useFilteredNFTs.ts +++ b/packages/gui/src/hooks/useFilteredNFTs.ts @@ -6,12 +6,13 @@ export default function useFilteredNFTs() { const filter = useNFTFilter(); const [hideSensitiveContent, setHideSensitiveContent] = useHideObjectionableContent(); - const { search, visibility, types, walletIds } = filter; + const { search, visibility, previewAvailability, types, walletIds } = filter; const nftsResult = useNFTs({ // filter props search, visibility, + previewAvailability, types, walletIds, diff --git a/packages/gui/src/hooks/useNFTs.ts b/packages/gui/src/hooks/useNFTs.ts index 60dfacec8b..dd91565d23 100644 --- a/packages/gui/src/hooks/useNFTs.ts +++ b/packages/gui/src/hooks/useNFTs.ts @@ -4,6 +4,8 @@ import { useMemo, useEffect, useState, useCallback } from 'react'; import type Metadata from '../@types/Metadata'; import MetadataState from '../@types/MetadataState'; +import NFTPreviewAvailability from '../@types/NFTPreviewAvailability'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; import NFTVisibility from '../@types/NFTVisibility'; import NFTsDataStatistics from '../@types/NFTsDataStatistics'; import FileType from '../constants/FileType'; @@ -24,9 +26,11 @@ const prepareNFTs = throttle( nfts: Map, nachos: Map, getMetadata: (id: string) => MetadataState, + getPreviewStatus: (nftId: string) => NFTPreviewStatus | undefined, walletIds: number[], isHidden: (nftId: string) => boolean, visibility: NFTVisibility, + previewAvailability: NFTPreviewAvailability, types: FileType[], search: string, onReponse: (filtered: NFTInfo[], statistics: NFTsDataStatistics) => void, @@ -42,6 +46,8 @@ const prepareNFTs = throttle( hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }; const filtered: NFTInfo[] = []; @@ -80,6 +86,15 @@ const prepareNFTs = throttle( stats.sensitive += 1; } + // Only a settled verdict places an NFT among the unavailable previews; + // one that has not been classified yet counts as available. + const isPreviewUnavailable = getPreviewStatus(nftId) === NFTPreviewStatus.UNAVAILABLE; + if (isPreviewUnavailable) { + stats.previewUnavailable += 1; + } else { + stats.previewAvailable += 1; + } + stats.total += 1; // process filtering @@ -95,6 +110,14 @@ const prepareNFTs = throttle( return; } + const previewMatches = + previewAvailability === NFTPreviewAvailability.ALL || + (previewAvailability === NFTPreviewAvailability.AVAILABLE && !isPreviewUnavailable) || + (previewAvailability === NFTPreviewAvailability.UNAVAILABLE && isPreviewUnavailable); + if (!previewMatches) { + return; + } + if (!type || !types.includes(type)) { return; } @@ -135,6 +158,7 @@ export type UseNFTsProps = { search?: string; types?: FileType[]; visibility?: NFTVisibility; + previewAvailability?: NFTPreviewAvailability; hideSensitiveContent?: boolean | 'false' | 'true'; }; @@ -147,11 +171,23 @@ export default function useNFTs(props: UseNFTsProps = {}) { types = allTypes, search = '', visibility = NFTVisibility.ALL, + previewAvailability = NFTPreviewAvailability.ALL, // hideSensitiveContent = false, } = props; - const { nfts, nachos, getMetadata, isLoading, error, progress, invalidate, count, subscribeToChanges } = - useNFTProvider(); + const { + nfts, + nachos, + getMetadata, + getPreviewStatus, + subscribeToPreviewStatusChanges, + isLoading, + error, + progress, + invalidate, + count, + subscribeToChanges, + } = useNFTProvider(); const [isNFTHidden] = useHiddenNFTs(); const total = useMemo(() => count + nachos.size, [count, nachos.size]); @@ -168,6 +204,8 @@ export default function useNFTs(props: UseNFTsProps = {}) { hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }); const updateFiltered = useCallback(() => { @@ -176,9 +214,11 @@ export default function useNFTs(props: UseNFTsProps = {}) { nfts, nachos, getMetadata, + getPreviewStatus, walletIds, isNFTHidden, visibility, + previewAvailability, types, search, (newFiltered, newStatistics) => { @@ -190,9 +230,11 @@ export default function useNFTs(props: UseNFTsProps = {}) { nfts, // immutable nachos, // immutable getMetadata, // immutable + getPreviewStatus, // immutable walletIds, // immutable isNFTHidden, visibility, + previewAvailability, types, search, setFiltered, @@ -212,6 +254,9 @@ export default function useNFTs(props: UseNFTsProps = {}) { [subscribeToChanges, updateFiltered], ); + // preview verdicts arrive as tiles settle and as cache lookups complete + useEffect(() => subscribeToPreviewStatusChanges(updateFiltered), [subscribeToPreviewStatusChanges, updateFiltered]); + return { total, nfts: filtered, diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts new file mode 100644 index 0000000000..5bfc16aa27 --- /dev/null +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.test.ts @@ -0,0 +1,141 @@ +import type CacheInfo from '../@types/CacheInfo'; +import type MetadataState from '../@types/MetadataState'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; +import CacheState from '../constants/CacheState'; + +import getNFTPreviewStatusFromCache, { getNFTPreviewUrls } from './getNFTPreviewStatusFromCache'; + +const HASH = '0xabc123'; +const PREVIEW_HASH = '0xdef456'; + +function cached(url: string, checksum: string): CacheInfo { + return { url, state: CacheState.CACHED, checksum, headers: {}, timestamp: 1 }; +} + +function errored(url: string, error: string): CacheInfo { + return { url, state: CacheState.ERROR, error, timestamp: 1 }; +} + +function notCached(url: string): CacheInfo { + return { url, state: CacheState.NOT_CACHED, timestamp: 1 }; +} + +function lookup(infos: CacheInfo[]) { + const byUrl = new Map(infos.map((info) => [info.url, info])); + return (url: string) => byUrl.get(url); +} + +const noMetadata: MetadataState = { metadata: undefined, isLoading: false, error: new Error('No metadata URI') }; +const loadingMetadata: MetadataState = { metadata: undefined, isLoading: true }; +const metadataWithPreview: MetadataState = { + metadata: { preview_image_uris: ['https://thumbs/x.png'], preview_image_hash: PREVIEW_HASH }, + isLoading: false, +}; + +const dead = errored('https://a/x.png', 'getaddrinfo ENOTFOUND a'); + +describe('getNFTPreviewStatusFromCache', () => { + it('is unavailable when there is no file to verify against', () => { + expect(getNFTPreviewStatusFromCache({ dataUris: [], dataHash: HASH }, noMetadata, lookup([]))).toBe( + NFTPreviewStatus.UNAVAILABLE, + ); + expect( + getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: undefined }, noMetadata, lookup([])), + ).toBe(NFTPreviewStatus.UNAVAILABLE); + }); + + it('is available once any data uri has cached bytes matching the hash, ignoring the 0x prefix', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + noMetadata, + lookup([dead, cached('https://b/x.png', 'abc123')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('is unavailable only when every uri has a settled failure', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + noMetadata, + lookup([ + errored('https://a/x.png', 'Request timed out after 30000ms of inactivity'), + cached('https://b/x.png', 'feed'), + ]), + ); + + expect(status).toBe(NFTPreviewStatus.UNAVAILABLE); + }); + + it('stays undecided while a uri has never been fetched', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png', 'https://b/x.png'], dataHash: HASH }, + noMetadata, + lookup([dead, notCached('https://b/x.png')]), + ); + + expect(status).toBeUndefined(); + expect( + getNFTPreviewStatusFromCache({ dataUris: ['https://a/x.png'], dataHash: HASH }, noMetadata, lookup([])), + ).toBeUndefined(); + }); + + it('stays undecided after a transient error the cache will retry', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + noMetadata, + lookup([errored('https://a/x.png', 'Request aborted')]), + ); + + expect(status).toBeUndefined(); + }); + + it('is available through a verified thumbnail even when the data file is unreachable', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + metadataWithPreview, + lookup([dead, cached('https://thumbs/x.png', 'def456')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('is available through a verified data file while the metadata is still loading', () => { + const status = getNFTPreviewStatusFromCache( + { dataUris: ['https://a/x.png'], dataHash: HASH }, + loadingMetadata, + lookup([cached('https://a/x.png', 'abc123')]), + ); + + expect(status).toBe(NFTPreviewStatus.AVAILABLE); + }); + + it('does not settle an unreachable data file as unavailable until the metadata is known', () => { + const nft = { dataUris: ['https://a/x.png'], dataHash: HASH }; + + expect(getNFTPreviewStatusFromCache(nft, loadingMetadata, lookup([dead]))).toBeUndefined(); + expect(getNFTPreviewStatusFromCache(nft, metadataWithPreview, lookup([dead]))).toBeUndefined(); + expect( + getNFTPreviewStatusFromCache( + nft, + metadataWithPreview, + lookup([dead, errored('https://thumbs/x.png', 'getaddrinfo ENOTFOUND thumbs')]), + ), + ).toBe(NFTPreviewStatus.UNAVAILABLE); + expect(getNFTPreviewStatusFromCache(nft, noMetadata, lookup([dead]))).toBe(NFTPreviewStatus.UNAVAILABLE); + }); + + it('lists the urls the classification consults', () => { + const nft = { dataUris: ['https://a/x.png'], dataHash: HASH }; + + expect(getNFTPreviewUrls(nft, loadingMetadata)).toEqual(['https://a/x.png']); + expect(getNFTPreviewUrls(nft, metadataWithPreview)).toEqual(['https://thumbs/x.png', 'https://a/x.png']); + // a preview source without a hash is never verified, so its uris are not consulted + expect( + getNFTPreviewUrls(nft, { + metadata: { preview_image_uris: ['https://thumbs/unhashed.png'] }, + isLoading: false, + }), + ).toEqual(['https://a/x.png']); + }); +}); diff --git a/packages/gui/src/util/getNFTPreviewStatusFromCache.ts b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts new file mode 100644 index 0000000000..91f5878f7a --- /dev/null +++ b/packages/gui/src/util/getNFTPreviewStatusFromCache.ts @@ -0,0 +1,106 @@ +import type CacheInfo from '../@types/CacheInfo'; +import type Metadata from '../@types/Metadata'; +import type MetadataState from '../@types/MetadataState'; +import NFTPreviewStatus from '../@types/NFTPreviewStatus'; +import CacheState from '../constants/CacheState'; + +import compareChecksums from './compareChecksums'; + +// Persisted errors the cache retries on the next access, so a tile would try +// the download again — they do not settle a uri as failed. +const TRANSIENT_ERRORS = ['Response aborted', 'Request aborted']; + +export type NFTPreviewSource = { + dataUris?: string[]; + dataHash?: string; +}; + +type PreviewCandidate = { + uris?: string[]; + hash?: string; +}; + +// The sources a preview-mode tile verifies, in the priority order +// `selectNFTPreviewState` applies: preview video, preview image, data file. +// The preview candidates exist only once the metadata is known. +function getCandidates(nft: NFTPreviewSource, metadata: Metadata | undefined): PreviewCandidate[] { + const candidates: PreviewCandidate[] = []; + + if (metadata) { + candidates.push( + { uris: metadata.preview_video_uris, hash: metadata.preview_video_hash }, + { uris: metadata.preview_image_uris, hash: metadata.preview_image_hash }, + ); + } + + candidates.push({ uris: nft.dataUris, hash: nft.dataHash }); + + return candidates; +} + +function settledMetadata(metadataState: MetadataState): Metadata | undefined { + return metadataState.isLoading ? undefined : metadataState.metadata; +} + +/** The urls whose cache state `getNFTPreviewStatusFromCache` consults. */ +export function getNFTPreviewUrls(nft: NFTPreviewSource, metadataState: MetadataState): string[] { + return getCandidates(nft, settledMetadata(metadataState)).flatMap((candidate) => + candidate.hash ? (candidate.uris ?? []) : [], + ); +} + +type UriOutcome = 'verified' | 'failed' | 'undecided'; + +function classifyUri(hash: string, cacheInfo: CacheInfo | undefined): UriOutcome { + if (cacheInfo?.state === CacheState.CACHED) { + // a cached file with the wrong checksum is a settled failure for this uri + return cacheInfo.checksum && compareChecksums(cacheInfo.checksum, hash) ? 'verified' : 'failed'; + } + + if (cacheInfo?.state === CacheState.ERROR) { + return TRANSIENT_ERRORS.includes(cacheInfo.error) ? 'undecided' : 'failed'; + } + + return 'undecided'; +} + +/** + * Classifies an NFT's preview from what the cache already persisted about its + * files, without fetching anything. Mirrors what a preview-mode tile settles + * on: it walks the same sources `useNFTVerifyHash` verifies — preview video, + * preview image, data file — and the first uri whose cached bytes match its + * hash makes the preview available. The preview is unavailable only once + * every uri of every source has a settled failure (a persisted download error + * or cached bytes with the wrong checksum). Anything the cache has not seen + * yet, or failed only transiently, leaves the outcome undecided + * (`undefined`), as does metadata that is still loading: until it settles the + * preview sources are unknown, and a thumbnail may still make the preview + * available even when the data file itself is unreachable. + */ +export default function getNFTPreviewStatusFromCache( + nft: NFTPreviewSource, + metadataState: MetadataState, + getCacheInfo: (url: string) => CacheInfo | undefined, +): NFTPreviewStatus | undefined { + let isUndecided = metadataState.isLoading; + + for (const candidate of getCandidates(nft, settledMetadata(metadataState))) { + // a source without a hash or uris has nothing to verify and contributes + // nothing — it can neither make the preview available nor fail it + if (candidate.hash && candidate.uris?.length) { + for (const uri of candidate.uris) { + const outcome = classifyUri(candidate.hash, getCacheInfo(uri)); + + if (outcome === 'verified') { + return NFTPreviewStatus.AVAILABLE; + } + + if (outcome === 'undecided') { + isUndecided = true; + } + } + } + } + + return isUndecided ? undefined : NFTPreviewStatus.UNAVAILABLE; +} diff --git a/packages/gui/src/util/getNFTsDataStatistics.ts b/packages/gui/src/util/getNFTsDataStatistics.ts index d3aca1f56f..ad8232da4c 100644 --- a/packages/gui/src/util/getNFTsDataStatistics.ts +++ b/packages/gui/src/util/getNFTsDataStatistics.ts @@ -19,6 +19,8 @@ export default function getNFTsDataStatistics( hidden: 0, total: 0, sensitive: 0, + previewAvailable: 0, + previewUnavailable: 0, }; data.forEach((item) => { @@ -37,6 +39,9 @@ export default function getNFTsDataStatistics( stats.sensitive += 1; } + // no preview verdicts are known here — nothing counts as unavailable + stats.previewAvailable += 1; + stats.total += 1; });