Skip to content

Commit ef63996

Browse files
jlobue10claude
andcommitted
Add a preview-availability filter to the NFT gallery
NFTs whose media cannot be shown — a dead host, a file that no longer matches its on-chain hash, or no file at all — render a placeholder tile but could not be singled out, so finding the broken ones in a large collection meant scrolling past every healthy one. The gallery's filter bar gains a third pill next to the Types and Visible/Hidden ones, with "Preview available" / "Preview not available" checkboxes and counts that follow the same four-state model as the visibility pill. The status behind the filter comes from two sources, both kept in a per-NFT store inside NFTProvider: - Tiles report what they actually settled on showing. The decision is derived from the same booleans NFTPreview renders from, so the filter classifies an NFT exactly as its tile does. Only preview-mode tiles report; the detail view verifies the full data file rather than the thumbnail and can legitimately disagree. - The gallery is virtualized, so most NFTs never mount. Those are classified from the cache's persisted outcomes via the new `getCacheInfos` IPC, mirroring the URI walk `useNFTVerifyHash` performs: a cached file matching the hash makes the preview available, and it is unavailable only once every URI has a settled failure. A URI the cache has never seen (or failed only transiently) leaves the NFT undecided, and undecided NFTs count as available — a tile would still attempt the download. Lookups run in batches of 200 URLs, once per NFT per session; a live report always wins over a lookup. Invalidating an NFT clears its verdict so the refreshed tile reports anew. Filtering and the statistics counts re-run as verdicts arrive, so with "Preview not available" selected the gallery converges on the broken NFTs as tiles settle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YKEGodEgVdEuvUM8dza52q
1 parent dad7bd6 commit ef63996

14 files changed

Lines changed: 586 additions & 6 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
enum NFTPreviewAvailability {
2+
AVAILABLE = 'available',
3+
UNAVAILABLE = 'unavailable',
4+
ALL = 'all',
5+
NONE = 'none',
6+
}
7+
8+
export default NFTPreviewAvailability;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Whether an NFT's gallery tile can show its media. UNAVAILABLE covers every
2+
// placeholder a tile renders instead of content: no file to verify against, a
3+
// file that failed to download, and a file that does not match its hash.
4+
enum NFTPreviewStatus {
5+
AVAILABLE = 'available',
6+
UNAVAILABLE = 'unavailable',
7+
}
8+
9+
export default NFTPreviewStatus;
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type FileType from '../constants/FileType';
22

3-
type NFTsDataStatistics = Record<FileType | 'visible' | 'hidden' | 'total' | 'sensitive', number>;
3+
type NFTsDataStatistics = Record<
4+
FileType | 'visible' | 'hidden' | 'total' | 'sensitive' | 'previewAvailable' | 'previewUnavailable',
5+
number
6+
>;
47

58
export default NFTsDataStatistics;

packages/gui/src/components/nfts/NFTFilterProvider.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import React, { createContext, useMemo, useState, type ReactNode } from 'react';
22

3+
import NFTPreviewAvailability from '../../@types/NFTPreviewAvailability';
34
import NFTVisibility from '../../@types/NFTVisibility';
45
import FileType from '../../constants/FileType';
56

67
export interface NFTFilterContextData {
78
walletIds: number[];
89
types: FileType[];
910
visibility: NFTVisibility;
11+
previewAvailability: NFTPreviewAvailability;
1012
search: string | undefined;
1113

1214
setWalletIds: (value: number[]) => void;
1315
setTypes: (value: FileType[]) => void;
1416
setVisibility: (value: NFTVisibility) => void;
17+
setPreviewAvailability: (value: NFTPreviewAvailability) => void;
1518
setSearch: (value: string | undefined) => void;
1619
}
1720

@@ -34,21 +37,35 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) {
3437
FileType.UNKNOWN,
3538
]);
3639
const [visibility, setVisibility] = useState<NFTVisibility>(NFTVisibility.ALL);
40+
const [previewAvailability, setPreviewAvailability] = useState<NFTPreviewAvailability>(NFTPreviewAvailability.ALL);
3741
const [search, setSearch] = useState('');
3842

3943
const value = useMemo(
4044
() => ({
4145
walletIds,
4246
types,
4347
visibility,
48+
previewAvailability,
4449
search,
4550

4651
setWalletIds,
4752
setTypes,
4853
setVisibility,
54+
setPreviewAvailability,
4955
setSearch,
5056
}),
51-
[walletIds, types, visibility, search, setWalletIds, setTypes, setVisibility, setSearch],
57+
[
58+
walletIds,
59+
types,
60+
visibility,
61+
previewAvailability,
62+
search,
63+
setWalletIds,
64+
setTypes,
65+
setVisibility,
66+
setPreviewAvailability,
67+
setSearch,
68+
],
5269
);
5370

5471
return <NFTFilterContext.Provider value={value}>{children}</NFTFilterContext.Provider>;

packages/gui/src/components/nfts/NFTPreview.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { alpha, Box, IconButton, Tooltip } from '@mui/material';
55
import React, { useMemo, useRef, Fragment, useCallback, useEffect, type ReactNode } from 'react';
66
import styled from 'styled-components';
77

8+
import NFTPreviewStatus from '../../@types/NFTPreviewStatus';
89
import AudioSmallIcon from '../../assets/img/audio-small.svg';
910
import DocumentBlobIcon from '../../assets/img/document-blob.svg';
1011
import DocumentSmallIcon from '../../assets/img/document-small.svg';
@@ -30,6 +31,7 @@ import useHideObjectionableContent from '../../hooks/useHideObjectionableContent
3031
import useNFT from '../../hooks/useNFT';
3132
import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode';
3233
import useNFTMetadata from '../../hooks/useNFTMetadata';
34+
import useNFTProvider from '../../hooks/useNFTProvider';
3335
import useNFTVerifyHash from '../../hooks/useNFTVerifyHash';
3436
import { useNFTVideoLoopGlobal, useNFTVideoLoopForNFT } from '../../hooks/useNFTVideoLoop';
3537
import useStateAbort from '../../hooks/useStateAbort';
@@ -139,6 +141,7 @@ export default function NFTPreview(props: NFTPreviewProps) {
139141
} = props;
140142

141143
const { getURI } = useCache();
144+
const { setPreviewStatus } = useNFTProvider();
142145
const nftId = useMemo(() => getNFTId(id), [id]);
143146
const iframeRef = useRef<any>(null);
144147
const { isDarkMode } = useDarkMode();
@@ -513,6 +516,40 @@ export default function NFTPreview(props: NFTPreviewProps) {
513516
!icon &&
514517
![FileType.MODEL, FileType.DOCUMENT].includes(previewFileType);
515518

519+
// What the tile settled on showing — the same decision the render below
520+
// makes — so the gallery's preview filter classifies NFTs exactly as their
521+
// tiles do. Undefined while the tile is still loading.
522+
const previewStatus = useMemo(() => {
523+
if (isLoading) {
524+
return undefined;
525+
}
526+
527+
if (!hasFile || isHashMismatch) {
528+
return NFTPreviewStatus.UNAVAILABLE;
529+
}
530+
531+
if (usesIframe) {
532+
if (prepareError) {
533+
return NFTPreviewStatus.UNAVAILABLE;
534+
}
535+
536+
if (!previewContent) {
537+
return undefined;
538+
}
539+
}
540+
541+
return NFTPreviewStatus.AVAILABLE;
542+
}, [isLoading, hasFile, isHashMismatch, usesIframe, prepareError, previewContent]);
543+
544+
useEffect(() => {
545+
// Only preview-mode tiles report: the detail view verifies the full data
546+
// file rather than the thumbnail and can legitimately disagree with the
547+
// gallery tile for the same NFT.
548+
if (isPreview && previewStatus) {
549+
setPreviewStatus(nftId, previewStatus);
550+
}
551+
}, [isPreview, previewStatus, nftId, setPreviewStatus]);
552+
516553
return (
517554
<StyledCardPreview width={width} height={height} sx={{ aspectRatio: ratio.toString() }}>
518555
{isLoading ? (

packages/gui/src/components/nfts/gallery/NFTGallery.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { xor, intersection /* , sortBy */ } from 'lodash';
3535
import React, { useMemo, useCallback, useRef, useEffect } from 'react';
3636
import { VirtuosoGrid } from 'react-virtuoso';
3737

38+
import NFTPreviewAvailability from '../../../@types/NFTPreviewAvailability';
3839
import NFTVisibility from '../../../@types/NFTVisibility';
3940
import FileType from '../../../constants/FileType';
4041
import useFilteredNFTs from '../../../hooks/useFilteredNFTs';
@@ -111,6 +112,9 @@ export default function NFTGallery() {
111112
visibility,
112113
setVisibility,
113114

115+
previewAvailability,
116+
setPreviewAvailability,
117+
114118
statistics,
115119
} = useFilteredNFTs();
116120

@@ -245,6 +249,40 @@ export default function NFTGallery() {
245249
}
246250
}
247251

252+
function togglePreviewAvailable() {
253+
switch (previewAvailability) {
254+
case NFTPreviewAvailability.ALL:
255+
setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE);
256+
return;
257+
case NFTPreviewAvailability.AVAILABLE:
258+
setPreviewAvailability(NFTPreviewAvailability.NONE);
259+
return;
260+
case NFTPreviewAvailability.NONE:
261+
setPreviewAvailability(NFTPreviewAvailability.AVAILABLE);
262+
return;
263+
case NFTPreviewAvailability.UNAVAILABLE:
264+
default:
265+
setPreviewAvailability(NFTPreviewAvailability.ALL);
266+
}
267+
}
268+
269+
function togglePreviewUnavailable() {
270+
switch (previewAvailability) {
271+
case NFTPreviewAvailability.ALL:
272+
setPreviewAvailability(NFTPreviewAvailability.AVAILABLE);
273+
return;
274+
case NFTPreviewAvailability.AVAILABLE:
275+
setPreviewAvailability(NFTPreviewAvailability.ALL);
276+
return;
277+
case NFTPreviewAvailability.NONE:
278+
setPreviewAvailability(NFTPreviewAvailability.UNAVAILABLE);
279+
return;
280+
case NFTPreviewAvailability.UNAVAILABLE:
281+
default:
282+
setPreviewAvailability(NFTPreviewAvailability.NONE);
283+
}
284+
}
285+
248286
function renderNFTCard(index: number, nft: NFTInfo) {
249287
return (
250288
<NFTCard
@@ -450,6 +488,82 @@ export default function NFTGallery() {
450488
</FilterPill>
451489
</Box>
452490
</Fade>
491+
<Fade in={showFilters} unmountOnExit>
492+
<Box>
493+
<FilterPill
494+
title={
495+
previewAvailability === NFTPreviewAvailability.ALL ? (
496+
<Trans>
497+
Any preview &nbsp;
498+
<Chip label={<FormatLargeNumber value={statistics.total} />} size="extraSmall" />
499+
</Trans>
500+
) : previewAvailability === NFTPreviewAvailability.AVAILABLE ? (
501+
<Trans>
502+
Preview available &nbsp;
503+
<Chip label={<FormatLargeNumber value={statistics.previewAvailable} />} size="extraSmall" />
504+
</Trans>
505+
) : previewAvailability === NFTPreviewAvailability.UNAVAILABLE ? (
506+
<Trans>
507+
Preview not available &nbsp;
508+
<Chip
509+
label={<FormatLargeNumber value={statistics.previewUnavailable} />}
510+
size="extraSmall"
511+
/>
512+
</Trans>
513+
) : (
514+
<Trans>None (0)</Trans>
515+
)
516+
}
517+
>
518+
<FormControl>
519+
<Flex flexDirection="column">
520+
<FormControlLabel
521+
control={
522+
<Checkbox
523+
checked={[NFTPreviewAvailability.AVAILABLE, NFTPreviewAvailability.ALL].includes(
524+
previewAvailability,
525+
)}
526+
onChange={togglePreviewAvailable}
527+
/>
528+
}
529+
label={
530+
<Flex width="100%" gap={1} justifyContent="space-between" alignItems="center">
531+
<Box>
532+
<Trans>Preview available</Trans>
533+
</Box>
534+
<Chip
535+
label={<FormatLargeNumber value={statistics.previewAvailable} />}
536+
size="extraSmall"
537+
/>
538+
</Flex>
539+
}
540+
/>
541+
<FormControlLabel
542+
control={
543+
<Checkbox
544+
checked={[NFTPreviewAvailability.UNAVAILABLE, NFTPreviewAvailability.ALL].includes(
545+
previewAvailability,
546+
)}
547+
onChange={togglePreviewUnavailable}
548+
/>
549+
}
550+
label={
551+
<Flex width="100%" gap={1} justifyContent="space-between" alignItems="center">
552+
<Box>
553+
<Trans>Preview not available</Trans>
554+
</Box>
555+
<Chip
556+
label={<FormatLargeNumber value={statistics.previewUnavailable} />}
557+
size="extraSmall"
558+
/>
559+
</Flex>
560+
}
561+
/>
562+
</Flex>
563+
</FormControl>
564+
</FilterPill>
565+
</Box>
566+
</Fade>
453567
</Flex>
454568
</Box>
455569
</Flex>

packages/gui/src/components/nfts/provider/NFTProvider.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import useMetadataData from './hooks/useMetadataData';
99
import useNFTData from './hooks/useNFTData';
1010
import useNFTDataNachos from './hooks/useNFTDataNachos';
1111
import useNFTDataOnDemand from './hooks/useNFTDataOnDemand';
12+
import useNFTPreviewStatuses from './hooks/useNFTPreviewStatuses';
1213

1314
const log = debug('nft:NFTProvider');
1415

@@ -131,6 +132,13 @@ export default function NFTProvider(props: NFTProviderProps) {
131132
[subscribeToDataChanges, subscribeToNachosChanges],
132133
);
133134

135+
const { getPreviewStatus, setPreviewStatus, invalidatePreviewStatus, subscribeToPreviewStatusChanges } =
136+
useNFTPreviewStatuses({
137+
nfts,
138+
nachos,
139+
subscribeToChanges,
140+
});
141+
134142
const invalidateNFT = useCallback(
135143
async (id: string | undefined) => {
136144
log(`Invalidating ${id}`);
@@ -143,6 +151,9 @@ export default function NFTProvider(props: NFTProviderProps) {
143151
return;
144152
}
145153

154+
// the files are about to be re-fetched, so the preview verdict is stale
155+
invalidatePreviewStatus(id);
156+
146157
// invalidate nft files
147158
const promises = [];
148159
const { dataUris, metadataUris } = nft;
@@ -177,7 +188,15 @@ export default function NFTProvider(props: NFTProviderProps) {
177188

178189
await Promise.all([invalidateNachos(), invalidateMetadata(id), invalidateNFTOnDemand(id)]);
179190
},
180-
[fetchNFT, fetchMetadata, invalidate, invalidateNachos, invalidateMetadata, invalidateNFTOnDemand],
191+
[
192+
fetchNFT,
193+
fetchMetadata,
194+
invalidate,
195+
invalidateNachos,
196+
invalidateMetadata,
197+
invalidateNFTOnDemand,
198+
invalidatePreviewStatus,
199+
],
181200
);
182201

183202
const context = useMemo(
@@ -192,6 +211,10 @@ export default function NFTProvider(props: NFTProviderProps) {
192211
getMetadata,
193212
subscribeToMetadataChanges,
194213

214+
getPreviewStatus,
215+
setPreviewStatus,
216+
subscribeToPreviewStatusChanges,
217+
195218
subscribeToChanges,
196219

197220
invalidate: invalidateNFT,
@@ -214,6 +237,9 @@ export default function NFTProvider(props: NFTProviderProps) {
214237
subscribeToNFTChanges,
215238
getMetadata,
216239
subscribeToMetadataChanges,
240+
getPreviewStatus,
241+
setPreviewStatus,
242+
subscribeToPreviewStatusChanges,
217243
count,
218244
loaded,
219245
progress,

packages/gui/src/components/nfts/provider/NFTProviderContext.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type NFTInfo } from '@chia-network/api';
22
import { createContext } from 'react';
33

44
import type MetadataState from '../../../@types/MetadataState';
5+
import type NFTPreviewStatus from '../../../@types/NFTPreviewStatus';
56
import type NFTState from '../../../@types/NFTState';
67

78
const NFTProviderContext = createContext<
@@ -29,6 +30,10 @@ const NFTProviderContext = createContext<
2930
id: string | undefined,
3031
callback: (metadataState: MetadataState) => void,
3132
) => () => void;
33+
34+
getPreviewStatus: (id: string | undefined) => NFTPreviewStatus | undefined;
35+
setPreviewStatus: (id: string, status: NFTPreviewStatus) => void;
36+
subscribeToPreviewStatusChanges: (callback: () => void) => () => void;
3237
}
3338
| undefined
3439
>(undefined);

0 commit comments

Comments
 (0)