diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json index 9ba7b8a6b726..b20241d34b4d 100644 --- a/packages/mask/shared-ui/locales/en-US.json +++ b/packages/mask/shared-ui/locales/en-US.json @@ -368,6 +368,14 @@ "plugin_red_packet_nft_account_name": "Wallet account", "plugin_red_packet_nft_attached_message": "Attached Message", "plugin_red_packet_nft_total_amount": "Total Amount", + "plugin_red_packet_nft_select_collection": "Choose your collection", + "plugin_red_packet_nft_max_shares": "The maximum number of NFTs to be sold in NFT lucky drop contract is {{amount}}.", + "plugin_red_packet_nft_max_shares_tip": "The collection lucky drop contract supports up to {{amount}} NFTs for sale.", + "plugin_red_packet_nft_shift_select_tip": "You can also use {{text}} to select multiple NFTs.", + "plugin_red_packet_nft_non_existed_tip": "Token ID does not exist or belong to you.", + "plugin_red_packet_nft_select_all_option": "ALL ({{total}} NFT)", + "plugin_red_packet_nft_select_partially_option": "Select partially", + "plugin_red_packet_nft_approve_all_tip": "Note: When selecting approve all, all NFTs in the contract will be authorized for sale by default, including the NFTs transfered later.", "plugin_red_packet_completed": "Completed", "plugin_red_packet_expired": "Expired", "plugin_red_packet_indivisible": "The minimum amount for each share is {{amount}} {{symbol}}", diff --git a/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketDialog.tsx b/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketDialog.tsx index 0866bcb8370b..6199da76200d 100644 --- a/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketDialog.tsx +++ b/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketDialog.tsx @@ -231,7 +231,7 @@ export default function RedPacketDialog(props: RedPacketDialogProps) { const tokenState = useState(RpTypeTabs.ERC20) - const dialogContentHeight = state[0] === DialogTabs.past ? 600 : tokenState[0] === RpTypeTabs.ERC20 ? 350 : 540 + const dialogContentHeight = state[0] === DialogTabs.past ? 600 : tokenState[0] === RpTypeTabs.ERC20 ? 350 : 640 const tabProps: AbstractTabProps = { tabs: [ diff --git a/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketERC721Form.tsx b/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketERC721Form.tsx index c72a4213107c..841ccc93fa19 100644 --- a/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketERC721Form.tsx +++ b/packages/mask/src/plugins/RedPacket/SNSAdaptor/RedPacketERC721Form.tsx @@ -1,4 +1,4 @@ -import { Box, Typography, List, ListItem } from '@mui/material' +import { Box, Typography, List, ListItem, CircularProgress } from '@mui/material' import { makeStyles } from '@masknet/theme' import { useState, useCallback, useEffect, useMemo } from 'react' import { useI18N } from '../../../utils' @@ -15,12 +15,15 @@ import { useChainId, useNftRedPacketConstants, } from '@masknet/web3-shared-evm' +import CheckIcon from '@mui/icons-material/Check' import CloseIcon from '@mui/icons-material/Close' import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' import { RedpacketMessagePanel } from './RedpacketMessagePanel' -import { SelectNftTokenDialog } from './SelectNftTokenDialog' +import { SelectNftTokenDialog, OrderedERC721Token } from './SelectNftTokenDialog' import { RedpacketNftConfirmDialog } from './RedpacketNftConfirmDialog' import { NftImage } from './NftImage' +import { NFTSelectOption } from '../types' +import { NFT_RED_PACKET_MAX_SHARES } from '../constants' const useStyles = makeStyles()((theme) => { return { @@ -135,6 +138,51 @@ const useStyles = makeStyles()((theme) => { width: 64, height: 64, }, + selectWrapper: { + display: 'flex', + alignItems: 'center', + margin: '16px 0 8px 0', + }, + option: { + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }, + optionLeft: { + marginRight: '16px', + }, + checkIcon: { + width: 15, + height: 15, + color: '#fff', + }, + checkIconWrapper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + cursor: 'pointer', + width: 17, + height: 17, + borderRadius: 999, + marginRight: 5, + border: '1px solid #6E748E', + backgroundColor: 'white', + }, + checked: { + borderColor: '#1D9BF0 !important', + background: '#1D9BF0 !important', + }, + approveAllTip: { + color: '#FF5F5F', + margin: '0px 4px 24px 4px', + }, + disabledSelector: { + opacity: 0.5, + pointerEvents: 'none', + }, + loadingOwnerList: { + margin: '24px auto 16px', + }, } }) interface RedPacketERC721FormProps { @@ -146,22 +194,25 @@ export function RedPacketERC721Form(props: RedPacketERC721FormProps) { const { classes } = useStyles() const [open, setOpen] = useState(false) const [balance, setBalance] = useState(0) + const [selectOption, setSelectOption] = useState(undefined) const [openConfirmDialog, setOpenConfirmDialog] = useState(false) const account = useAccount() const chainId = useChainId() const [contract, setContract] = useState() - const [existTokenDetailedList, setExistTokenDetailedList] = useState([]) + const [existTokenDetailedList, setExistTokenDetailedList] = useState([]) const [message, setMessage] = useState('Best Wishes!') const { asyncRetry: { loading: loadingOwnerList }, - tokenDetailedOwnerList = [], + tokenDetailedOwnerList: _tokenDetailedOwnerList = [], clearTokenDetailedOwnerList, } = useERC721TokenDetailedOwnerList(contract, account) - + const tokenDetailedOwnerList = _tokenDetailedOwnerList.map((v, index) => ({ ...v, index } as OrderedERC721Token)) const removeToken = useCallback((token: ERC721TokenDetailed) => { setExistTokenDetailedList((list) => list.filter((t) => t.tokenId !== token.tokenId)) }, []) + const maxSelectShares = Math.min(NFT_RED_PACKET_MAX_SHARES, tokenDetailedOwnerList.length) + const clearToken = useCallback(() => { setExistTokenDetailedList([]) clearTokenDetailedOwnerList() @@ -172,6 +223,14 @@ export function RedPacketERC721Form(props: RedPacketERC721FormProps) { setContract(undefined) }, []) + useEffect(() => { + if (loadingOwnerList) { + setSelectOption(undefined) + } else if (!selectOption) { + setSelectOption(NFTSelectOption.Partial) + } + }, [tokenDetailedOwnerList, selectOption, loadingOwnerList]) + useEffect(() => { clearToken() setOpen(false) @@ -199,6 +258,56 @@ export function RedPacketERC721Form(props: RedPacketERC721FormProps) { onBalanceChange={setBalance} /> {contract && balance ? ( + loadingOwnerList ? ( + + ) : ( + +
{ + setSelectOption(NFTSelectOption.All) + setExistTokenDetailedList(tokenDetailedOwnerList.slice(0, maxSelectShares)) + }}> +
+ +
+ + {tokenDetailedOwnerList.length === 0 + ? 'All' + : t('plugin_red_packet_nft_select_all_option', { + total: Math.min(NFT_RED_PACKET_MAX_SHARES, tokenDetailedOwnerList.length), + })} + +
+
{ + setSelectOption(NFTSelectOption.Partial) + setExistTokenDetailedList([]) + }}> +
+ +
+ + {t('plugin_red_packet_nft_select_partially_option')} + +
+
+ ) + ) : null} + {contract && balance && selectOption === NFTSelectOption.Partial && !loadingOwnerList ? (
{existTokenDetailedList.map((value, i) => ( @@ -233,6 +342,11 @@ export function RedPacketERC721Form(props: RedPacketERC721FormProps) {
setMessage(val)} message={message} />
+ {contract && balance && !loadingOwnerList ? ( + + {t('plugin_red_packet_nft_approve_all_tip')} + + ) : null} { + retryAvailability() + resetCallback() + }, [account]) const previewNftImg = new URL('./assets/nft-preview.png', import.meta.url).toString() const rpNftImg = new URL('./assets/redpacket.nft.png', import.meta.url).toString() diff --git a/packages/mask/src/plugins/RedPacket/SNSAdaptor/SelectNftTokenDialog.tsx b/packages/mask/src/plugins/RedPacket/SNSAdaptor/SelectNftTokenDialog.tsx index 593a1897e9b4..6fd555cf206e 100644 --- a/packages/mask/src/plugins/RedPacket/SNSAdaptor/SelectNftTokenDialog.tsx +++ b/packages/mask/src/plugins/RedPacket/SNSAdaptor/SelectNftTokenDialog.tsx @@ -7,21 +7,29 @@ import { useAccount, isSameAddress, } from '@masknet/web3-shared-evm' -import { useI18N } from '../../../utils' +import { useI18N, ShadowRootTooltip } from '../../../utils' import { DialogContent, Box, InputBase, Paper, Button, Typography, ListItem, CircularProgress } from '@mui/material' +import QuestionMarkIcon from '@mui/icons-material/QuestionMark' import { makeStyles } from '@masknet/theme' import { useCallback, useState, useEffect } from 'react' import { SearchIcon } from '@masknet/icons' import CheckIcon from '@mui/icons-material/Check' +import { Trans } from 'react-i18next' import { useUpdate } from 'react-use' import { NftImage } from './NftImage' +import { findLastIndex, uniqBy } from 'lodash-unified' +import { NFT_RED_PACKET_MAX_SHARES } from '../constants' -const useStyles = makeStyles()((theme) => ({ +interface StyleProps { + isSelectSharesExceed: boolean +} + +const useStyles = makeStyles()((theme, props) => ({ dialogContent: { minHeight: 380, }, dialogContentFixedHeight: { - height: 550, + height: 600, }, tokenBox: { background: theme.palette.mode === 'light' ? '#F7F9FA' : '#17191D', @@ -34,9 +42,9 @@ const useStyles = makeStyles()((theme) => ({ ownerTokenBox: { background: theme.palette.mode === 'light' ? '#F7F9FA' : '#17191D', width: '96%', - height: 450, + height: 480, borderRadius: 12, - margin: '16px auto', + margin: '14px auto', padding: 10, }, ownerList: { @@ -119,6 +127,8 @@ const useStyles = makeStyles()((theme) => ({ }, confirmButton: { width: '100%', + backgroundColor: theme.palette.mode === 'dark' ? '#fff' : '#000', + color: theme.palette.mode === 'dark' ? '#000' : '#fff', }, tokenSelector: { display: 'grid', @@ -141,8 +151,12 @@ const useStyles = makeStyles()((theme) => ({ flexDirection: 'column', borderRadius: 6, height: 180, + userSelect: 'none', width: 120, }, + hide: { + display: 'none !important', + }, loadingWrapper: { display: 'flex', justifyContent: 'center', @@ -178,7 +192,7 @@ const useStyles = makeStyles()((theme) => ({ backgroundColor: 'white', }, checked: { - borderColor: '#1C68F3', + borderColor: '#1C68F3 !important', }, checkIcon: { width: 15, @@ -192,20 +206,101 @@ const useStyles = makeStyles()((theme) => ({ width: 64, height: 64, }, + selectedTokenAmount: { + color: props.isSelectSharesExceed ? '#FF5F5F' : '#1C68F3', + }, + totalAmount: { + paddingLeft: 1, + }, + selectAmountBox: { + display: 'flex', + flexDirection: 'row-reverse', + alignItems: 'center', + }, + questionMarkIcon: { + padding: 2, + width: 12, + border: `1px solid ${theme.palette.mode === 'light' ? '#0F1419' : '#D9D9D9'}`, + borderRadius: 999, + transform: 'translateY(-1px)', + height: 12, + marginLeft: 5, + cursor: 'pointer', + }, + selectBar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 12, + padding: '0 8px', + }, + selectAll: { + display: 'flex', + alignItems: 'center', + fontSize: 14, + }, + selectAllCheckBox: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + cursor: 'pointer', + width: 17, + height: 17, + borderRadius: 6, + marginRight: 5, + border: '2px solid #6E748E', + backgroundColor: 'white', + }, + selectAllCheckBoxText: { + pointerEvents: 'none', + }, + selectSharesExceed: { + color: '#FF5F5F', + }, + selectSharesExceedBox: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + margin: '14px 4px', + }, + arrow: { + color: theme.palette.mode === 'dark' ? '#fff' : '#111418', + transform: 'translateX(260px) !important', + }, + tooltip: { + transform: 'translateX(20px) !important', + padding: '10px 20px', + width: 256, + backgroundColor: theme.palette.mode === 'dark' ? '#fff' : '#111418', + }, + tooltipText: { + color: theme.palette.mode === 'dark' ? '#000' : '#fff', + }, + tokenId: { + color: '#1D9BF0', + }, + nonExistedTokenBox: { + height: 388, + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, })) +export type OrderedERC721Token = ERC721TokenDetailed & { index: number } + export interface SelectNftTokenDialogProps extends withClasses { open: boolean loadingOwnerList: boolean onClose: () => void contract: ERC721ContractDetailed | undefined - existTokenDetailedList: ERC721TokenDetailed[] - tokenDetailedOwnerList: ERC721TokenDetailed[] - setExistTokenDetailedList: React.Dispatch> + existTokenDetailedList: OrderedERC721Token[] + tokenDetailedOwnerList: OrderedERC721Token[] + setExistTokenDetailedList: React.Dispatch> } export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { - const { classes } = useStyles() const { open, contract, @@ -217,12 +312,37 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { } = props const { t } = useI18N() const account = useAccount() - const [tokenDetailed, setTokenDetailed] = useState() + const [tokenDetailed, setTokenDetailed] = useState() const [searched, setSearched] = useState(false) const [tokenDetailedSelectedList, setTokenDetailedSelectedList] = - useState(existTokenDetailedList) + useState(existTokenDetailedList) const [loadingToken, setLoadingToken] = useState(false) const [tokenId, setTokenId, erc721TokenDetailedCallback] = useERC721TokenDetailedCallback(contract) + const [tokenIdListInput, setTokenIdListInput] = useState('') + const [tokenIdFilterList, setTokenIdFilterList] = useState([]) + const [nonExistedTokenIdList, setNonExistedTokenIdList] = useState([]) + const maxSelectShares = Math.min(NFT_RED_PACKET_MAX_SHARES, tokenDetailedOwnerList.length) + const isSelectSharesExceed = + (tokenDetailedOwnerList.length === 0 ? NFT_RED_PACKET_MAX_SHARES - 1 : NFT_RED_PACKET_MAX_SHARES) < + tokenDetailedSelectedList.length + const { classes } = useStyles({ isSelectSharesExceed }) + const selectAll = maxSelectShares === tokenDetailedSelectedList.length + const selectAllHandler = useCallback(() => { + setTokenDetailedSelectedList( + tokenIdFilterList.length === 0 + ? [] + : selectAll + ? tokenDetailedSelectedList.filter((t) => !tokenIdFilterList.includes(t.tokenId)) + : tokenDetailedSelectedList, + ) + + if (!selectAll) { + const newlyAdded = tokenDetailedOwnerList + .filter((t) => tokenIdFilterList.length === 0 || tokenIdFilterList.includes(t.tokenId)) + .slice(0, maxSelectShares) + setTokenDetailedSelectedList(uniqBy(newlyAdded.concat(tokenDetailedSelectedList), 'tokenId')) + } + }, [selectAll, maxSelectShares, tokenIdFilterList, tokenDetailedSelectedList]) useEffect(() => { setTokenDetailed(undefined) @@ -231,26 +351,61 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { setSearched(false) }, [contract]) - const update = useUpdate() useEffect(() => { - update() - }, [tokenDetailedOwnerList]) + setTokenIdFilterList([]) + setNonExistedTokenIdList([]) + }, [tokenIdListInput]) + + const update = useUpdate() + useEffect(update, [tokenDetailedOwnerList]) const selectToken = useCallback( - (token: ERC721TokenDetailed, findToken: ERC721TokenDetailed | undefined) => { - if (findToken) { - setTokenDetailedSelectedList(tokenDetailedSelectedList.filter((v) => v.tokenId !== findToken.tokenId)) + (token: OrderedERC721Token, findToken: OrderedERC721Token | undefined, shiftKey: boolean, index: number) => { + if (!shiftKey || tokenIdFilterList.length > 0) { + if (findToken) { + setTokenDetailedSelectedList( + tokenDetailedSelectedList.filter((v) => v.tokenId !== findToken.tokenId), + ) + } else { + setTokenDetailedSelectedList(tokenDetailedSelectedList.concat({ ...token, index })) + } } else { - setTokenDetailedSelectedList(tokenDetailedSelectedList.concat(token)) + const tokenDetailedSelectedListSorted = tokenDetailedSelectedList.sort( + (a, b) => (b.index ?? 0) - (a.index ?? 0), + ) + if (findToken) { + const unselectedTokenIdList: string[] = [] + let nextToken: OrderedERC721Token | undefined = findToken + while (nextToken) { + unselectedTokenIdList.push(nextToken.tokenId) + const nextTokenIndex: number = (nextToken?.index ?? 0) + 1 + nextToken = tokenDetailedSelectedListSorted.find((v) => (v.index ?? 0) === nextTokenIndex) + } + setTokenDetailedSelectedList( + tokenDetailedSelectedList.filter((v) => !unselectedTokenIdList.includes(v.tokenId)), + ) + } else { + const lastSelectedToken = tokenDetailedSelectedListSorted.filter((v) => (v?.index ?? 0) < index)[0] + + const lastSelectedTokenIndex = findLastIndex( + tokenDetailedOwnerList, + (v, i) => v.tokenId === lastSelectedToken?.tokenId && i < index, + ) + setTokenDetailedSelectedList( + tokenDetailedSelectedList.concat( + tokenDetailedOwnerList.slice(lastSelectedTokenIndex + 1, index + 1), + ), + ) + } } }, - [tokenDetailedSelectedList, setTokenDetailedSelectedList], + [tokenDetailedSelectedList, setTokenDetailedSelectedList, tokenIdFilterList], ) //#region fetch token detail const onSearch = useCallback(async () => { setLoadingToken(true) const _tokenDetailed = await erc721TokenDetailedCallback() - setTokenDetailed(_tokenDetailed?.info.owner ? _tokenDetailed : undefined) + setTokenDetailed(_tokenDetailed?.info.owner ? { ..._tokenDetailed, index: 0 } : undefined) setSearched(true) setLoadingToken(false) }, [erc721TokenDetailedCallback]) @@ -260,10 +415,21 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { setSearched(false) }, [tokenId]) + useEffect(() => { + if (tokenDetailedOwnerList.length > 0) setTokenDetailed(undefined) + }, [tokenDetailedOwnerList.length]) + const isOwner = isSameAddress(account, tokenDetailed?.info.owner) || tokenDetailedSelectedList.length > 0 const isAdded = existTokenDetailedList.map((t) => t.tokenId).includes(tokenDetailed?.tokenId ?? '') //#endregion + const onFilter = useCallback(() => { + if (!/^(\s?(\d+)?\s?,?)+$/.test(tokenIdListInput)) return + const list = tokenIdListInput.split(',').map((v) => Number(v).toString()) + setTokenIdFilterList(list) + setNonExistedTokenIdList(list.filter((v) => !tokenDetailedOwnerList.map((t) => t.tokenId).includes(v))) + }, [tokenIdListInput]) + const onSubmit = useCallback(() => { setExistTokenDetailedList( tokenDetailed ? [tokenDetailed, ...existTokenDetailedList] : tokenDetailedSelectedList, @@ -271,8 +437,30 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { onClose() }, [tokenDetailed, tokenDetailedSelectedList, setExistTokenDetailedList, onClose]) + const NonExistedTokenList = () => ( + + {nonExistedTokenIdList.map((id, i) => ( + + {' #' + id} + {i < nonExistedTokenIdList.length - 1 ? ', ' : ' '} + + ))} + + ), + }} + /> + ) + return ( - + {tokenDetailedOwnerList.length === 0 ? ( @@ -306,17 +494,49 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) { ) : ( - -
- - {tokenDetailed.info.name} + +
+ + {tokenDetailed?.info.name}
)} +
+ + {isSelectSharesExceed + ? t('plugin_red_packet_nft_max_shares_tip', { amount: NFT_RED_PACKET_MAX_SHARES }) + : null} + + + + {t('plugin_red_packet_nft_max_shares', { + amount: NFT_RED_PACKET_MAX_SHARES, + })} + + } + placement="top-end" + classes={{ tooltip: classes.tooltip, arrow: classes.arrow }} + arrow> + + + + {tokenDetailedSelectedList.length}{' '} + NFTs + + +
@@ -364,53 +588,144 @@ export function SelectNftTokenDialog(props: SelectNftTokenDialogProps) {
) : ( -
- {tokenDetailedOwnerList.map((token, i) => { - const findToken = tokenDetailedSelectedList.find((t) => t.tokenId === token.tokenId) - - return ( - - -
- - {token?.info.name} - -
+ <> + {tokenIdFilterList.length === 0 ? ( +
+
selectToken(token, findToken)}> - {findToken ? : null} + onClick={selectAllHandler}> + {selectAll ? : null}
- - ) - })} - {loadingOwnerList ? ( - - - + + {t('select_all')} + +
+ + , + }} + values={{ + text: 'Shift', + }} + /> + +
) : null} -
+ {nonExistedTokenIdList.length > 0 && + nonExistedTokenIdList.length === tokenIdFilterList.length ? ( +
+ + + +
+ ) : ( +
+ {tokenDetailedOwnerList.map((token, i) => { + const findToken = tokenDetailedSelectedList.find( + (t) => t.tokenId === token.tokenId, + ) + + return ( + 0 && + !tokenIdFilterList.includes(token.tokenId) + ? classes.hide + : '', + )} + key={i.toString()}> + +
+ + {token?.info.name} + +
+ +
+ selectToken(token, findToken, event.shiftKey, token.index) + }> + {findToken ? : null} +
+
+ ) + })} + {loadingOwnerList ? ( + + + + ) : null} +
+ )} + )} +
+
+ + {nonExistedTokenIdList.length > 0 && + nonExistedTokenIdList.length !== tokenIdFilterList.length ? ( + + ) : null} + + + {isSelectSharesExceed + ? t('plugin_red_packet_nft_max_shares_tip', { amount: NFT_RED_PACKET_MAX_SHARES }) + : null} + +
+ + + + {t('plugin_red_packet_nft_max_shares', { + amount: NFT_RED_PACKET_MAX_SHARES, + })} + + } + placement="top-end" + classes={{ tooltip: classes.tooltip, arrow: classes.arrow }} + arrow> + + + + + {tokenDetailedSelectedList.length + ' '} + + /{tokenDetailedOwnerList.length} + + +