Skip to content

Commit

Permalink
support ipfs gateway settings
Browse files Browse the repository at this point in the history
  • Loading branch information
dan13ram authored and vidvidvid committed Jan 27, 2023
1 parent fa72480 commit 06ff3bf
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 34 deletions.
156 changes: 156 additions & 0 deletions components/Settings/IPFSGatewaySettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
Box,
Button,
HStack,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Stack,
Text,
useDisclosure,
} from '@chakra-ui/react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import CreatableSelect from 'react-select/creatable';

import { EditIcon } from '@/components/icons/EditIcon';
import { uniqueList } from '@/utils/helpers';
import { setInStorage, STORAGE_KEYS } from '@/utils/storageHelpers';
import {
checkIPFSGateway,
getIPFSGateway,
IPFS_GATEWAYS,
} from '@/utils/uriHelpers';

import { SubmitButton } from '../SubmitButton';

type Option = {
readonly label: string;
readonly value: string;
};

const createOption = (input: string): Option => {
const url = new URL(input);
const label = `${url.protocol}//${url.hostname}`;
return {
label,
value: label,
};
};

export const IPFSGatewaySettings: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();

const ipfsGateway = getIPFSGateway();

const [isLoading, setLoading] = useState(false);

const defaultOptions = uniqueList([ipfsGateway, ...IPFS_GATEWAYS]).map(g =>
createOption(g),
);

const [options, setOptions] = useState<Option[]>(defaultOptions);
useEffect(() => setOptions(defaultOptions), [defaultOptions, isOpen]);

const [value, setValue] = useState<Option | null>(createOption(ipfsGateway));
useEffect(() => setValue(createOption(ipfsGateway)), [ipfsGateway, isOpen]);

const handleCreate = useCallback(async (inputValue: string) => {
try {
setLoading(true);
await checkIPFSGateway(inputValue);
const newOption = createOption(inputValue);
setOptions(prev => [...prev, newOption]);
setValue(newOption);
} catch (error) {
toast.error('IPFS gateway invalid or not reachable');
} finally {
setLoading(false);
}
}, []);

return (
<Stack
direction={{ base: 'column', md: 'row' }}
align={{ base: 'start', md: 'center' }}
fontWeight="bold"
>
<Text fontSize="lg">IPFS Gateway:</Text>
<HStack pl={4} borderRadius="full" bg="whiteAlpha.100">
<Text>{ipfsGateway}</Text>
<Button
variant="ghost"
bgColor="rgba(71, 85, 105, 0.4)"
onClick={onOpen}
fontSize="xs"
leftIcon={<EditIcon fontSize="sm" />}
>
Change
</Button>
</HStack>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent maxW="48rem">
<ModalHeader>Change IPFS Gateway</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>
Please select a gateway from the following list or add your own.
</Text>
<Text>
You can use the{' '}
<Link
href="https://ipfs.github.io/public-gateway-checker/"
isExternal
textDecor="underline"
color="main"
>
IPFS Public Gateway Checker
</Link>{' '}
to find what works best for your location.
</Text>
<Box w="100%" pt={6} color="black">
<CreatableSelect
isDisabled={isLoading}
isLoading={isLoading}
onChange={setValue}
onCreateOption={handleCreate}
options={options}
value={value}
/>
</Box>
</ModalBody>
<ModalFooter alignItems="baseline">
<Button
variant="ghost"
mr={3}
onClick={() => {
setInStorage(STORAGE_KEYS.IPFS_GATEWAY, ipfsGateway);
onClose();
}}
borderRadius="full"
>
Close
</Button>
<SubmitButton
mt={4}
isDisabled={!value || value.value === ipfsGateway}
onClick={() => {
if (!value) return;
setInStorage(STORAGE_KEYS.IPFS_GATEWAY, value.value);
onClose();
}}
>
Submit
</SubmitButton>
</ModalFooter>
</ModalContent>
</Modal>
</Stack>
);
};
6 changes: 2 additions & 4 deletions components/TokenImage/TokenImageOrVideo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BoxProps, Image as ChakraImage } from '@chakra-ui/react';
import NoImageAvailable from 'assets/no-image-available.svg';
import React, { useCallback, useMemo, useState } from 'react';
import { uriToHttpAsArray } from 'utils/uriHelpers';
import { ipfsUriToHttp } from 'utils/uriHelpers';

import { ImageOrVideo } from './ImageOrVideo';

Expand All @@ -22,9 +22,7 @@ export const TokenImageOrVideo: React.FC<{ uri: string } & BoxProps> = ({
...props
}) => {
const [, refresh] = useState(0);
const srcs = useMemo(() => uriToHttpAsArray(uri), [uri]);

const src = srcs.find(s => !BAD_SRCS[s]);
const src = useMemo(() => ipfsUriToHttp(uri), [uri]);

const onError = useCallback((badSrc: string) => {
if (badSrc && !BAD_SRCS[badSrc]) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"react-markdown": "^8.0.3",
"react-markdown-editor-lite": "^1.3.3",
"react-scroll": "^1.8.7",
"react-select": "^5.7.0",
"react-share": "^4.4.1",
"remark-gfm": "^3.0.1",
"remove-markdown": "^0.5.0",
Expand Down
22 changes: 22 additions & 0 deletions pages/settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Heading } from '@chakra-ui/react';

import { Page } from '@/components/Layout/Page';
import { HeadComponent } from '@/components/Seo';
import { IPFSGatewaySettings } from '@/components/Settings/IPFSGatewaySettings';
import { QUESTCHAINS_URL } from '@/utils/constants';

const Settings: React.FC = () => {
return (
<Page align="start" gap={4}>
<HeadComponent
title="Quest Chains Settings"
description="Quest Chains Settings"
url={QUESTCHAINS_URL + '/settings'}
/>
<Heading>Settings</Heading>
<IPFSGatewaySettings />
</Page>
);
};

export default Settings;
5 changes: 5 additions & 0 deletions utils/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const handleError = (error: unknown) => {

export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export const uniqueList = (arr: string[]): string[] => {
const seen = new Set<string>();
return arr.filter(item => (seen.has(item) ? false : seen.add(item)));
};

export const handleTxLoading = (txHash: string, chainId: string): string => {
return toast.loading(
<Link href={getTxUrl(txHash, chainId)} _hover={{}} isExternal>
Expand Down
17 changes: 17 additions & 0 deletions utils/storageHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const STORAGE_KEYS = {
IPFS_GATEWAY: 'quest-chains-ipfs-gateway',
};

export const getFromStorage = (key: string): string | null => {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(key);
};

export const setInStorage = (key: string, value: string): void => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, value);
};
99 changes: 69 additions & 30 deletions utils/uriHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getFromStorage, STORAGE_KEYS } from './storageHelpers';

const IPFS_URL_ADDON = `ipfs/`;
const IPNS_URL_ADDON = `ipns/`;
const URL_ADDON_LENGTH = 5;
Expand Down Expand Up @@ -25,48 +27,85 @@ const parseUri = (
const hashIndex = uri.indexOf('Qm');
hash = uri.substring(hashIndex);
}

return { protocol, hash, name };
};

export const uriToHttpAsArray = (uri: string): string[] => {
if (!uri) return [];
if (uri.startsWith('data')) return [uri];
export const IPFS_GATEWAYS = [
'https://w3s.link',
'https://gateway.pinata.cloud',
'https://gateway.ipfs.io',
'https://cloudflare-ipfs.com',
'https://ipfs.io',
'https://dweb.link',
];

const DEFAULT_IPFS_GATEWAY = IPFS_GATEWAYS[0];

export const getIPFSGateway = (): string => {
const gateway = getFromStorage(STORAGE_KEYS.IPFS_GATEWAY);
if (gateway) return gateway;
return DEFAULT_IPFS_GATEWAY;
};

export const ipfsUriToHttp = (uri: string | null | undefined): string => {
if (!uri) return '';
if (uri.startsWith('data')) return uri;
const { protocol, hash, name } = parseUri(uri);
const ipfsGateway = getIPFSGateway();
const url = new URL(ipfsGateway);

switch (protocol) {
case 'https':
return [uri];
case 'http':
return [`https${uri.slice(4)}`, uri];
return uri;
case 'ipfs':
if (hash.startsWith('ipfs')) {
const newHash = hash.split('/')[1];
return [
`https://w3s.link/ipfs/${newHash}`,
`https://gateway.ipfs.io/ipfs/${newHash}/`,
`https://gateway.pinata.cloud/ipfs/${newHash}/`,
`https://ipfs.io/ipfs/${newHash}/`,
];
}
return [
`https://w3s.link/ipfs/${hash}`,
`https://gateway.ipfs.io/ipfs/${hash}/`,
`https://gateway.pinata.cloud/ipfs/${hash}/`,
`https://ipfs.io/ipfs/${hash}/`,
];
return `${url.protocol}//${url.hostname}/ipfs/${hash}`;
case 'ipns':
return [
`https://gateway.ipfs.io/ipns/${name}/`,
`https://gateway.pinata.cloud/ipns/${name}/`,
`https://ipfs.io/ipns/${name}/`,
];
return `${url.protocol}//${url.hostname}/ipns/${name}`;
default:
return [];
return '';
}
};

export const ipfsUriToHttp = (uri: string | null | undefined): string => {
if (!uri) return '';
const array = uriToHttpAsArray(uri);
return array[0] ?? '';
const IMG_HASH = 'bafybeibwzifw52ttrkqlikfzext5akxu7lz4xiwjgwzmqcpdzmp3n5vnbe';

export const checkIPFSGateway = (gatewayUrl: string): Promise<void> => {
const url = new URL(gatewayUrl);
const imgUrl = new URL(
`${url.protocol}//${
url.hostname
}/ipfs/${IMG_HASH}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`,
);

// we check if gateway is up by loading 1x1 px image:
// this is more robust check than loading js, as it won't be blocked
// by privacy protections present in modern browsers or in extensions such as Privacy Badger
const imgCheckTimeout = 15000;
return new Promise((resolve, reject) => {
const timeout = () => {
if (!timer) return false;
clearTimeout(timer);
timer = null;
return true;
};

let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
if (timeout()) reject(new Error());
}, imgCheckTimeout);
const img = new Image();

img.onerror = () => {
timeout();
reject(new Error());
};

img.onload = () => {
// subdomain works
timeout();
resolve();
};

img.src = imgUrl.toString();
});
};
Loading

0 comments on commit 06ff3bf

Please sign in to comment.