bridge_ui: improve NFTViewer

Change-Id: I576441d6516f2a45bd86586369b3b83b71194df4
This commit is contained in:
Evan Gray 2021-09-20 21:29:11 -04:00
parent 8d8197f293
commit a62017176a
14 changed files with 289 additions and 67 deletions

View File

@ -7,7 +7,6 @@
"": {
"name": "test_ui",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@certusone/wormhole-sdk": "file:..\\sdk\\js",
"@material-ui/core": "^4.12.2",
@ -36,6 +35,7 @@
"bn.js": "^5.1.3",
"borsh": "^0.4.0",
"bs58": "^4.0.1",
"clsx": "^1.1.1",
"ethers": "^5.4.1",
"js-base64": "^3.6.1",
"notistack": "^1.0.10",
@ -58,7 +58,8 @@
}
},
"../sdk/js": {
"version": "0.0.2",
"name": "@certusone/wormhole-sdk",
"version": "0.0.4",
"license": "Apache-2.0",
"dependencies": {
"@improbable-eng/grpc-web": "^0.14.0",

View File

@ -30,6 +30,7 @@
"bn.js": "^5.1.3",
"borsh": "^0.4.0",
"bs58": "^4.0.1",
"clsx": "^1.1.1",
"ethers": "^5.4.1",
"js-base64": "^3.6.1",
"notistack": "^1.0.10",

View File

@ -50,7 +50,7 @@ export default function SourcePreview() {
{explainerContent}
</Typography>
{sourceParsedTokenAccount ? (
<NFTViewer value={sourceParsedTokenAccount} />
<NFTViewer value={sourceParsedTokenAccount} chainId={sourceChain} />
) : null}
</>
);

View File

@ -4,13 +4,13 @@ import {
CHAIN_ID_SOLANA,
} from "@certusone/wormhole-sdk";
import { Button, makeStyles, Tooltip, Typography } from "@material-ui/core";
import { FileCopy, OpenInNew } from "@material-ui/icons";
import { withStyles } from "@material-ui/styles";
import { useSnackbar } from "notistack";
import { useCallback } from "react";
import clsx from "clsx";
import useCopyToClipboard from "../hooks/useCopyToClipboard";
import { ParsedTokenAccount } from "../store/transferSlice";
import { CLUSTER } from "../utils/consts";
import { shortenAddress } from "../utils/solana";
import { FileCopy, OpenInNew } from "@material-ui/icons";
const useStyles = makeStyles((theme) => ({
mainTypog: {
@ -20,20 +20,19 @@ const useStyles = makeStyles((theme) => ({
textDecoration: "underline",
textUnderlineOffset: "2px",
},
noGutter: {
marginLeft: 0,
marginRight: 0,
},
noUnderline: {
textDecoration: "none",
},
buttons: {
marginLeft: ".5rem",
marginRight: ".5rem",
},
}));
function pushToClipboard(content: any) {
if (!navigator.clipboard) {
// Clipboard API not available
return;
}
return navigator.clipboard.writeText(content);
}
const tooltipStyles = {
tooltip: {
minWidth: "max-content",
@ -54,6 +53,8 @@ export default function SmartAddress({
symbol,
tokenName,
variant,
noGutter,
noUnderline,
}: {
chainId: ChainId;
parsedTokenAccount?: ParsedTokenAccount;
@ -62,13 +63,14 @@ export default function SmartAddress({
tokenName?: string;
symbol?: string;
variant?: any;
noGutter?: boolean;
noUnderline?: boolean;
}) {
const classes = useStyles();
const useableAddress = parsedTokenAccount?.mintKey || address || "";
const useableSymbol = parsedTokenAccount?.symbol || symbol || "";
const isNative = parsedTokenAccount?.isNativeAsset || false;
const addressShort = shortenAddress(useableAddress) || "";
const { enqueueSnackbar } = useSnackbar();
const useableName = isNative
? "Native Currency"
@ -95,11 +97,7 @@ export default function SmartAddress({
: undefined;
const explorerName = chainId === CHAIN_ID_ETH ? "Etherscan" : "Explorer";
const copyToClipboard = useCallback(() => {
pushToClipboard(useableAddress)?.then(() => {
enqueueSnackbar("Copied address to clipboard.", { variant: "success" });
});
}, [useableAddress, enqueueSnackbar]);
const copyToClipboard = useCopyToClipboard(useableAddress);
const explorerButton = !explorerAddress ? null : (
<Button
@ -149,7 +147,10 @@ export default function SmartAddress({
>
<Typography
variant={variant || "body1"}
className={classes.mainTypog}
className={clsx(classes.mainTypog, {
[classes.noGutter]: noGutter,
[classes.noUnderline]: noUnderline,
})}
component="div"
>
{useableSymbol || addressShort}

View File

@ -27,6 +27,7 @@ import { NFTParsedTokenAccount } from "../../store/nftSlice";
import NFTViewer from "./NFTViewer";
import { useDebounce } from "use-debounce/lib";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
import { CHAIN_ID_ETH } from "@certusone/wormhole-sdk";
const useStyles = makeStyles(() =>
createStyles({
@ -281,22 +282,29 @@ export default function EthereumSourceTokenSelector(
!cancelled && setAdvancedModeLoading(false);
});
} else {
console.error("no NFT result");
!cancelled &&
setAdvancedModeError(
"This token does not support ERC-721"
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
}
})
.catch((error) => {
console.error("isNFT", error);
!cancelled &&
setAdvancedModeError("This token does not support ERC-721");
setAdvancedModeError(
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
});
})
.catch((error) => {
console.error("getEthereumNFT", error);
!cancelled &&
setAdvancedModeError("This token does not support ERC-721");
setAdvancedModeError(
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
});
} else {
@ -506,7 +514,7 @@ export default function EthereumSourceTokenSelector(
const content = value ? (
<>
{nft ? (
<NFTViewer value={value} />
<NFTViewer value={value} chainId={CHAIN_ID_ETH} />
) : (
<RefreshButtonWrapper callback={resetAccountWrapper}>
<Typography>

View File

@ -1,13 +1,25 @@
import {
Avatar,
Card,
CardContent,
CardMedia,
makeStyles,
Tooltip,
Typography,
} from "@material-ui/core";
import axios from "axios";
import { useEffect, useState } from "react";
import { NFTParsedTokenAccount } from "../../store/nftSlice";
import clsx from "clsx";
import {
ChainId,
CHAIN_ID_ETH,
CHAIN_ID_SOLANA,
} from "@certusone/wormhole-sdk";
import SmartAddress from "../SmartAddress";
import ethIcon from "../../icons/eth.svg";
import solanaIcon from "../../icons/solana.svg";
import useCopyToClipboard from "../../hooks/useCopyToClipboard";
const safeIPFS = (uri: string) =>
uri.startsWith("ipfs://ipfs/")
@ -18,34 +30,128 @@ const safeIPFS = (uri: string) =>
? uri.replace("https://cloudflare-ipfs.com/ipfs/", "https://ipfs.io/ipfs/")
: uri;
const LogoIcon = ({ chainId }: { chainId: ChainId }) =>
chainId === CHAIN_ID_SOLANA ? (
<Avatar
style={{
backgroundColor: "black",
height: "1em",
width: "1em",
marginLeft: "4px",
}}
src={solanaIcon}
alt="Solana"
/>
) : chainId === CHAIN_ID_ETH ? (
<Avatar
style={{
backgroundColor: "white",
height: "1em",
width: "1em",
marginLeft: "4px",
}}
src={ethIcon}
alt="Ethereum"
/>
) : null;
const useStyles = makeStyles((theme) => ({
card: {
background: "transparent",
border: `1px solid ${theme.palette.divider}`,
maxWidth: 480,
width: 480,
borderRadius: 9,
maxWidth: "100%",
width: 400,
margin: `${theme.spacing(1)}px auto`,
padding: 8,
position: "relative",
zIndex: 1,
transition: "background-position 1s, transform 0.25s",
"&:hover": {
backgroundPosition: "right center",
transform: "scale(1.25)",
},
backgroundSize: "200% auto",
backgroundColor: "#ffb347",
background:
"linear-gradient(to right, #ffb347 0%, #ffcc33 51%, #ffb347 100%)",
},
solanaBorder: {
backgroundColor: "#D9D8D6",
backgroundSize: "200% auto",
background:
"linear-gradient(to bottom right, #757F9A 0%, #D7DDE8 51%, #757F9A 100%)",
"&:hover": {
backgroundPosition: "right center",
},
},
cardInset: {},
textContent: {
background: theme.palette.background.paper,
background: "transparent",
paddingTop: 4,
paddingBottom: 2,
display: "flex",
},
detailsContent: {
background: "transparent",
paddingTop: 4,
paddingBottom: 2,
"&:last-child": {
//override rule
paddingBottom: 2,
},
},
title: {
flex: 1,
},
description: {
padding: theme.spacing(0.5, 0, 1),
},
tokenId: {
fontSize: "8px",
},
mediaContent: {
display: "flex",
background: "transparent",
border: "1px solid #ffb347",
margin: theme.spacing(0, 2),
},
solanaMediaBorder: {
borderColor: "#D7DDE8",
},
// thanks https://cssgradient.io/
eth: {
// colors from https://en.wikipedia.org/wiki/Ethereum#/media/File:Ethereum-icon-purple.svg
backgroundColor: "rgb(69,74,117)",
background:
"linear-gradient(160deg, rgba(69,74,117,1) 0%, rgba(138,146,178,1) 33%, rgba(69,74,117,1) 66%, rgba(98,104,143,1) 100%)",
},
solana: {
// colors from https://solana.com/branding/new/exchange/exchange-sq-black.svg
backgroundColor: "rgb(153,69,255)",
background:
"linear-gradient(45deg, rgba(153,69,255,1) 0%, rgba(121,98,231,1) 20%, rgba(0,209,140,1) 100%)",
},
}));
export default function NFTViewer({ value }: { value: NFTParsedTokenAccount }) {
export default function NFTViewer({
value,
chainId,
}: {
value: NFTParsedTokenAccount;
chainId: ChainId;
}) {
const uri = safeIPFS(value.uri || "");
const [metadata, setMetadata] = useState({
image: value.image,
animation_url: value.animation_url,
name: value.name,
nftName: value.nftName,
description: value.description,
});
useEffect(() => {
setMetadata({
image: value.image,
animation_url: value.animation_url,
name: value.name,
nftName: value.nftName,
description: value.description,
});
}, [value]);
useEffect(() => {
@ -54,10 +160,12 @@ export default function NFTViewer({ value }: { value: NFTParsedTokenAccount }) {
(async () => {
const result = await axios.get(uri);
if (!cancelled && result && result.data) {
console.log(result.data);
setMetadata({
image: result.data.image,
animation_url: result.data.animation_url,
name: result.data.name,
nftName: result.data.name,
description: result.data.description,
});
}
})();
@ -85,36 +193,76 @@ export default function NFTViewer({ value }: { value: NFTParsedTokenAccount }) {
const image = (
<img
src={safeIPFS(metadata.image || "")}
alt={metadata.name || ""}
alt={metadata.nftName || ""}
style={{ maxWidth: "100%" }}
/>
);
const copyTokenId = useCopyToClipboard(value.tokenId || "");
return (
<Card className={classes.card} elevation={10}>
<CardContent className={classes.textContent}>
<Typography>
{(value.symbol ? value.symbol + " " : "") + value.mintKey}
</Typography>
{metadata.name || value.tokenId ? (
<Typography>
{metadata.name}
{value.tokenId ? ` (${value.tokenId})` : null}
</Typography>
) : null}
</CardContent>
<CardMedia className={classes.mediaContent}>
{hasVideo ? (
<video controls style={{ maxWidth: "100%" }}>
<source src={safeIPFS(metadata.animation_url || "")} />
{image}
</video>
) : (
image
)}
{hasAudio ? (
<audio controls src={safeIPFS(metadata.animation_url || "")} />
) : null}
</CardMedia>
<Card
className={clsx(classes.card, {
[classes.solanaBorder]: chainId === CHAIN_ID_SOLANA,
})}
elevation={10}
>
<div
className={clsx(classes.cardInset, {
[classes.eth]: chainId === CHAIN_ID_ETH,
[classes.solana]: chainId === CHAIN_ID_SOLANA,
})}
>
<CardContent className={classes.textContent}>
{metadata.nftName ? (
<Typography className={classes.title}>
{metadata.nftName}
</Typography>
) : (
<div className={classes.title} />
)}
<SmartAddress
chainId={chainId}
parsedTokenAccount={value}
noGutter
noUnderline
/>
<LogoIcon chainId={chainId} />
</CardContent>
<CardMedia
className={clsx(classes.mediaContent, {
[classes.solanaMediaBorder]: chainId === CHAIN_ID_SOLANA,
})}
>
{hasVideo ? (
<video controls style={{ maxWidth: "100%" }}>
<source src={safeIPFS(metadata.animation_url || "")} />
{image}
</video>
) : (
image
)}
{hasAudio ? (
<audio controls src={safeIPFS(metadata.animation_url || "")} />
) : null}
</CardMedia>
<CardContent className={classes.detailsContent}>
{metadata.description ? (
<Typography variant="body2" className={classes.description}>
{metadata.description}
</Typography>
) : null}
{value.tokenId ? (
<Typography className={classes.tokenId} align="right">
<Tooltip title="Copy" arrow>
<span onClick={copyTokenId}>
{value.tokenId.length > 18
? `#${value.tokenId.substr(0, 16)}...`
: `#${value.tokenId}`}
</span>
</Tooltip>
</Typography>
) : null}
</CardContent>
</div>
</Card>
);
}

View File

@ -1,3 +1,4 @@
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import { CircularProgress, TextField, Typography } from "@material-ui/core";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { Alert, Autocomplete } from "@material-ui/lab";
@ -318,7 +319,9 @@ export default function SolanaSourceTokenSelector(
<React.Fragment>
{isLoading ? <CircularProgress /> : wrappedContent}
{error && <Typography color="error">{error}</Typography>}
{nft && value ? <NFTViewer value={value} /> : null}
{nft && value ? (
<NFTViewer value={value} chainId={CHAIN_ID_SOLANA} />
) : null}
</React.Fragment>
);
}

View File

@ -0,0 +1,12 @@
import { useSnackbar } from "notistack";
import { useCallback } from "react";
import pushToClipboard from "../utils/pushToClipboard";
export default function useCopyToClipboard(content: string) {
const { enqueueSnackbar } = useSnackbar();
return useCallback(() => {
pushToClipboard(content)?.then(() => {
enqueueSnackbar("Copied", { variant: "success" });
});
}, [content, enqueueSnackbar]);
}

View File

@ -94,12 +94,14 @@ export function createNFTParsedTokenAccount(
uiAmountString: string,
tokenId: string,
symbol?: string,
name?: string,
uri?: string,
animation_url?: string,
external_url?: string,
image?: string,
image_256?: string,
name?: string
nftName?: string,
description?: string
): NFTParsedTokenAccount {
return {
publicKey,
@ -116,6 +118,8 @@ export function createNFTParsedTokenAccount(
image_256,
symbol,
name,
nftName,
description,
};
}
@ -191,6 +195,7 @@ const createNFTParsedTokenAccountFromCovalent = (
covalent.contract_decimals
),
symbol: covalent.contract_ticker_symbol,
name: covalent.contract_name,
logo: covalent.logo_url,
tokenId: nft_data.token_id,
uri: nft_data.token_url,
@ -198,7 +203,8 @@ const createNFTParsedTokenAccountFromCovalent = (
external_url: nft_data.external_data.external_url,
image: nft_data.external_data.image,
image_256: nft_data.external_data.image_256,
name: nft_data.external_data.name,
nftName: nft_data.external_data.name,
description: nft_data.external_data.description,
};
};
@ -220,6 +226,7 @@ export type CovalentNFTExternalData = {
image: string;
image_256: string;
name: string;
description: string;
};
export type CovalentNFTData = {
@ -492,6 +499,7 @@ function useGetAvailableTokens(nft: boolean = false) {
//const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c";
// const nftTestWallet1 = "0x3f304c6721f35ff9af00fd32650c8e0a982180ab";
// const nftTestWallet2 = "0x98ed231428088eb440e8edb5cc8d66dcf913b86e";
// const nftTestWallet3 = "0xb1fadf677a7e9b90e9d4f31c8ffb3dc18c138c6f";
let cancelled = false;
const walletAddress = signerAddress;
if (walletAddress && lookupChain === CHAIN_ID_ETH && !covalent) {

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1920 1920" enable-background="new 0 0 1920 1920" xml:space="preserve">
<g>
<polygon fill="#8A92B2" points="959.8,80.7 420.1,976.3 959.8,731 "/>
<polygon fill="#62688F" points="959.8,731 420.1,976.3 959.8,1295.4 "/>
<polygon fill="#62688F" points="1499.6,976.3 959.8,80.7 959.8,731 "/>
<polygon fill="#454A75" points="959.8,1295.4 1499.6,976.3 959.8,731 "/>
<polygon fill="#8A92B2" points="420.1,1078.7 959.8,1839.3 959.8,1397.6 "/>
<polygon fill="#62688F" points="959.8,1397.6 959.8,1839.3 1499.9,1078.7 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 807 B

View File

@ -0,0 +1,16 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" fill="black"/>
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M123.42 255.13C124.173 254.302 125.09 253.641 126.113 253.188C127.135 252.735 128.242 252.501 129.36 252.5L312.64 252.65C313.421 252.652 314.184 252.88 314.837 253.307C315.49 253.734 316.006 254.342 316.32 255.056C316.635 255.77 316.735 256.561 316.609 257.331C316.483 258.101 316.136 258.818 315.61 259.395L276.58 302.37C275.827 303.198 274.909 303.86 273.885 304.313C272.862 304.766 271.755 305 270.635 305L87.3602 304.85C86.5796 304.848 85.8164 304.62 85.1631 304.193C84.5098 303.766 83.9946 303.158 83.6801 302.444C83.3655 301.73 83.2652 300.939 83.3913 300.169C83.5173 299.399 83.8643 298.682 84.3902 298.105L123.42 255.13ZM315.61 219.355C316.136 219.932 316.483 220.649 316.609 221.419C316.735 222.189 316.635 222.98 316.32 223.694C316.006 224.408 315.49 225.016 314.837 225.443C314.184 225.87 313.421 226.098 312.64 226.1L129.365 226.25C128.246 226.25 127.139 226.016 126.115 225.563C125.091 225.11 124.173 224.448 123.42 223.62L84.3902 180.62C83.8643 180.043 83.5173 179.326 83.3913 178.556C83.2652 177.786 83.3655 176.995 83.6801 176.281C83.9946 175.567 84.5098 174.959 85.1631 174.532C85.8164 174.105 86.5796 173.877 87.3602 173.875L270.64 173.725C271.759 173.726 272.865 173.96 273.888 174.413C274.911 174.866 275.828 175.527 276.58 176.355L315.61 219.355ZM123.42 97.63C124.173 96.8023 125.09 96.1408 126.113 95.6879C127.135 95.2351 128.242 95.0007 129.36 95L312.64 95.15C313.421 95.1516 314.184 95.3798 314.837 95.8069C315.49 96.234 316.006 96.8416 316.32 97.5559C316.635 98.2703 316.735 99.0606 316.609 99.8308C316.483 100.601 316.136 101.318 315.61 101.895L276.58 144.87C275.827 145.698 274.909 146.36 273.885 146.813C272.862 147.266 271.755 147.5 270.635 147.5L87.3602 147.35C86.5796 147.348 85.8164 147.12 85.1631 146.693C84.5098 146.266 83.9946 145.658 83.6801 144.944C83.3655 144.23 83.2652 143.439 83.3913 142.669C83.5173 141.899 83.8643 141.182 84.3902 140.605L123.42 97.63Z" fill="url(#paint0_linear)"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="90.4202" y1="309.58" x2="309.58" y2="90.42" gradientUnits="userSpaceOnUse">
<stop stop-color="#9945FF"/>
<stop offset="0.2" stop-color="#7962E7"/>
<stop offset="1" stop-color="#00D18C"/>
</linearGradient>
<clipPath id="clip0">
<rect width="240" height="210" fill="white" transform="translate(80 95)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -26,7 +26,8 @@ export interface NFTParsedTokenAccount extends ParsedTokenAccount {
external_url?: string | null;
image?: string;
image_256?: string;
name?: string;
nftName?: string;
description?: string;
}
export interface NFTState {

View File

@ -52,10 +52,11 @@ export async function getEthereumNFT(
export async function isNFT(token: NFTImplementation) {
const erc721 = "0x80ac58cd";
const erc721metadata = "0x5b5e139f";
return (
(await token.supportsInterface(arrayify(erc721))) &&
(await token.supportsInterface(arrayify(erc721metadata)))
const supportsErc721 = await token.supportsInterface(arrayify(erc721));
const supportsErc721Metadata = await token.supportsInterface(
arrayify(erc721metadata)
);
return supportsErc721 && supportsErc721Metadata;
}
export async function ethNFTToNFTParsedTokenAccount(
@ -66,6 +67,7 @@ export async function ethNFTToNFTParsedTokenAccount(
const decimals = 0;
const balance = (await token.ownerOf(tokenId)) === signerAddress ? 1 : 0;
const symbol = await token.symbol();
const name = await token.name();
const uri = await token.tokenURI(tokenId);
return createNFTParsedTokenAccount(
signerAddress,
@ -76,6 +78,7 @@ export async function ethNFTToNFTParsedTokenAccount(
formatUnits(balance, decimals),
tokenId,
symbol,
name,
uri
);
}

View File

@ -0,0 +1,7 @@
export default function pushToClipboard(content: any) {
if (!navigator.clipboard) {
// Clipboard API not available
return;
}
return navigator.clipboard.writeText(content);
}