Skip to content

Commit

Permalink
feat: adds custom API support + retry logic
Browse files Browse the repository at this point in the history
chore: use cross-env for windows script compat

fix: prettier adding crlf on windows

feat: add custom api support + update Pagination mechanism

feat: add Retry button

feat: add autoRetry prop

feat: limit failed request retry count to 20

fix: prevent layout resize on preloading state

re-add global limit check

Update README.md

Co-authored-by: Ben Kremer <[email protected]>

set MAX_AUTO_RETRY_ATTEMPT to 10

Co-authored-by: Ben Kremer <[email protected]>

update hook dependencies

Co-authored-by: Ben Kremer <[email protected]>

fix: ensure showcase mode works with cursor pagination

chore: tweak wording on RetryButton

fix(api): ensures timeout is applied for retries

`fetchOpenseaAssets` was being immediately invoked instead of being
passed to the `delay` function, causing all retries to happen
immediately.

refactor: unify load fns into `useCallback`

fix: default to `currentCursor` for retry if `nextCursor` is null

fix(stories): use proxyApi for showcase mode

docs(readme): tweaks instructions for custom API usage

fix: removes unused `ErrorPopupProps`
  • Loading branch information
trkaplan authored and bkrem committed Apr 9, 2022
1 parent 44a237c commit 0ddc9b6
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 60 deletions.
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The NFT assets for an address are resolved via the [OpenSea API](https://docs.op
>
> This means:
>
> - The gallery may see breaking changes between minor versions until `v1.0.0` is released.
> - The gallery may not always render/behave as expected across different browsers & browser versions.
> Please [open an issue](https://github.com/bkrem/react-nft-gallery/issues) in this case.
> - The gallery may not render/behave as expected for your use case.
Expand All @@ -43,6 +44,7 @@ The NFT assets for an address are resolved via the [OpenSea API](https://docs.op
- [OpenSea API Key](#opensea-api-key)
- [Installation](#installation)
- [Usage](#usage)
- [Using a custom API endpoint](#using-a-custom-api-endpoint)
- [API](#api)
- [Roadmap](#roadmap)

Expand All @@ -52,7 +54,7 @@ OpenSea has recently added the requirement for an `X-API-KEY` header to be passe
requests to their [`/assets` endpoint](https://docs.opensea.io/reference/getting-assets).
By default, `react-nft-gallery` can now only fetch the first 20 assets for any provided `ownerAddress`.

The gallery's full capabilities are available by passing an OpenSea API key as the `openseaApiKey` prop.
The gallery's full capabilities are available by passing an OpenSea API key as the `openseaApiKey` prop, or by [using a custom API endpoint](#using-a-custom-api-endpoint).

To request an API key, please consult the [API key form on the OpenSea docs](https://docs.opensea.io/reference/request-an-api-key).

Expand All @@ -74,6 +76,27 @@ import { NftGallery } from 'react-nft-gallery';
return <NftGallery ownerAddress="vitalik.eth" />;
```

### Using a custom API endpoint

To use a custom API endpoint, pass it via the `apiUrl` prop.

If the endpoint injects an OpenSea API key, set the `isProxyApi` prop to `true`.
This allows for paginated requests without exposing the OpenSea API key in the client via `openseaApiKey`:

```tsx
import { NftGallery } from 'react-nft-gallery';

// ...

return (
<NftGallery
ownerAddress="vitalik.eth"
apiUrl="https://your-opensea-api-proxy.vercel.app"
isProxyApi={true}
/>
);
```

## API

````ts
Expand All @@ -90,6 +113,22 @@ interface NftGalleryProps {
*/
openseaApiKey?: string;

/**
* Set to `true` when using a proxy API to hide the OpenSea API key.
* Otherwise the gallery disables pagination if `openseaApiKey` is also not provided.
*/
isProxyApi?: boolean;

/**
* Set a custom API URL.
*/
apiUrl?: string;

/**
* Auto retry (max. 10 times) after an API request failed.
*/
autoRetry?: boolean;

/**
* Display asset metadata underneath the NFT.
* Defaults to `true`.
Expand Down Expand Up @@ -163,7 +202,7 @@ interface NftGalleryProps {

## Roadmap

- [x] feat: support ENS domain resolution in `ownerAddress`
- [x] feat: support ENS domain resolution in `ownerAddress`
- [x] feat: support keyboard navigation for lightbox
- [x] feat: remove "load more" button and auto-resolve all assets via recursive pagination on OpenSea API (P1)
- [x] feat: use card placeholders instead of spinner for loading phase (P1)
Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
},
"scripts": {
"start": "tsdx watch",
"build": "NODE_ENV=production tsdx build",
"build": "cross-env NODE_ENV=production tsdx build",
"test": "tsdx test --passWithNoTests",
"lint": "tsdx lint",
"prepare": "husky install && NODE_ENV=production tsdx build",
"prepare": "husky install && cross-env NODE_ENV=production tsdx build",
"release": "np",
"postpublish": "yarn deploy-storybook",
"size": "size-limit",
Expand All @@ -40,7 +40,8 @@
"printWidth": 80,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
"trailingComma": "es5",
"endOfLine": "auto"
},
"size-limit": [
{
Expand Down Expand Up @@ -86,6 +87,7 @@
"@types/react-dom": "^17.0.9",
"autoprefixer": "^9",
"babel-loader": "^8.2.2",
"cross-env": "^7.0.3",
"eslint-plugin-prettier": "^3.4.0",
"husky": "^7.0.1",
"identity-obj-proxy": "^3.0.0",
Expand All @@ -101,4 +103,4 @@
"tslib": "^2.3.0",
"typescript": "^4.3.5"
}
}
}
151 changes: 118 additions & 33 deletions src/NftGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { CSSProperties, useEffect, useState } from 'react';
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import InView from 'react-intersection-observer';

import { GalleryItem } from './components/GalleryItem/GalleryItem';
import { LoadMoreButton } from './components/LoadMoreButton';
import { RetryButton } from './components/RetryButton';
import { OpenseaAsset } from './types/OpenseaAsset';
import { isEnsDomain, joinClassNames } from './utils';
import {
Expand All @@ -28,6 +29,21 @@ export interface NftGalleryProps {
*/
openseaApiKey?: string;

/**
* Set true when using an proxy API which is used to hide API key. Otherwise component disables pagination when no API key provided.
*/
isProxyApi?: boolean;

/**
* Set custom API URL.
*/
apiUrl?: string;

/**
* Auto retry (10 times by default) after a request failed.
*/
autoRetry?: boolean;

/**
* Display asset metadata underneath the NFT.
* Defaults to `true`.
Expand Down Expand Up @@ -101,6 +117,9 @@ export interface NftGalleryProps {
export const NftGallery: React.FC<NftGalleryProps> = ({
ownerAddress = '',
openseaApiKey = '',
isProxyApi = false,
apiUrl = '',
autoRetry = false,
darkMode = false,
metadataIsVisible = true,
showcaseMode = false,
Expand All @@ -115,7 +134,9 @@ export const NftGallery: React.FC<NftGalleryProps> = ({
}) => {
const [assets, setAssets] = useState([] as OpenseaAsset[]);
const [showcaseAssets, setShowcaseAssets] = useState([] as OpenseaAsset[]);
const [currentOffset, setCurrentOffset] = useState(0);
const [currentCursor, setCurrentCursor] = useState('');
const [nextCursor, setNextCursor] = useState('');
const [hasError, setHasError] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);

Expand All @@ -128,28 +149,47 @@ export const NftGallery: React.FC<NftGalleryProps> = ({

const displayedAssets = showcaseMode ? showcaseAssets : assets;

const loadAssets = async (
const loadAssetsPage = async (
ownerAddress: NftGalleryProps['ownerAddress'],
apiKey: NftGalleryProps['openseaApiKey'],
offset: number
isProxyApi: NftGalleryProps['isProxyApi'],
apiUrl: NftGalleryProps['apiUrl'],
autoRetry: NftGalleryProps['autoRetry'],
cursor: string
) => {
setIsLoading(true);
const owner = isEnsDomain(ownerAddress)
? await resolveEnsDomain(ownerAddress)
: ownerAddress;
const rawAssets = await fetchOpenseaAssets({
const {
assets: rawAssets,
hasError,
nextCursor,
} = await fetchOpenseaAssets({
owner,
apiKey,
offset,
isProxyApi,
apiUrl,
autoRetry,
cursor,
});
setAssets((prevAssets) => [...prevAssets, ...rawAssets]);
setCanLoadMore(rawAssets.length === OPENSEA_API_OFFSET);
if (hasError) {
setHasError(true);
} else {
setHasError(false);
setAssets((prevAssets) => [...prevAssets, ...rawAssets]);
setCanLoadMore(rawAssets.length === OPENSEA_API_OFFSET);
setNextCursor(nextCursor);
}
setIsLoading(false);
};

const loadShowcaseAssets = async (
ownerAddress: NftGalleryProps['ownerAddress'],
apiKey: NftGalleryProps['openseaApiKey']
apiKey: NftGalleryProps['openseaApiKey'],
isProxyApi: NftGalleryProps['isProxyApi'],
apiUrl: NftGalleryProps['apiUrl'],
autoRetry: NftGalleryProps['autoRetry']
) => {
setIsLoading(true);
// Stop if we already have 1000+ items in play.
Expand All @@ -160,30 +200,67 @@ export const NftGallery: React.FC<NftGalleryProps> = ({

let shouldFetch = true;
let currentOffset = 0;
let cursor = '';

// Grab all assets of this address to filter down to showcase-only.
// TODO: optimise this to exit as soon as all showcase items have been resolved.
while (shouldFetch) {
const rawAssets = await fetchOpenseaAssets({
const response = await fetchOpenseaAssets({
owner,
apiKey,
offset: currentOffset,
isProxyApi,
apiUrl,
autoRetry,
cursor,
});
setAssets((prevAssets) => [...prevAssets, ...rawAssets]);
currentOffset += OPENSEA_API_OFFSET;
if (rawAssets.length !== 0) setIsLoading(false);

// Terminate if hit the global limit or we hit a non-full page (i.e. end of assets).
if (
rawAssets.length < OPENSEA_API_OFFSET ||
currentOffset >= MAX_OFFSET
) {
shouldFetch = false;
setIsLoading(false);
const { assets: rawAssets, hasError, nextCursor } = response;
if (hasError) {
setHasError(true);
} else {
currentOffset += OPENSEA_API_OFFSET;
cursor = nextCursor;
setAssets((prevAssets) => [...prevAssets, ...rawAssets]);
if (rawAssets.length !== 0) setIsLoading(false);
setNextCursor(nextCursor);
setHasError(hasError);
// Terminate if next cursor is `null` (i.e. last page) or we hit the asset limit.
if (cursor === null || currentOffset >= MAX_OFFSET) {
shouldFetch = false;
setIsLoading(false);
}
}
}
};

const loadAssets = useCallback(() => {
if (showcaseMode) {
loadShowcaseAssets(
ownerAddress,
openseaApiKey,
isProxyApi,
apiUrl,
autoRetry
);
} else {
loadAssetsPage(
ownerAddress,
openseaApiKey,
isProxyApi,
apiUrl,
autoRetry,
currentCursor
);
}
}, [
showcaseMode,
ownerAddress,
openseaApiKey,
isProxyApi,
apiUrl,
autoRetry,
currentCursor,
]);

const updateShowcaseAssets = (
allAssets: OpenseaAsset[],
itemIds: string[]
Expand All @@ -196,10 +273,20 @@ export const NftGallery: React.FC<NftGalleryProps> = ({

const onLastItemInView = (isInView: boolean) => {
if (!hasLoadMoreButton && isInView) {
setCurrentOffset((prevOffset) => prevOffset + OPENSEA_API_OFFSET);
setCurrentCursor(nextCursor);
}
};

const retryLastRequest = () =>
loadAssetsPage(
ownerAddress,
openseaApiKey,
isProxyApi,
apiUrl,
autoRetry,
nextCursor ?? currentCursor
);

// TODO: Move into `Lightbox` component once its refactored to being a singleton.
const handleKeydownEvent = (evt: KeyboardEvent) => {
const hasActiveLightbox =
Expand All @@ -224,12 +311,8 @@ export const NftGallery: React.FC<NftGalleryProps> = ({

// Handles fetching of assets via OpenSea API.
useEffect(() => {
if (showcaseMode) {
loadShowcaseAssets(ownerAddress, openseaApiKey);
} else {
loadAssets(ownerAddress, openseaApiKey, currentOffset);
}
}, [showcaseMode, ownerAddress, openseaApiKey, currentOffset]);
loadAssets();
}, [loadAssets]);

// Isolates assets specified for showcase mode into their own collection whenever `assets` changes.
useEffect(() => {
Expand All @@ -253,8 +336,12 @@ export const NftGallery: React.FC<NftGalleryProps> = ({

return (
<div
className={joinClassNames(darkMode ? 'rnftg-dark' : '', 'rnftg-h-full')}
className={joinClassNames(
darkMode ? 'rnftg-dark' : '',
'rnftg-h-full rnftg-w-full'
)}
>
{hasError && <RetryButton onClick={retryLastRequest} />}
<div
style={galleryContainerStyle}
className={joinClassNames(
Expand Down Expand Up @@ -312,9 +399,7 @@ export const NftGallery: React.FC<NftGalleryProps> = ({
{hasLoadMoreButton && canLoadMore && (
<LoadMoreButton
onClick={() => {
setCurrentOffset(
(prevOffset) => prevOffset + OPENSEA_API_OFFSET
);
setCurrentCursor(nextCursor);
}}
/>
)}
Expand Down
Loading

0 comments on commit 0ddc9b6

Please sign in to comment.