diff --git a/packages/dashboard/src/pages/Wallets/components/CollectibleList/index.tsx b/packages/dashboard/src/pages/Wallets/components/CollectibleList/index.tsx index d6834fbed62e..695e940d787f 100644 --- a/packages/dashboard/src/pages/Wallets/components/CollectibleList/index.tsx +++ b/packages/dashboard/src/pages/Wallets/components/CollectibleList/index.tsx @@ -64,6 +64,18 @@ export const CollectibleList = memo(({ selectedNetwork }) Asset?.getNonFungibleAssets?.(account, { page: page, size: 20 }, undefined, selectedNetwork || undefined), [account, Asset?.getNonFungibleAssets, network, selectedNetwork], ) + useEffect(() => { + const unsubscribeTokens = PluginMessages.Wallet.events.erc721TokensUpdated.on(() => retry()) + const unsubscribeSocket = PluginMessages.Wallet.events.socketMessageUpdated.on((info) => { + if (!info.done) { + retry() + } + }) + return () => { + unsubscribeTokens() + unsubscribeSocket() + } + }, [retry]) useEffect(() => { if (!loadingSize) return @@ -86,15 +98,6 @@ export const CollectibleList = memo(({ selectedNetwork }) [currentPluginId], ) - useEffect(() => { - PluginMessages.Wallet.events.erc721TokensUpdated.on(() => retry()) - PluginMessages.Wallet.events.socketMessageUpdated.on((info) => { - if (!info.done) { - retry() - } - }) - }, [retry]) - const hasNextPage = (page + 1) * loadingSize < value.data.length const isLoading = renderData.length === 0 && isQuerying diff --git a/packages/icons/general/Drop2.tsx b/packages/icons/general/Drop2.tsx new file mode 100644 index 000000000000..c1c081af5b04 --- /dev/null +++ b/packages/icons/general/Drop2.tsx @@ -0,0 +1,12 @@ +import { createIcon } from '../utils' + +export const Drop2Icon = createIcon( + 'Drop2Icon', + + + , + '0 0 20 24', +) diff --git a/packages/icons/general/Edit2.tsx b/packages/icons/general/Edit2.tsx new file mode 100644 index 000000000000..0e05c13325dd --- /dev/null +++ b/packages/icons/general/Edit2.tsx @@ -0,0 +1,13 @@ +import { createIcon } from '../utils' +import type { SvgIcon } from '@mui/material' + +export const Edit2Icon: typeof SvgIcon = createIcon( + 'Edit2Icon', + + + , + '0 0 24 24', +) diff --git a/packages/icons/general/index.ts b/packages/icons/general/index.ts index a3d4ce6901cf..f9e5c503e831 100644 --- a/packages/icons/general/index.ts +++ b/packages/icons/general/index.ts @@ -12,6 +12,7 @@ export * from './Link' export * from './Author' export * from './Settings' export * from './Edit' +export * from './Edit2' export * from './ArrowDownRound' export * from './ArrowUpRound' export * from './Empty' @@ -79,6 +80,7 @@ export * from './ChevronUp' export * from './BestTrade' export * from './Retweet' export * from './Drop' +export * from './Drop2' export * from './Right' export * from './Tutorial' export * from './AssetLoading' diff --git a/packages/mask/src/plugins/EVM/UI/Web3State/createNonFungibleToken.ts b/packages/mask/src/plugins/EVM/UI/Web3State/createNonFungibleToken.ts new file mode 100644 index 000000000000..218ba80a7734 --- /dev/null +++ b/packages/mask/src/plugins/EVM/UI/Web3State/createNonFungibleToken.ts @@ -0,0 +1,27 @@ +import { TokenType, Web3Plugin } from '@masknet/plugin-infra' +import type { ERC721TokenDetailed } from '@masknet/web3-shared-evm' + +export function createNonFungibleToken(token: ERC721TokenDetailed) { + return { + ...token, + id: `${token.contractDetailed.address}_${token.tokenId}`, + tokenId: token.tokenId, + chainId: token.contractDetailed.chainId, + type: TokenType.NonFungible, + name: token.info.name ?? `${token.contractDetailed.name} ${token.tokenId}`, + description: token.info.description ?? '', + owner: token.info.owner, + contract: { + ...token.contractDetailed, + type: TokenType.NonFungible, + id: token.contractDetailed.address, + }, + metadata: { + name: token.info.name ?? `${token.contractDetailed.name} ${token.tokenId}`, + description: token.info.description ?? '', + mediaType: 'Unknown', + iconURL: token.contractDetailed.iconURL, + assetURL: token.info.mediaUrl, + }, + } as Web3Plugin.NonFungibleToken +} diff --git a/packages/mask/src/plugins/EVM/UI/Web3State/getAssetsFn.ts b/packages/mask/src/plugins/EVM/UI/Web3State/getAssetsFn.ts index 69f40729cae1..f767bbfb913f 100644 --- a/packages/mask/src/plugins/EVM/UI/Web3State/getAssetsFn.ts +++ b/packages/mask/src/plugins/EVM/UI/Web3State/getAssetsFn.ts @@ -1,33 +1,34 @@ -import Web3 from 'web3' +import { Pageable, Pagination, TokenType, Web3Plugin } from '@masknet/plugin-infra' +import BalanceCheckerABI from '@masknet/web3-contracts/abis/BalanceChecker.json' +import ERC721ABI from '@masknet/web3-contracts/abis/ERC721.json' +import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' +import { TokenPrice } from '@masknet/web3-providers' +import { pow10 } from '@masknet/web3-shared-base' import { + ChainId, createContract, + createExternalProvider, createNativeToken, + CurrencyType, ERC721TokenDetailed, formatEthereumAddress, - getERC721TokenDetailedFromChain, + FungibleAssetProvider, + getCoinGeckoCoinId, getERC721TokenAssetFromChain, + getERC721TokenDetailedFromChain, getEthereumConstants, isSameAddress, - Web3ProviderType, - FungibleAssetProvider, - createExternalProvider, - getCoinGeckoCoinId, - CurrencyType, PriceRecord, - ChainId, + Web3ProviderType, } from '@masknet/web3-shared-evm' import BigNumber from 'bignumber.js' -import { Pageable, Pagination, TokenType, Web3Plugin } from '@masknet/plugin-infra' -import BalanceCheckerABI from '@masknet/web3-contracts/abis/BalanceChecker.json' -import ERC721ABI from '@masknet/web3-contracts/abis/ERC721.json' -import type { AbiItem } from 'web3-utils' import { uniqBy } from 'lodash-unified' +import Web3 from 'web3' +import type { AbiItem } from 'web3-utils' import { PLUGIN_NETWORKS } from '../../constants' import { makeSortAssertWithoutChainFn } from '../../utils/token' import { createGetLatestBalance } from './createGetLatestBalance' -import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' -import { pow10 } from '@masknet/web3-shared-base' -import { TokenPrice } from '@masknet/web3-providers' +import { createNonFungibleToken } from './createNonFungibleToken' // tokens unavailable neither from api or balance checker. // https://forum.conflux.fun/t/how-to-upvote-debank-proposal-for-conflux-espace-integration/13935 @@ -215,31 +216,7 @@ export const getNonFungibleTokenFn = const tokenFromProvider = socket.getResult(socketId) const allData: Web3Plugin.NonFungibleToken[] = [...tokenInDb, ...tokenFromProvider] - .map( - (x) => - ({ - ...x, - id: `${x.contractDetailed.address}_${x.tokenId}`, - tokenId: x.tokenId, - chainId: x.contractDetailed.chainId, - type: TokenType.NonFungible, - name: x.info.name ?? `${x.contractDetailed.name} ${x.tokenId}`, - description: x.info.description ?? '', - owner: x.info.owner, - contract: { - ...x.contractDetailed, - type: TokenType.NonFungible, - id: x.contractDetailed.address, - }, - metadata: { - name: x.info.name ?? `${x.contractDetailed.name} ${x.tokenId}`, - description: x.info.description ?? '', - mediaType: 'Unknown', - iconURL: x.contractDetailed.iconURL, - assetURL: x.info.mediaUrl, - }, - } as Web3Plugin.NonFungibleToken), - ) + .map(createNonFungibleToken) .filter((x) => isSameAddress(x.owner, address)) .filter((x) => !network || x.chainId === network.chainId) diff --git a/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx b/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx index c888a6006157..f95c8b0dc1fb 100644 --- a/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx @@ -6,10 +6,13 @@ import { NextIdPage } from '../components/NextIdPage' import { PostTipButton, TipTaskManager } from '../components/Tip' import { PLUGIN_ID } from '../constants' import { RootContext } from '../contexts' +import { setupStorage, storageDefaultValue } from '../storage' const sns: Plugin.SNSAdaptor.Definition = { ...base, - init() {}, + init(signal, context) { + setupStorage(context.createKVStorage('memory', storageDefaultValue)) + }, ProfileTabs: [ { ID: `${PLUGIN_ID}_tabContent`, diff --git a/packages/mask/src/plugins/NextID/components/Tip/AddDialog.tsx b/packages/mask/src/plugins/NextID/components/Tip/AddDialog.tsx new file mode 100644 index 000000000000..be9080da903f --- /dev/null +++ b/packages/mask/src/plugins/NextID/components/Tip/AddDialog.tsx @@ -0,0 +1,156 @@ +import { useNetworkDescriptors } from '@masknet/plugin-infra' +import { ImageIcon, InjectedDialog, InjectedDialogProps } from '@masknet/shared' +import { makeStyles } from '@masknet/theme' +import { + ERC721TokenDetailed, + getERC721ContractDetailed, + getERC721TokenDetailed, + isSameAddress, + useAccount, + useChainId, + useERC721TokenContract, +} from '@masknet/web3-shared-evm' +import { Button, DialogContent, FormControl, TextField, Typography } from '@mui/material' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import { useAsyncFn } from 'react-use' +import { EthereumAddress } from 'wallet.ts' +import { WalletRPC } from '../../../Wallet/messages' +import { useI18N } from '../../locales' + +const useStyles = makeStyles()((theme) => ({ + addButton: { + marginLeft: 'auto', + }, + chain: { + display: 'flex', + }, + row: { + marginTop: theme.spacing(1.5), + }, + chainName: { + marginLeft: theme.spacing(1), + }, + error: { + display: 'flex', + height: 16, + alignItems: 'center', + color: theme.palette.error.main, + '&::before': { + backgroundColor: theme.palette.error.main, + content: '""', + width: 2, + height: 16, + marginRight: theme.spacing(0.5), + }, + }, +})) + +interface Props extends InjectedDialogProps { + onAdd?(token: ERC721TokenDetailed): void +} + +export const AddDialog: FC = ({ onAdd, onClose, ...rest }) => { + const { classes } = useStyles() + const chainId = useChainId() + const account = useAccount() + const [contractAddress, setContractAddress] = useState('') + const [tokenId, setTokenId] = useState('') + const t = useI18N() + const allNetworks = useNetworkDescriptors() + const network = useMemo(() => allNetworks.find((n) => n.chainId === chainId), [allNetworks, chainId]) + const erc721TokenContract = useERC721TokenContract(contractAddress) + + const [, checkOwner] = useAsyncFn(async () => { + if (!erc721TokenContract || !account) return false + const ownerAddress = await erc721TokenContract.methods.ownerOf(tokenId).call() + return isSameAddress(ownerAddress, account) + }, [erc721TokenContract, tokenId, account]) + + const [message, setMessage] = useState('') + const reset = useCallback(() => { + setMessage('') + setContractAddress('') + setTokenId('') + }, []) + + useEffect(() => { + setMessage('') + }, [tokenId, contractAddress]) + + const [state, handleAdd] = useAsyncFn(async () => { + if (!erc721TokenContract || !EthereumAddress.isValid(contractAddress)) { + setMessage(t.tip_add_collectibles_error()) + return + } + + const hasOwnership = await checkOwner() + if (!hasOwnership) { + setMessage(t.tip_add_collectibles_error()) + return + } + const erc721ContractDetailed = await getERC721ContractDetailed(erc721TokenContract, contractAddress, chainId) + const erc721TokenDetailed = await getERC721TokenDetailed( + erc721ContractDetailed, + erc721TokenContract, + tokenId, + chainId, + ) + + if (!erc721TokenDetailed) { + setMessage(t.tip_add_collectibles_error()) + return + } + await WalletRPC.addToken(erc721TokenDetailed) + onAdd?.(erc721TokenDetailed) + reset() + }, [onAdd, t, contractAddress, tokenId]) + + const handleClose = useCallback(() => { + onClose?.() + reset() + }, [onClose]) + + const addButton = useMemo(() => { + return ( + + ) + }, [t, handleAdd, state.loading]) + + if (!network) return null + return ( + + +
+ + {network.name} +
+ + setContractAddress(e.currentTarget.value)} + placeholder={t.tip_add_collectibles_contract_address()} + /> + + + setTokenId(e.currentTarget.value)} + placeholder={t.tip_add_collectibles_token_id()} + /> + + {message ? ( + + {message} + + ) : null} +
+
+ ) +} diff --git a/packages/mask/src/plugins/NextID/components/Tip/NFTSection/NFTList.tsx b/packages/mask/src/plugins/NextID/components/Tip/NFTSection/NFTList.tsx index f19c33b3bcbf..bafa0ba86ed1 100644 --- a/packages/mask/src/plugins/NextID/components/Tip/NFTSection/NFTList.tsx +++ b/packages/mask/src/plugins/NextID/components/Tip/NFTSection/NFTList.tsx @@ -1,16 +1,17 @@ +import { useWeb3State, Web3Plugin } from '@masknet/plugin-infra' import { NFTCardStyledAssetPlayer } from '@masknet/shared' -import { makeStyles } from '@masknet/theme' -import { ERC721TokenDetailed, formatNFT_TokenId, useChainId } from '@masknet/web3-shared-evm' -import { Checkbox, List, ListItem, Radio, Typography } from '@mui/material' +import { makeStyles, ShadowRootTooltip } from '@masknet/theme' +import { formatNFT_TokenId, isSameAddress, useChainId } from '@masknet/web3-shared-evm' +import { Checkbox, Link, List, ListItem, Radio } from '@mui/material' import classnames from 'classnames' import { noop } from 'lodash-unified' import { FC, useCallback } from 'react' +import type { TipNFTKeyPair } from '../../../types' interface Props { - selectedIds: string[] - tokens: ERC721TokenDetailed[] - enableTokenIds?: string[] - onChange?: (ids: string[]) => void + selectedPairs: TipNFTKeyPair[] + tokens: Web3Plugin.NonFungibleToken[] + onChange?: (id: string | null, contractAddress: string) => void limit?: number className: string } @@ -21,111 +22,157 @@ const useStyles = makeStyles()((theme) => ({ right: 0, top: 0, }, + list: { + gridGap: 13, + display: 'grid', + gridTemplateColumns: 'repeat(5, 1fr)', + }, nftItem: { position: 'relative', cursor: 'pointer', - background: theme.palette.mode === 'light' ? '#fff' : '#2F3336', + background: theme.palette.mode === 'light' ? '#EDEFEF' : '#2F3336', display: 'flex', overflow: 'hidden', padding: 0, flexDirection: 'column', - borderRadius: 8, - height: 180, + borderRadius: 12, + height: 100, userSelect: 'none', - width: 120, + width: 100, justifyContent: 'center', }, disabled: { opacity: 0.5, cursor: 'not-allowed', }, + selected: { + position: 'relative', + '&::after': { + position: 'absolute', + border: `2px solid ${theme.palette.primary.main}`, + content: '""', + left: 0, + top: 0, + pointerEvents: 'none', + boxSizing: 'border-box', + width: '100%', + height: '100%', + borderRadius: 12, + }, + }, + unselected: { + opacity: 0.5, + }, loadingFailImage: { width: 64, height: 64, }, + assetPlayerIframe: { + height: 100, + width: 100, + }, + imgWrapper: { + height: '100px !important', + width: '100px !important', + img: { + height: '100%', + width: '100%', + }, + }, + tooltip: { + marginBottom: `${theme.spacing(0.5)} !important`, + }, })) -function arrayRemove(arr: T[], item: T): T[] { - const idx = arr.indexOf(item) - return arr.splice(idx, 1) -} - interface NFTItemProps { - token: ERC721TokenDetailed + token: Web3Plugin.NonFungibleToken } export const NFTItem: FC = ({ token }) => { const { classes } = useStyles() const chainId = useChainId() return ( - <> - - - {formatNFT_TokenId(token.tokenId, 2)} - - + ) } -export const NFTList: FC = ({ selectedIds, tokens, enableTokenIds = [], onChange, limit = 1, className }) => { +const includes = (pairs: TipNFTKeyPair[], pair: TipNFTKeyPair): boolean => { + return !!pairs.find(([address, tokenId]) => isSameAddress(address, pair[0]) && tokenId === pair[1]) +} + +export const NFTList: FC = ({ selectedPairs, tokens, onChange, limit = 1, className }) => { const { classes } = useStyles() const isRadio = limit === 1 - const reachedLimit = selectedIds.length >= limit + const reachedLimit = selectedPairs.length >= limit const toggleItem = useCallback( - (currentId: string) => { - if (!onChange) return - if (isRadio) return onChange([currentId]) - let newIds = [...selectedIds] - if (selectedIds.includes(currentId)) { - arrayRemove(newIds, currentId) - } else if (!reachedLimit) { - newIds = [...selectedIds, currentId] - } - onChange(newIds) + (currentId: string | null, contractAddress: string) => { + onChange?.(currentId, contractAddress) }, - [selectedIds, onChange, isRadio, reachedLimit], + [onChange, isRadio, reachedLimit], ) const SelectComponent = isRadio ? Radio : Checkbox + const { Utils } = useWeb3State() return ( - + {tokens.map((token) => { - const isNotOwned = !enableTokenIds.includes(token.tokenId) - const disabled = (!isRadio && reachedLimit && !selectedIds.includes(token.tokenId)) || isNotOwned + const selected = includes(selectedPairs, [token.contract?.address!, token.tokenId]) + const disabled = !isRadio && reachedLimit && !selected + const link = + Utils?.resolveNonFungibleTokenLink && token.contract + ? Utils.resolveNonFungibleTokenLink( + token.contract?.chainId, + token.contract?.address, + token.tokenId, + ) + : undefined return ( - { - if (disabled) return - toggleItem(token.tokenId) - }}> - - { - if (disabled) return - toggleItem(token.tokenId) - }} - className={classes.checkbox} - checked={selectedIds.includes(token.tokenId)} - /> - + title={`${token.contract?.name} ${formatNFT_TokenId(token.tokenId, 2)}`} + placement="top" + arrow> + 0 && !selected, + })}> + + + + { + if (disabled || !token.contract?.address) return + if (selected) { + toggleItem(null, '') + } else { + toggleItem(token.tokenId, token.contract.address) + } + }} + className={classes.checkbox} + checked={selected} + /> + + ) })} diff --git a/packages/mask/src/plugins/NextID/components/Tip/NFTSection/index.tsx b/packages/mask/src/plugins/NextID/components/Tip/NFTSection/index.tsx index 8232f6a1b325..890b18d2701f 100644 --- a/packages/mask/src/plugins/NextID/components/Tip/NFTSection/index.tsx +++ b/packages/mask/src/plugins/NextID/components/Tip/NFTSection/index.tsx @@ -1,14 +1,17 @@ +import { useNetworkDescriptor, useWeb3State as useWeb3PluginState } from '@masknet/plugin-infra' +import { EMPTY_LIST } from '@masknet/shared-base' import { makeStyles } from '@masknet/theme' -import { ERC721TokenDetailed, useAccount, useERC721TokenDetailedCallback } from '@masknet/web3-shared-evm' -import { useERC721TokenDetailedOwnerList } from '@masknet/web3-providers' -import { Button, FormControl, Typography } from '@mui/material' +import { isSameAddress, useAccount } from '@masknet/web3-shared-evm' +import { Button, CircularProgress, Typography } from '@mui/material' import classnames from 'classnames' -import { FC, HTMLProps, useCallback, useMemo, useState } from 'react' -import { SearchInput } from '../../../../../extension/options-page/DashboardComponents/SearchInput' -import { ERC721ContractSelectPanel } from '../../../../../web3/UI/ERC721ContractSelectPanel' -import { TargetChainIdContext, useTip } from '../../../contexts' -import { NFTList } from './NFTList' +import { uniqWith } from 'lodash-unified' +import { FC, HTMLProps, useEffect, useMemo, useState } from 'react' +import { useAsyncFn, useTimeoutFn } from 'react-use' +import { WalletMessages } from '../../../../Wallet/messages' +import { useTip } from '../../../contexts' import { useI18N } from '../../../locales' +import type { TipNFTKeyPair } from '../../../types' +import { NFTList } from './NFTList' export * from './NFTList' @@ -17,36 +20,28 @@ const useStyles = makeStyles()((theme) => ({ display: 'flex', flexDirection: 'column', overflow: 'auto', + height: 282, }, selectSection: { - marginTop: theme.spacing(1.5), display: 'flex', flexDirection: 'column', overflow: 'auto', }, + statusBox: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: 282, + }, + loadingText: { + marginTop: theme.spacing(1), + }, list: { flexGrow: 1, - marginTop: theme.spacing(2), - display: 'grid', - gridTemplateColumns: 'repeat(4, 1fr)', maxHeight: 400, overflow: 'auto', - gridGap: 18, - backgroundColor: theme.palette.background.default, borderRadius: 4, - padding: theme.spacing(1), - }, - keyword: { - borderRadius: 8, - marginRight: theme.spacing(1.5), - }, - searchButton: { - borderRadius: 8, - width: 100, - }, - row: { - display: 'flex', - flexDirection: 'row', }, errorMessage: { marginTop: theme.spacing(3), @@ -56,74 +51,103 @@ const useStyles = makeStyles()((theme) => ({ }, })) -interface Props extends HTMLProps {} +interface Props extends HTMLProps { + onAddToken?(): void + onEmpty?(empty: boolean): void +} -export const NFTSection: FC = ({ className, ...rest }) => { - const t = useI18N() - const { targetChainId: chainId } = TargetChainIdContext.useContainer() - const { erc721Contract, setErc721Contract, erc721TokenId, setErc721TokenId, isSending } = useTip() - const [tokenId, setTokenId, erc721TokenDetailedCallback] = useERC721TokenDetailedCallback(erc721Contract) +export const NFTSection: FC = ({ className, onAddToken, onEmpty, ...rest }) => { + const { erc721Address, erc721TokenId, setErc721TokenId, setErc721Address } = useTip() const { classes } = useStyles() + const t = useI18N() const account = useAccount() - const { tokenDetailedOwnerList: myTokens = [] } = useERC721TokenDetailedOwnerList(erc721Contract, account) + const selectedPairs: TipNFTKeyPair[] = useMemo( + () => (erc721Address && erc721TokenId ? [[erc721Address, erc721TokenId]] : []), + [erc721TokenId, erc721TokenId], + ) + const { Asset } = useWeb3PluginState() + + const networkDescriptor = useNetworkDescriptor() + + // Cannot get the loading status of fetching via websocket + // loading status of `useAsyncRetry` is not the real status + const [guessLoading, setGuessLoading] = useState(true) + useTimeoutFn(() => { + setGuessLoading(false) + }, 10000) + + const [{ value = { data: EMPTY_LIST }, loading }, fetchTokens] = useAsyncFn(async () => { + const result = await Asset?.getNonFungibleAssets?.(account, { page: 0 }, undefined, networkDescriptor) + return result + }, [account, Asset?.getNonFungibleAssets, networkDescriptor]) + + useEffect(() => { + fetchTokens() + }, [fetchTokens]) + + useEffect(() => { + const unsubscribeTokens = WalletMessages.events.erc721TokensUpdated.on(fetchTokens) + const unsubscribeSocket = WalletMessages.events.socketMessageUpdated.on((info) => { + setGuessLoading(info.done) + if (!info.done) { + fetchTokens() + } + }) + return () => { + unsubscribeTokens() + unsubscribeSocket() + } + }, [fetchTokens]) + + const fetchedTokens = value?.data ?? EMPTY_LIST - const selectedIds = useMemo(() => (erc721TokenId ? [erc721TokenId] : []), [erc721TokenId]) + const tokens = useMemo(() => { + return uniqWith(fetchedTokens, (v1, v2) => { + return isSameAddress(v1.contract?.address, v2.contract?.address) && v1.tokenId === v2.tokenId + }) + }, [fetchedTokens]) - const [searchedToken, setSearchedToken] = useState(null) - const onSearch = useCallback(async () => { - const token = await erc721TokenDetailedCallback() - setSearchedToken(token?.info.owner ? token : null) - }, [erc721TokenDetailedCallback]) + const showLoadingIndicator = tokens.length === 0 && !loading && !guessLoading - const tokens = useMemo(() => (searchedToken ? [searchedToken] : myTokens), [searchedToken, myTokens]) - const enableTokenIds = useMemo(() => myTokens.map((t) => t.tokenId), [myTokens]) + useEffect(() => { + onEmpty?.(showLoadingIndicator) + }, [onEmpty, showLoadingIndicator]) return (
- - - - {erc721Contract ? ( -
- - setTokenId(id)} - inputBaseProps={{ - disabled: isSending, - }} - label="" - /> - - - { - setErc721TokenId(ids.length ? ids[0] : null) - }} - /> -
- ) : null} - {tokens.length === 1 && !enableTokenIds.includes(tokens[0].tokenId) ? ( - - {t.nft_not_belong_to_you()} - - ) : null} +
+ {(() => { + if (tokens.length) { + return ( + { + setErc721TokenId(id) + setErc721Address(address) + }} + /> + ) + } + if (loading || guessLoading) { + return ( +
+ + {t.tip_loading()} +
+ ) + } + return ( +
+ {t.tip_empty_nft()} + +
+ ) + })()} +
) } diff --git a/packages/mask/src/plugins/NextID/components/Tip/TipDialog.tsx b/packages/mask/src/plugins/NextID/components/Tip/TipDialog.tsx index aa6a349703ba..12945c87e5e1 100644 --- a/packages/mask/src/plugins/NextID/components/Tip/TipDialog.tsx +++ b/packages/mask/src/plugins/NextID/components/Tip/TipDialog.tsx @@ -1,22 +1,45 @@ -import { SuccessIcon } from '@masknet/icons' -import { PluginId, useActivatedPlugin, useCurrentWeb3NetworkPluginID } from '@masknet/plugin-infra' -import { InjectedDialog, NFTCardStyledAssetPlayer } from '@masknet/shared' +import { Drop2Icon, LinkOutIcon, SuccessIcon } from '@masknet/icons' +import { + PluginId, + useActivatedPlugin, + useCurrentWeb3NetworkPluginID, + useNetworkDescriptor, + useProviderDescriptor, + useReverseAddress, + useWeb3State, +} from '@masknet/plugin-infra' +import { InjectedDialog, NFTCardStyledAssetPlayer, WalletIcon } from '@masknet/shared' import { EMPTY_LIST } from '@masknet/shared-base' -import { openWindow } from '@masknet/shared-base-ui' +import { useRemoteControlledDialog } from '@masknet/shared-base-ui' import { makeStyles } from '@masknet/theme' -import { TransactionStateType, useChainId, useERC721TokenDetailed } from '@masknet/web3-shared-evm' -import { DialogContent, Typography } from '@mui/material' +import { + ERC721TokenDetailed, + TransactionStateType, + useAccount, + useChainId, + useERC721TokenDetailed, +} from '@masknet/web3-shared-evm' +import { DialogContent, Link, Typography } from '@mui/material' import { useCallback, useEffect, useMemo } from 'react' import { useBoolean } from 'react-use' +import { hasNativeAPI, nativeAPI } from '../../../../../shared/native-rpc' import { NetworkTab } from '../../../../components/shared/NetworkTab' import { activatedSocialNetworkUI } from '../../../../social-network' +import { WalletMessages } from '../../../Wallet/messages' import { TargetChainIdContext, useTip } from '../../contexts' import { useI18N } from '../../locales' import { TipType } from '../../types' import { ConfirmModal } from '../ConfirmModal' +import { AddDialog } from './AddDialog' import { TipForm } from './TipForm' const useStyles = makeStyles()((theme) => ({ + dialog: { + width: 600, + }, + dialogTitle: { + height: 60, + }, content: { display: 'flex', flexDirection: 'column', @@ -69,6 +92,53 @@ const useStyles = makeStyles()((theme) => ({ width: 64, height: 64, }, + walletChip: { + marginLeft: 'auto', + height: 40, + boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + backgroundColor: theme.palette.background.default, + padding: theme.spacing(0.5, 1), + borderRadius: 99, + }, + wallet: { + marginLeft: theme.spacing(1), + }, + walletTitle: { + marginLeft: theme.spacing(1), + lineHeight: '18px', + height: 18, + fontSize: 14, + fontWeight: 'bold', + }, + walletAddress: { + height: 12, + display: 'flex', + alignItems: 'center', + fontSize: 10, + color: theme.palette.text.secondary, + }, + changeWalletButton: { + marginLeft: theme.spacing(0.5), + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }, + link: { + cursor: 'pointer', + lineHeight: '10px', + marginTop: 2, + '&:hover': { + textDecoration: 'none', + }, + }, + linkIcon: { + fill: 'none', + width: 12, + height: 12, + marginLeft: theme.spacing(0.5), + }, })) interface TipDialogProps { @@ -83,12 +153,25 @@ export function TipDialog({ open = false, onClose }: TipDialogProps) { const t = useI18N() const { classes } = useStyles() + const [addTokenDialogIsOpen, openAddTokenDialog] = useBoolean(false) const [confirmModalIsOpen, openConfirmModal] = useBoolean(false) const { targetChainId, setTargetChainId } = TargetChainIdContext.useContainer() - const { tipType, amount, token, recipientSnsId, recipient, sendState, erc721Contract, erc721TokenId } = useTip() + const { + tipType, + amount, + token, + recipientSnsId, + recipient, + sendState, + erc721Contract, + erc721TokenId, + setErc721Address, + setErc721TokenId, + reset, + } = useTip() const isTokenTip = tipType === TipType.Token - const shareLink = useMemo(() => { + const shareText = useMemo(() => { const promote = t.tip_mask_promote() const message = isTokenTip ? t.tip_token_share_post({ @@ -99,12 +182,12 @@ export function TipDialog({ open = false, onClose }: TipDialogProps) { promote, }) : t.tip_nft_share_post({ - name: erc721Contract?.name || '', + name: erc721Contract?.name || 'NFT', recipientSnsId, recipient, promote, }) - return activatedSocialNetworkUI.utils.getShareLinkURL?.(message) + return message }, [amount, isTokenTip, erc721Contract?.name, token, recipient, recipientSnsId, t]) const { tokenDetailed: erc721Token } = useERC721TokenDetailed(erc721Contract, erc721TokenId) @@ -143,14 +226,68 @@ export function TipDialog({ open = false, onClose }: TipDialogProps) { }, [sendState.type]) const handleConfirm = useCallback(() => { - openWindow(shareLink) + activatedSocialNetworkUI.utils.share?.(shareText) openConfirmModal(false) onClose?.() - }, [shareLink, onClose]) + }, [shareText, onClose]) + const networkDescriptor = useNetworkDescriptor() + const providerDescriptor = useProviderDescriptor() + + const { Utils } = useWeb3State() + const account = useAccount() + const { value: domain } = useReverseAddress(account) + const walletTitle = + Utils?.formatDomainName?.(domain) || Utils?.formatAddress?.(account, 4) || providerDescriptor?.name + + // #region change provider + const { openDialog: openSelectProviderDialog } = useRemoteControlledDialog( + WalletMessages.events.selectProviderDialogUpdated, + ) + // #endregion + const openWallet = useCallback(() => { + if (hasNativeAPI) return nativeAPI?.api.misc_openCreateWalletView() + return openSelectProviderDialog() + }, [openSelectProviderDialog, hasNativeAPI]) + + const handleAddToken = useCallback((token: ERC721TokenDetailed) => { + setErc721Address(token.contractDetailed.address ?? '') + setErc721TokenId(token.tokenId) + openAddTokenDialog(false) + }, []) + + const walletChip = account ? ( +
+ +
+ + {walletTitle} + + + + {Utils?.formatAddress?.(account, 4)} + + + + +
+
+ +
+
+ ) : null return ( <> - +
- + openAddTokenDialog(true)} />
openConfirmModal(false)} + onClose={() => { + openConfirmModal(false) + reset() + onClose?.() + }} icon={isTokenTip ? : null} message={successMessage} confirmText={t.tip_share()} onConfirm={handleConfirm} /> + openAddTokenDialog(false)} onAdd={handleAddToken} /> ) } diff --git a/packages/mask/src/plugins/NextID/components/Tip/TipForm.tsx b/packages/mask/src/plugins/NextID/components/Tip/TipForm.tsx index b8c731e49afd..d0f947d54542 100644 --- a/packages/mask/src/plugins/NextID/components/Tip/TipForm.tsx +++ b/packages/mask/src/plugins/NextID/components/Tip/TipForm.tsx @@ -1,17 +1,29 @@ import { useWeb3State } from '@masknet/plugin-infra' import { WalletMessages } from '@masknet/plugin-wallet' -import { usePickToken } from '@masknet/shared' import { useRemoteControlledDialog } from '@masknet/shared-base-ui' import { makeStyles } from '@masknet/theme' -import { EthereumTokenType, useAccount, useFungibleTokenBalance } from '@masknet/web3-shared-evm' -import { Box, BoxProps, FormControl, MenuItem, Select, Typography } from '@mui/material' +import { ChainId, useAccount, useChainId } from '@masknet/web3-shared-evm' +import { + Box, + BoxProps, + Button, + FormControl, + FormControlLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Typography, +} from '@mui/material' import classnames from 'classnames' -import { FC, memo, useCallback, useRef } from 'react' +import { FC, memo, useRef, useState } from 'react' import ActionButton from '../../../../extension/options-page/DashboardComponents/ActionButton' import { EthereumChainBoundary } from '../../../../web3/UI/EthereumChainBoundary' -import { TokenAmountPanel } from '../../../../web3/UI/TokenAmountPanel' import { TargetChainIdContext, useTip, useTipValidate } from '../../contexts' import { useI18N } from '../../locales' +import { TipType } from '../../types' +import { NFTSection } from './NFTSection' +import { TokenSection } from './TokenSection' const useStyles = makeStyles()((theme) => { return { @@ -25,6 +37,20 @@ const useStyles = makeStyles()((theme) => { flexGrow: 1, overflow: 'auto', }, + receiverRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + to: { + fontSize: 19, + fontWeight: 500, + }, + address: { + height: 48, + flexGrow: 1, + marginLeft: theme.spacing(1), + }, actionButton: { marginTop: theme.spacing(1.5), fontSize: 16, @@ -47,28 +73,37 @@ const useStyles = makeStyles()((theme) => { borderRadius: 24, height: 'auto', }, + controls: { + marginTop: theme.spacing(1), + display: 'flex', + flexDirection: 'row', + }, + addButton: { + marginLeft: 'auto', + }, tokenField: { marginTop: theme.spacing(2), }, } }) -interface Props extends BoxProps {} +interface Props extends BoxProps { + onAddToken?(): void +} -export const TipForm: FC = memo(({ className, ...rest }) => { +export const TipForm: FC = memo(({ className, onAddToken, ...rest }) => { const t = useI18N() + const currentChainId = useChainId() const { targetChainId: chainId } = TargetChainIdContext.useContainer() const { classes } = useStyles() const { recipient, recipients: recipientAddresses, setRecipient, - token, - setToken, - amount, - setAmount, isSending, sendTip, + tipType, + setTipType, } = useTip() const [isValid, validateMessage] = useTipValidate() const { Utils } = useWeb3State() @@ -77,35 +112,21 @@ export const TipForm: FC = memo(({ className, ...rest }) => { const { openDialog: openSelectProviderDialog } = useRemoteControlledDialog( WalletMessages.events.selectProviderDialogUpdated, ) - const pickToken = usePickToken() - const onSelectTokenChipClick = useCallback(async () => { - const picked = await pickToken({ - chainId, - disableNativeToken: false, - selectedTokens: token ? [token.address] : [], - }) - if (picked) { - setToken(picked) - } - }, [pickToken, token?.address, chainId]) - - // balance - const { value: tokenBalance = '0', loading: loadingTokenBalance } = useFungibleTokenBalance( - token?.type || EthereumTokenType.Native, - token?.address || '', - chainId, - ) - // #endregion + const [empty, setEmpty] = useState(false) const buttonLabel = isSending ? t.sending_tip() : isValid || !validateMessage ? t.send_tip() : validateMessage + const enabledNft = + !isSending && + chainId === currentChainId && + [ChainId.Mainnet, ChainId.BSC, ChainId.Matic].includes(currentChainId) return (
- {t.tip_to()} - - + + {t.tip_to()} - - + + setTipType(e.target.value as TipType)}> + } + label={t.tip_type_token()} + /> + } + label={t.tip_type_nft()} + /> + + {tipType === TipType.NFT && !empty ? ( + + ) : null} + {tipType === TipType.Token ? ( + + + + ) : ( + + )}
{account ? ( { + const { token, setToken, amount, setAmount, isSending } = useTip() + const { targetChainId: chainId } = TargetChainIdContext.useContainer() + // balance + const { value: tokenBalance = '0', loading: loadingTokenBalance } = useFungibleTokenBalance( + token?.type || EthereumTokenType.Native, + token?.address || '', + chainId, + ) + const gasConfig = useGasConfig(chainId) + const maxAmount = useMemo(() => { + if (!isNativeTokenAddress(token?.address)) return tokenBalance + const gasPrice = gasConfig.gasPrice ?? '1' + const gasFee = new BigNumber(gasPrice).times(GAS_LIMIT) + return new BigNumber(tokenBalance).minus(gasFee).toFixed() + }, [token?.address, tokenBalance, gasConfig.gasPrice]) + const pickToken = usePickToken() + const onSelectTokenChipClick = useCallback(async () => { + const picked = await pickToken({ + chainId, + disableNativeToken: false, + selectedTokens: token ? [token.address] : [], + }) + if (picked) { + setToken(picked) + } + }, [pickToken, token?.address, chainId]) + // #endregion + return ( + + ) +} diff --git a/packages/mask/src/plugins/NextID/contexts/TargetChainIdContext.ts b/packages/mask/src/plugins/NextID/contexts/TargetChainIdContext.ts index 17df3f66514f..ad2d904a03a7 100644 --- a/packages/mask/src/plugins/NextID/contexts/TargetChainIdContext.ts +++ b/packages/mask/src/plugins/NextID/contexts/TargetChainIdContext.ts @@ -1,11 +1,15 @@ import { ChainId, useChainId } from '@masknet/web3-shared-evm' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { createContainer } from 'unstated-next' function useTargetChainId() { const chainId = useChainId() const [targetChainId, setTargetChainId] = useState(chainId) + useEffect(() => { + setTargetChainId(chainId) + }, [chainId]) + return { targetChainId, setTargetChainId, diff --git a/packages/mask/src/plugins/NextID/contexts/Tip/TipContext.ts b/packages/mask/src/plugins/NextID/contexts/Tip/TipContext.ts index 2082cac59765..8fa4be5af260 100644 --- a/packages/mask/src/plugins/NextID/contexts/Tip/TipContext.ts +++ b/packages/mask/src/plugins/NextID/contexts/Tip/TipContext.ts @@ -1,3 +1,4 @@ +import type { Web3Plugin } from '@masknet/plugin-infra' import { ERC721ContractDetailed, FungibleTokenDetailed, @@ -22,10 +23,13 @@ export interface ContextOptions { erc721TokenId: string | null setErc721TokenId: Dispatch> erc721Contract: ERC721ContractDetailed | null - setErc721Contract: Dispatch> + erc721Address: string + setErc721Address: Dispatch> sendTip: () => Promise isSending: boolean sendState: TransactionState + storedTokens: Web3Plugin.NonFungibleToken[] + reset: () => void } export const TipContext = createContext({ @@ -42,8 +46,11 @@ export const TipContext = createContext({ erc721TokenId: null, setErc721TokenId: noop, erc721Contract: null, - setErc721Contract: noop, + erc721Address: '', + setErc721Address: noop, sendTip: noop as () => Promise, isSending: false, sendState: { type: TransactionStateType.UNKNOWN }, + storedTokens: [], + reset: noop, }) diff --git a/packages/mask/src/plugins/NextID/contexts/Tip/TipTaskProvider.tsx b/packages/mask/src/plugins/NextID/contexts/Tip/TipTaskProvider.tsx index de39fd31f565..7b8722fa717e 100644 --- a/packages/mask/src/plugins/NextID/contexts/Tip/TipTaskProvider.tsx +++ b/packages/mask/src/plugins/NextID/contexts/Tip/TipTaskProvider.tsx @@ -1,5 +1,12 @@ -import { TransactionStateType, useNativeTokenDetailed } from '@masknet/web3-shared-evm' +import { + TransactionStateType, + useChainId, + useERC721ContractDetailed, + useNativeTokenDetailed, +} from '@masknet/web3-shared-evm' import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { useSubscription } from 'use-subscription' +import { getStorage } from '../../storage' import { TipTask, TipType } from '../../types' import { TargetChainIdContext } from '../TargetChainIdContext' import { ContextOptions, TipContext } from './TipContext' @@ -15,10 +22,14 @@ export const TipTaskProvider: FC = ({ children, task }) => { const [tipType, setTipType] = useState(TipType.Token) const [amount, setAmount] = useState('') const { targetChainId } = TargetChainIdContext.useContainer() - const [erc721Contract, setErc721Contract] = useState(null) + const chainId = useChainId() + const [erc721Address, setErc721Address] = useState('') const { value: nativeTokenDetailed = null } = useNativeTokenDetailed(targetChainId) const [token, setToken] = useState(nativeTokenDetailed) const [erc721TokenId, setErc721TokenId] = useState(null) + const storedTokens = useSubscription(getStorage().addedTokens.subscription) + + const { value: erc721Contract } = useERC721ContractDetailed(erc721Address) useEffect(() => { setTipType(TipType.Token) @@ -38,7 +49,7 @@ export const TipTaskProvider: FC = ({ children, task }) => { setToken(nativeTokenDetailed) }, [nativeTokenDetailed]) const tokenTipTuple = useTokenTip(recipient, token, amount) - const nftTipTuple = useNftTip(recipient, erc721TokenId, erc721Contract) + const nftTipTuple = useNftTip(recipient, erc721TokenId, erc721Address) const sendTipTuple = tipType === TipType.Token ? tokenTipTuple : nftTipTuple const sendState = sendTipTuple[0] @@ -51,6 +62,12 @@ export const TipTaskProvider: FC = ({ children, task }) => { TransactionStateType.RECEIPT, ].includes(sendState.type) + const reset = () => { + setAmount('') + setErc721TokenId(null) + setErc721Address('') + } + return { recipient, recipientSnsId: task.recipientSnsId || '', @@ -64,13 +81,17 @@ export const TipTaskProvider: FC = ({ children, task }) => { setAmount, erc721TokenId, setErc721TokenId, - erc721Contract, - setErc721Contract, + erc721Contract: erc721Contract || null, + erc721Address, + setErc721Address, sendTip, isSending, sendState, + storedTokens: storedTokens.filter((t) => t.contract?.chainId === chainId), + reset, } }, [ + chainId, recipient, task.recipientSnsId, task.addresses, @@ -78,9 +99,11 @@ export const TipTaskProvider: FC = ({ children, task }) => { amount, erc721TokenId, erc721Contract, + erc721Address, token, sendTip, sendState, + storedTokens, ]) return {children} } diff --git a/packages/mask/src/plugins/NextID/contexts/Tip/useNftTip.ts b/packages/mask/src/plugins/NextID/contexts/Tip/useNftTip.ts index 398d2cfbe769..da0a1a620572 100644 --- a/packages/mask/src/plugins/NextID/contexts/Tip/useNftTip.ts +++ b/packages/mask/src/plugins/NextID/contexts/Tip/useNftTip.ts @@ -1,21 +1,32 @@ -import { ERC721ContractDetailed, EthereumTokenType, useTokenTransferCallback } from '@masknet/web3-shared-evm' -import { useCallback } from 'react' +import { + EthereumTokenType, + useERC721ContractDetailed, + useERC721TokenDetailedCallback, + useTokenTransferCallback, +} from '@masknet/web3-shared-evm' +import { useCallback, useEffect } from 'react' +import { WalletRPC } from '../../../Wallet/messages' import type { TipTuple } from './type' -export function useNftTip( - recipient: string, - tokenId: string | null, - contract: ERC721ContractDetailed | null, -): TipTuple { - const [transferState, transferCallback] = useTokenTransferCallback( - EthereumTokenType.ERC721, - contract?.address || '', - ) +export function useNftTip(recipient: string, tokenId: string | null, contractAddress?: string): TipTuple { + const [transferState, transferCallback] = useTokenTransferCallback(EthereumTokenType.ERC721, contractAddress || '') + const { value: contractDetailed } = useERC721ContractDetailed(contractAddress) + const [, setTokenId, erc721TokenDetailedCallback] = useERC721TokenDetailedCallback(contractDetailed) + + useEffect(() => { + if (tokenId) { + setTokenId(tokenId) + } + }, [tokenId]) const sendTip = useCallback(async () => { - if (!tokenId) return + if (!tokenId || !contractAddress) return await transferCallback(tokenId, recipient) - }, [tokenId, recipient, transferCallback]) + const tokenDetailed = await erc721TokenDetailedCallback() + if (tokenDetailed) { + await WalletRPC.removeToken(tokenDetailed) + } + }, [tokenId, contractAddress, erc721TokenDetailedCallback, recipient, transferCallback]) return [transferState, sendTip] } diff --git a/packages/mask/src/plugins/NextID/contexts/Tip/useTipValidate.ts b/packages/mask/src/plugins/NextID/contexts/Tip/useTipValidate.ts index 741010377f9c..2eca80b4dc8d 100644 --- a/packages/mask/src/plugins/NextID/contexts/Tip/useTipValidate.ts +++ b/packages/mask/src/plugins/NextID/contexts/Tip/useTipValidate.ts @@ -8,7 +8,7 @@ import { TipContext } from './TipContext' type ValidationTuple = [isValid: boolean, message?: string] export function useTipValidate(): ValidationTuple { - const { tipType, amount, token, erc721TokenId, erc721Contract } = useContext(TipContext) + const { tipType, amount, token, erc721TokenId, erc721Address } = useContext(TipContext) const { value: balance = '0' } = useFungibleTokenBalance(token?.type || EthereumTokenType.Native, token?.address) const t = useI18N() @@ -18,10 +18,10 @@ export function useTipValidate(): ValidationTuple { if (isGreaterThan(rightShift(amount, token?.decimals), balance)) return [false, t.token_insufficient_balance()] } else { - if (!erc721TokenId || !erc721Contract) return [false] + if (!erc721TokenId || !erc721Address) return [false] } return [true] - }, [tipType, amount, token?.decimals, balance, erc721Contract, erc721TokenId, t]) + }, [tipType, amount, token?.decimals, balance, erc721TokenId, erc721Address, t]) return result } diff --git a/packages/mask/src/plugins/NextID/locales/en-US.json b/packages/mask/src/plugins/NextID/locales/en-US.json index a9b3f95a04e6..fda218ebf57a 100644 --- a/packages/mask/src/plugins/NextID/locales/en-US.json +++ b/packages/mask/src/plugins/NextID/locales/en-US.json @@ -53,8 +53,8 @@ "send_tip": "Send", "sending_tip": "Sending...", "nft_not_belong_to_you": "The collectible doesn't exist or belong to you.", - "send_tip_successfully": "Sent tip successfully", - "send_specific_tip_successfully": "Sent {{amount}} {{name}} tip successfully", + "send_tip_successfully": "Sent tip successfully.", + "send_specific_tip_successfully": "Sent {{amount}} {{name}} tip successfully.", "tip_share": "Share", "tip_connect_wallet_message": "Please connect to a wallet.", "tip_connect_wallet": "Connect Wallet", @@ -62,5 +62,13 @@ "tip_contracts": "Contracts", "tip_mask_promote": "Install https://mask.io/download-links to send your first tip.", "tip_token_share_post": "I just tipped {{amount}} {{symbol}} to @{{recipientSnsId}}'s wallet address {{recipient}}\n\n{{promote}}", - "tip_nft_share_post": "I just tipped a {{name}} to @{{recipientSnsId}}'s wallet address {{recipient}}\n\n{{promote}}" + "tip_nft_share_post": "I just tipped a {{name}} to @{{recipientSnsId}}'s wallet address {{recipient}}\n\n{{promote}}", + "tip_add": "Add", + "tip_adding": "Adding", + "tip_add_collectibles": "Add Collectibles", + "tip_add_collectibles_contract_address": "Input contract address", + "tip_add_collectibles_token_id": "Token ID", + "tip_add_collectibles_error": "The contract address is incorrect or the collectible does not belong to you.", + "tip_loading": "Loading", + "tip_empty_nft": "No any collectible is available to preview. Please add your collectible here." } diff --git a/packages/mask/src/plugins/NextID/storage/index.ts b/packages/mask/src/plugins/NextID/storage/index.ts new file mode 100644 index 000000000000..6c9312b13e59 --- /dev/null +++ b/packages/mask/src/plugins/NextID/storage/index.ts @@ -0,0 +1,38 @@ +import type { Web3Plugin } from '@masknet/plugin-infra' +import type { ScopedStorage } from '@masknet/shared-base' +import { isSameAddress } from '@masknet/web3-shared-evm' +import { remove } from 'lodash-unified' + +interface StorageValue { + addedTokens: Web3Plugin.NonFungibleToken[] +} + +export const storageDefaultValue = { + publicKey: null as null | string, + addedTokens: [], +} + +let storage: ScopedStorage = null! + +export function setupStorage(_: typeof storage) { + storage = _ +} + +export function getStorage() { + return storage.storage +} + +export function getTokens() { + return storage.storage.addedTokens.value +} + +export function storeToken(token: Web3Plugin.NonFungibleToken) { + const tokens = [token, ...getTokens()] + storage.storage.addedTokens.setValue(tokens) +} + +export function deleteToken(address: string, tokenId: string) { + const tokens = getTokens() + remove(tokens, (t) => t.tokenId === tokenId && isSameAddress(t.contract?.address, address)) + storage.storage.addedTokens.setValue(tokens) +} diff --git a/packages/mask/src/plugins/NextID/types/tip.ts b/packages/mask/src/plugins/NextID/types/tip.ts index 2e6cbde0e5cd..8fefaf25e33a 100644 --- a/packages/mask/src/plugins/NextID/types/tip.ts +++ b/packages/mask/src/plugins/NextID/types/tip.ts @@ -8,3 +8,5 @@ export interface TipTask { recipientSnsId?: string addresses: string[] } + +export type TipNFTKeyPair = [address: string, tokenId: string] diff --git a/packages/plugin-infra/src/web3-types.ts b/packages/plugin-infra/src/web3-types.ts index d851716caf51..5c1442031d6b 100644 --- a/packages/plugin-infra/src/web3-types.ts +++ b/packages/plugin-infra/src/web3-types.ts @@ -29,6 +29,18 @@ export type Color = | `#${string}${string}${string}` | `hsl(${number}, ${number}%, ${number}%)` +// Borrow from @masknet/web3-shared-evm +interface ERC721TokenInfo { + name?: string + description?: string + tokenURI?: string + mediaUrl?: string + imageURL?: string + owner?: string + // loading tokenURI + hasTokenDetailed?: boolean +} + export declare namespace Web3Plugin { /** * Plugin can declare what chain it supports to trigger side effects (e.g. create a new transaction). @@ -197,6 +209,7 @@ export declare namespace Web3Plugin { description?: string owner?: string metadata?: NonFungibleTokenMetadata + info?: ERC721TokenInfo contract?: NonFungibleContract } diff --git a/packages/shared/src/UI/components/AssetPlayer/index.tsx b/packages/shared/src/UI/components/AssetPlayer/index.tsx index 08ce050d74b1..ed4b076351a1 100644 --- a/packages/shared/src/UI/components/AssetPlayer/index.tsx +++ b/packages/shared/src/UI/components/AssetPlayer/index.tsx @@ -163,10 +163,7 @@ export const AssetPlayer = memo((props) => { setIframe() }} className={ - ![ - AssetPlayerState.NORMAL, - ...(props.showIframeFromInit ? [AssetPlayerState.INIT] : []), - ].includes(playerState) + ![AssetPlayerState.NORMAL, AssetPlayerState.INIT].includes(playerState) ? classes.hidden : classes.iframe } diff --git a/packages/shared/src/UI/components/ImageIcon/index.tsx b/packages/shared/src/UI/components/ImageIcon/index.tsx index 90110f420a1d..5a020afcee7b 100644 --- a/packages/shared/src/UI/components/ImageIcon/index.tsx +++ b/packages/shared/src/UI/components/ImageIcon/index.tsx @@ -8,7 +8,7 @@ const useStyles = makeStyles()((theme) => { export interface ImageIconProps extends withClasses<'icon'> { size?: number - icon?: URL + icon?: URL | string } export function ImageIcon(props: ImageIconProps) { diff --git a/packages/shared/src/UI/components/NFTCardStyledAssetPlayer/index.tsx b/packages/shared/src/UI/components/NFTCardStyledAssetPlayer/index.tsx index 2aeb6adc0757..ac2bea3ed059 100644 --- a/packages/shared/src/UI/components/NFTCardStyledAssetPlayer/index.tsx +++ b/packages/shared/src/UI/components/NFTCardStyledAssetPlayer/index.tsx @@ -13,9 +13,6 @@ const useStyles = makeStyles()((theme) => ({ width: 120, overflow: 'hidden', }, - loadingNftImg: { - marginTop: 20, - }, loadingPlaceholder: { height: 160, width: 120, @@ -45,6 +42,10 @@ interface Props extends withClasses<'loadingFailImage' | 'iframe' | 'wrapper' | setERC721TokenName?: (name: string) => void setSourceType?: (type: string) => void } + +const assetPlayerFallbackImageDark = new URL('./nft_token_fallback_dark.png', import.meta.url) +const assetPlayerFallbackImageLight = new URL('./nft_token_fallback.png', import.meta.url) + export function NFTCardStyledAssetPlayer(props: Props) { const { chainId = ChainId.Mainnet, @@ -67,9 +68,7 @@ export function NFTCardStyledAssetPlayer(props: Props) { const { value: isImageToken } = useImageChecker(url || tokenDetailed?.info.imageURL || tokenDetailed?.info.mediaUrl) const fallbackImageURL = - theme.palette.mode === 'dark' - ? new URL('./nft_token_fallback_dark.png', import.meta.url) - : new URL('./nft_token_fallback.png', import.meta.url) + theme.palette.mode === 'dark' ? assetPlayerFallbackImageDark : assetPlayerFallbackImageLight return isImageToken || isNative ? (
@@ -86,6 +85,7 @@ export function NFTCardStyledAssetPlayer(props: Props) {
) : ( 200) iframe at once. renderTimeout={renderOrder ? 20000 * Math.floor(renderOrder / 100) : undefined} fallbackImage={fallbackImage ?? fallbackImageURL} - loadingIcon={} + loadingIcon={} classes={{ iframe: classNames(classes.wrapper, classes.iframe), errorPlaceholder: classes.wrapper, diff --git a/packages/shared/src/contexts/components/InjectedDialog.tsx b/packages/shared/src/contexts/components/InjectedDialog.tsx index 9e55e3a89039..99355b9cac55 100644 --- a/packages/shared/src/contexts/components/InjectedDialog.tsx +++ b/packages/shared/src/contexts/components/InjectedDialog.tsx @@ -54,6 +54,7 @@ export interface InjectedDialogProps extends Omit> onClose?(): void title?: React.ReactChild + titleTail?: React.ReactChild | null disableBackdropClick?: boolean disableTitleBorder?: boolean titleBarIconStyle?: 'auto' | 'back' | 'close' @@ -78,8 +79,17 @@ export function InjectedDialog(props: InjectedDialogProps) { const t = useSharedI18N() const fullScreen = useMediaQuery(useTheme().breakpoints.down('xs')) const isDashboard = isDashboardPage() - const { children, open, disableBackdropClick, titleBarIconStyle, onClose, title, disableTitleBorder, ...rest } = - props + const { + children, + open, + disableBackdropClick, + titleBarIconStyle, + onClose, + title, + titleTail = null, + disableTitleBorder, + ...rest + } = props const actions = CopyElementWithNewProps(children, DialogActions, { root: dialogActions }) const content = CopyElementWithNewProps(children, DialogContent, { root: dialogContent }) const { extraProps, shouldReplaceExitWithBack, IncreaseStack } = useDialogStackActor(open) @@ -128,6 +138,7 @@ export function InjectedDialog(props: InjectedDialogProps) { {title} + {titleTail} ) : null} {/* There is a .MuiDialogTitle+.MuiDialogContent selector that provides paddingTop: 0 */} diff --git a/packages/web3-shared/evm/hooks/useERC721ContractDetailed.ts b/packages/web3-shared/evm/hooks/useERC721ContractDetailed.ts index 4ca404e8db20..c99f91b9e31b 100644 --- a/packages/web3-shared/evm/hooks/useERC721ContractDetailed.ts +++ b/packages/web3-shared/evm/hooks/useERC721ContractDetailed.ts @@ -5,39 +5,14 @@ import type { ChainId } from '../types' import { useChainId } from './useChainId' import { useERC721TokenContract } from '../contracts/useERC721TokenContract' import { createERC721ContractDetailed, safeNonPayableTransactionCall } from '../utils' -import { useOpenseaAPIConstants } from '../constants' +import { getOpenseaAPIConstants } from '../constants' export function useERC721ContractDetailed(address?: string) { const chainId = useChainId() - const { GET_CONTRACT_URL } = useOpenseaAPIConstants() const erc721TokenContract = useERC721TokenContract(address) return useAsyncRetry(async () => { if (!address || !EthereumAddress.isValid(address) || !erc721TokenContract) return - - const erc721ContractDetailedFromChain = await getERC721ContractDetailedFromChain( - address, - chainId, - erc721TokenContract, - ) - - if (!GET_CONTRACT_URL) return erc721ContractDetailedFromChain - - const contractDetailedFromOpensea = await getERC721ContractDetailedFromOpensea( - address, - chainId, - GET_CONTRACT_URL, - ) - - // We prefer to use `name` and `symbol` from chain rather than opensea since, - // these two data on opensea is sometimes incorrect. Meanwhile there's often - // a lack of `iconURL` from chain, which exists on opensea. - return contractDetailedFromOpensea - ? { - ...contractDetailedFromOpensea, - name: erc721ContractDetailedFromChain.name, - symbol: erc721ContractDetailedFromChain.symbol, - } - : erc721ContractDetailedFromChain + return getERC721ContractDetailed(erc721TokenContract, address, chainId) }, [address, chainId, erc721TokenContract]) } @@ -69,3 +44,21 @@ async function getERC721ContractDetailedFromOpensea(address: string, chainId: Ch } return null } + +export async function getERC721ContractDetailed(contract: ERC721, address: string, chainId: ChainId) { + const erc721ContractDetailedFromChain = await getERC721ContractDetailedFromChain(address, chainId, contract) + const { GET_CONTRACT_URL } = getOpenseaAPIConstants(chainId) + if (!GET_CONTRACT_URL) return erc721ContractDetailedFromChain + const contractDetailedFromOpensea = await getERC721ContractDetailedFromOpensea(address, chainId, GET_CONTRACT_URL) + + // We prefer to use `name` and `symbol` from chain rather than opensea since, + // these two data on opensea is sometimes incorrect. Meanwhile there's often + // a lack of `iconURL` from chain, which exists on opensea. + return contractDetailedFromOpensea + ? { + ...contractDetailedFromOpensea, + name: erc721ContractDetailedFromChain.name, + symbol: erc721ContractDetailedFromChain.symbol, + } + : erc721ContractDetailedFromChain +} diff --git a/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts b/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts index e120f5d5616a..6dd2eb3d033f 100644 --- a/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts +++ b/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts @@ -1,37 +1,25 @@ +import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' +import { first, noop } from 'lodash-unified' import { useAsyncRetry } from 'react-use' -import { useRef } from 'react' import urlcat from 'urlcat' -import { first, noop } from 'lodash-unified' -import type { ERC721ContractDetailed, ERC721TokenInfo, ERC721TokenDetailed } from '../types' +import { getOpenseaAPIConstants } from '../constants' import { useERC721TokenContract } from '../contracts/useERC721TokenContract' -import { safeNonPayableTransactionCall, createERC721Token, formatNFT_TokenId } from '../utils' -import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' -import { useOpenseaAPIConstants } from '../constants' +import type { ChainId, ERC721ContractDetailed, ERC721TokenInfo } from '../types' +import { createERC721Token, formatNFT_TokenId, safeNonPayableTransactionCall } from '../utils' +import { useChainId } from './useChainId' export function useERC721TokenDetailed( contractDetailed: ERC721ContractDetailed | null | undefined, tokenId: string | null | undefined, ) { - const { GET_SINGLE_ASSET_URL } = useOpenseaAPIConstants() const erc721TokenContract = useERC721TokenContract(contractDetailed?.address ?? '') - const tokenDetailedRef = useRef() + const chainId = useChainId() const asyncRetry = useAsyncRetry(async () => { if (!erc721TokenContract || !contractDetailed || !tokenId || !contractDetailed.address) return - tokenDetailedRef.current = GET_SINGLE_ASSET_URL - ? await getERC721TokenDetailedFromOpensea(contractDetailed, tokenId, GET_SINGLE_ASSET_URL) - : await getERC721TokenDetailedFromChain(contractDetailed, erc721TokenContract, tokenId) - const info = await getERC721TokenAssetFromChain(tokenDetailedRef.current?.info.tokenURI) - - if (info && tokenDetailedRef.current) - tokenDetailedRef.current.info = { - ...info, - ...tokenDetailedRef.current.info, - hasTokenDetailed: true, - name: info.name ?? tokenDetailedRef.current.info.name, - } - }, [tokenId, JSON.stringify(contractDetailed), erc721TokenContract, GET_SINGLE_ASSET_URL]) + return getERC721TokenDetailed(contractDetailed, erc721TokenContract, tokenId, chainId) + }, [tokenId, JSON.stringify(contractDetailed), erc721TokenContract]) - return { asyncRetry, tokenDetailed: tokenDetailedRef.current } + return { asyncRetry, tokenDetailed: asyncRetry.value } } export async function getERC721TokenDetailedFromOpensea( @@ -142,3 +130,25 @@ export async function getERC721TokenAssetFromChain(tokenURI?: string): Promise { + setTransferState({ + type: TransactionStateType.UNKNOWN, + }) if (!account || !recipient || !tokenId || !erc721Contract) { - setTransferState({ - type: TransactionStateType.UNKNOWN, - }) return } diff --git a/packages/web3-shared/evm/utils/formatter.ts b/packages/web3-shared/evm/utils/formatter.ts index 8a80d99b4564..1c907af9d583 100644 --- a/packages/web3-shared/evm/utils/formatter.ts +++ b/packages/web3-shared/evm/utils/formatter.ts @@ -59,7 +59,7 @@ export function formatEthereumAddress(address: string, size = 0) { } export function formatNFT_TokenId(tokenId: string, size = 0) { - if (tokenId.length < 9) return tokenId + if (tokenId.length < 9) return `#${tokenId}` return `#${tokenId.substr(0, 2 + size)}...${tokenId.substr(-size)}` }