diff --git a/cspell.json b/cspell.json index aa4c82a87783..5ff39800d228 100644 --- a/cspell.json +++ b/cspell.json @@ -288,6 +288,7 @@ "cryptoartai", "cusd", "defi", + "deletable", "devnet", "drawed", "energi", @@ -364,6 +365,7 @@ "switcher", "testweb", "tinycolor", + "tipable", "treeshake", "txreceipt", "txts", diff --git a/packages/icons/plugins/Currency.tsx b/packages/icons/plugins/Currency.tsx index 2538c608d41d..4dbdbc3794a4 100644 --- a/packages/icons/plugins/Currency.tsx +++ b/packages/icons/plugins/Currency.tsx @@ -5,31 +5,39 @@ export const Currency: typeof SvgIcon = createIcon( 'Currency', - + + - , '0 0 25 25', ) diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json index 3475ad2577c6..a1f8a5ea7b17 100644 --- a/packages/mask/shared-ui/locales/en-US.json +++ b/packages/mask/shared-ui/locales/en-US.json @@ -491,10 +491,6 @@ "plugin_trader_no_enough_liquidity": "No enough liquidity", "plugin_trader_no_trade": "Please select a trade", "plugin_trader_gas": "GAS", - "plugin_tip_tip": "Tip", - "plugin_tip_send": "Send", - "plugin_tip_token": "Token", - "plugin_tip_nft": "NFT", "plugin_poll_display_name": "Plugin: Poll", "plugin_poll_question_hint": "Ask a question…", "plugin_poll_options_hint": "choice", diff --git a/packages/mask/shared-ui/locales/qya-AA.json b/packages/mask/shared-ui/locales/qya-AA.json index 57a053e36b48..7263cf4140a8 100644 --- a/packages/mask/shared-ui/locales/qya-AA.json +++ b/packages/mask/shared-ui/locales/qya-AA.json @@ -491,10 +491,6 @@ "plugin_trader_no_enough_liquidity": "crwdns10481:0crwdne10481:0", "plugin_trader_no_trade": "crwdns10483:0crwdne10483:0", "plugin_trader_gas": "crwdns10485:0crwdne10485:0", - "plugin_tip_tip": "crwdns14546:0crwdne14546:0", - "plugin_tip_send": "crwdns14548:0crwdne14548:0", - "plugin_tip_token": "crwdns14550:0crwdne14550:0", - "plugin_tip_nft": "crwdns14552:0crwdne14552:0", "plugin_poll_display_name": "crwdns4941:0crwdne4941:0", "plugin_poll_question_hint": "crwdns4943:0crwdne4943:0", "plugin_poll_options_hint": "crwdns4945:0crwdne4945:0", diff --git a/packages/mask/shared-ui/locales/zh-CN.json b/packages/mask/shared-ui/locales/zh-CN.json index 0996fe9a0722..219f45ccba3e 100644 --- a/packages/mask/shared-ui/locales/zh-CN.json +++ b/packages/mask/shared-ui/locales/zh-CN.json @@ -479,9 +479,6 @@ "plugin_trader_gas_option": "{{option}} ({{value}}) Gwei", "plugin_trader_no_enough_liquidity": "流动性不足", "plugin_trader_no_trade": "请选择一个交易", - "plugin_tip_tip": "打赏", - "plugin_tip_send": "发送", - "plugin_tip_token": "代币", "plugin_poll_display_name": "插件:投票", "plugin_poll_question_hint": "询问...", "plugin_poll_options_hint": "选项", diff --git a/packages/mask/shared/flags.ts b/packages/mask/shared/flags.ts index 3a02f63f634e..3c6c724e60aa 100644 --- a/packages/mask/shared/flags.ts +++ b/packages/mask/shared/flags.ts @@ -58,8 +58,8 @@ export const Flags = { celo_enabled: true, aurora_enabled: true, nft_airdrop_enabled: false, - post_actions_enabled: false, - next_id_tip_enabled: false, + post_actions_enabled: true, + next_id_tip_enabled: true, // #region Functionality missing / broken /** diff --git a/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx b/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx index aee858243f8e..8274f326b7e7 100644 --- a/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/NextID/SNSAdaptor/index.tsx @@ -3,7 +3,7 @@ import { base } from '../base' import { PLUGIN_ID } from '../constants' import { NextIdPage } from '../components/NextIdPage' import { RootContext } from '../contexts' -import { TipButton, TipTaskManager } from '../components/tip' +import { PostTipButton, TipTaskManager } from '../components/tip' import { Flags } from '../../../../shared' const sns: Plugin.SNSAdaptor.Definition = { @@ -31,7 +31,7 @@ const sns: Plugin.SNSAdaptor.Definition = { if (!Flags.next_id_tip_enabled) return null return ( - + ) }, diff --git a/packages/mask/src/plugins/NextID/components/BindingItem.tsx b/packages/mask/src/plugins/NextID/components/BindingItem.tsx index 225546f430d7..08aaea073333 100644 --- a/packages/mask/src/plugins/NextID/components/BindingItem.tsx +++ b/packages/mask/src/plugins/NextID/components/BindingItem.tsx @@ -9,6 +9,8 @@ import { ExternalLink } from 'react-feather' import { NetworkPluginID, useNetworkDescriptor, useWeb3State } from '@masknet/plugin-infra' import { useI18N } from '../locales' import { ImageIcon } from '@masknet/shared' +import { TipButton } from './tip' +import { useCurrentVisitingIdentity } from '../../../components/DataSource/useActivatedUI' const useStyles = makeStyles()((theme) => ({ item: { @@ -18,11 +20,6 @@ const useStyles = makeStyles()((theme) => ({ borderRadius: 8, alignItems: 'center', }, - trashIcon: { - fontSize: 20, - stroke: theme.palette.text.primary, - cursor: 'pointer', - }, copy: { fontSize: 16, stroke: theme.palette.text.primary, @@ -40,19 +37,36 @@ const useStyles = makeStyles()((theme) => ({ linkIcon: { marginRight: theme.spacing(1), }, + tipButton: { + display: 'inline-flex', + alignItems: 'center', + }, + tipButtonLabel: { + color: theme.palette.text.primary, + fontSize: 14, + marginLeft: theme.spacing(0.5), + }, + delButton: { + fontSize: 20, + stroke: theme.palette.text.primary, + cursor: 'pointer', + marginLeft: theme.spacing(1), + }, })) interface Item { platform: Platform identity: string - enableAction: boolean + tipable?: boolean + deletable?: boolean onUnBind(address: string): void } -export const BindingItem = memo(({ platform, identity, enableAction, onUnBind }) => { +export const BindingItem = memo(({ platform, identity, tipable, deletable, onUnBind }) => { const t = useI18N() const { Utils } = useWeb3State() ?? {} const { classes } = useStyles() const networkDescriptor = useNetworkDescriptor(ChainId.Mainnet, NetworkPluginID.PLUGIN_EVM) + const visitingPersona = useCurrentVisitingIdentity() if (platform === Platform.ethereum) { return ( @@ -77,7 +91,15 @@ export const BindingItem = memo(({ platform, identity, enableAction, onUnB - {enableAction && onUnBind(identity)} />} + {tipable ? ( + + {t.tip()} + + ) : null} + {deletable ? onUnBind(identity)} /> : null} ) diff --git a/packages/mask/src/plugins/NextID/components/ConfirmModal.tsx b/packages/mask/src/plugins/NextID/components/ConfirmModal.tsx new file mode 100644 index 000000000000..96708fc435bb --- /dev/null +++ b/packages/mask/src/plugins/NextID/components/ConfirmModal.tsx @@ -0,0 +1,61 @@ +import { makeStyles } from '@masknet/theme' +import { Button, DialogActions, DialogContent, Typography } from '@mui/material' +import type { FC, ReactNode } from 'react' +import { InjectedDialog, InjectedDialogProps } from '../../../components/shared/InjectedDialog' + +const useStyles = makeStyles()((theme) => ({ + confirmDialog: { + width: 480, + height: 290, + }, + content: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + justifyItems: 'center', + padding: theme.spacing(3), + boxSizing: 'border-box', + }, + icon: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + message: { + color: theme.palette.text.primary, + textAlign: 'center', + fontSize: 18, + }, + actions: { + marginTop: theme.spacing(3), + }, +})) + +interface Props extends InjectedDialogProps { + message: string | ReactNode + icon?: ReactNode + confirmText?: string + onConfirm?(): void +} + +export const ConfirmModal: FC = ({ className, message, icon, confirmText, onConfirm, ...rest }) => { + const { classes } = useStyles() + confirmText = confirmText || 'Confirm' + return ( + + + {icon ?
{icon}
: null} + {message} +
+ + + +
+ ) +} diff --git a/packages/mask/src/plugins/NextID/components/NextIdPage.tsx b/packages/mask/src/plugins/NextID/components/NextIdPage.tsx index dac61c8c38c8..eb4b797a675c 100644 --- a/packages/mask/src/plugins/NextID/components/NextIdPage.tsx +++ b/packages/mask/src/plugins/NextID/components/NextIdPage.tsx @@ -1,15 +1,15 @@ -import { useI18N } from '../locales' import { makeStyles } from '@masknet/theme' import { Box, Button, Skeleton, Stack, Typography } from '@mui/material' import { useMemo, useState } from 'react' -import { BindDialog } from './BindDialog' import { useAsync, useAsyncRetry, useCounter } from 'react-use' +import { useCurrentVisitingIdentity, useLastRecognizedIdentity } from '../../../components/DataSource/useActivatedUI' +import { usePersonaConnectStatus } from '../../../components/DataSource/usePersonaConnectStatus' import Services from '../../../extension/service' -import { BindingItem } from './BindingItem' +import { useI18N } from '../locales' import type { Platform } from '../types' +import { BindDialog } from './BindDialog' +import { BindingItem } from './BindingItem' import { UnbindDialog } from './UnbindDialog' -import { useCurrentVisitingIdentity, useLastRecognizedIdentity } from '../../../components/DataSource/useActivatedUI' -import { usePersonaConnectStatus } from '../../../components/DataSource/usePersonaConnectStatus' const useStyles = makeStyles()((theme) => ({ tip: { @@ -102,7 +102,8 @@ export function NextIdPage({}: NextIDPageProps) { {bindings.proofs.map((x) => ( void limit?: number className: string @@ -45,16 +47,17 @@ export function arrayRemove(arr: T[], item: T): T[] { return arr.splice(idx, 1) } -export const NFTList: FC = ({ selectedIds, onChange, limit = 1, tokens, className }) => { +export const NFTList: FC = ({ selectedIds, tokens, enableTokenIds = [], onChange, limit = 1, className }) => { const chainId = useChainId() const { classes } = useStyles() + const isRadio = limit === 1 const reachedLimit = selectedIds.length >= limit const toggleItem = useCallback( (currentId: string) => { - const disabled = !selectedIds.includes(currentId) && reachedLimit - if (!onChange || disabled) return + if (!onChange) return + if (isRadio) return onChange([currentId]) let newIds = [...selectedIds] if (selectedIds.includes(currentId)) { arrayRemove(newIds, currentId) @@ -63,37 +66,52 @@ export const NFTList: FC = ({ selectedIds, onChange, limit = 1, tokens, c } onChange(newIds) }, - [selectedIds, onChange, reachedLimit], + [selectedIds, onChange, isRadio, reachedLimit], ) + const SelectComponent = isRadio ? Radio : Checkbox + const t = useI18N() + return ( - {tokens.map((token) => ( - { - toggleItem(token.tokenId) - }}> - - { - toggleItem(token.tokenId) - }} - className={classes.checkbox} - checked={selectedIds.includes(token.tokenId)} - /> - - ))} + {tokens.map((token) => { + const isNotOwned = !enableTokenIds.includes(token.tokenId) + const disabled = (!isRadio && reachedLimit && !selectedIds.includes(token.tokenId)) || isNotOwned + return ( + + { + if (disabled) return + toggleItem(token.tokenId) + }}> + + { + if (disabled) return + toggleItem(token.tokenId) + }} + className={classes.checkbox} + checked={selectedIds.includes(token.tokenId)} + /> + + + ) + })} ) } 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 869bc04abc65..5e2f34a95471 100644 --- a/packages/mask/src/plugins/NextID/components/tip/NFTSection/index.tsx +++ b/packages/mask/src/plugins/NextID/components/tip/NFTSection/index.tsx @@ -14,11 +14,16 @@ const useStyles = makeStyles()((theme) => ({ root: { display: 'flex', flexDirection: 'column', + overflow: 'auto', }, selectSection: { marginTop: theme.spacing(1.5), + display: 'flex', + flexDirection: 'column', + overflow: 'auto', }, list: { + flexGrow: 1, display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', maxHeight: 400, @@ -26,11 +31,11 @@ const useStyles = makeStyles()((theme) => ({ gridGap: 18, }, keyword: { - borderRadius: 6, + borderRadius: 8, marginRight: theme.spacing(1.5), }, searchButton: { - borderRadius: 6, + borderRadius: 8, width: 100, }, row: { @@ -43,7 +48,7 @@ interface Props extends HTMLProps {} export const NFTSection: FC = ({ className, ...rest }) => { const { t } = useI18N() - const { erc721Contract, setErc721Contract, erc721TokenId, setErc721TokenId } = useTip() + const { erc721Contract, setErc721Contract, erc721TokenId, setErc721TokenId, isSending } = useTip() const [tokenId, setTokenId, erc721TokenDetailedCallback] = useERC721TokenDetailedCallback(erc721Contract) const { classes } = useStyles() const account = useAccount() @@ -58,6 +63,7 @@ export const NFTSection: FC = ({ className, ...rest }) => { }, [erc721TokenDetailedCallback]) const tokens = useMemo(() => (searchedToken ? [searchedToken] : myTokens), [searchedToken, myTokens]) + const enableTokenIds = useMemo(() => myTokens.map((t) => t.tokenId), [myTokens]) return (
@@ -71,9 +77,16 @@ export const NFTSection: FC = ({ className, ...rest }) => { classes={{ root: classes.keyword }} value={tokenId} onChange={(id) => setTokenId(id)} + inputBaseProps={{ + disabled: isSending, + }} label="" /> - @@ -81,6 +94,7 @@ export const NFTSection: FC = ({ className, ...rest }) => { className={classes.list} selectedIds={selectedIds} tokens={tokens} + enableTokenIds={enableTokenIds} onChange={(ids) => { setErc721TokenId(ids.length ? ids[0] : null) }} diff --git a/packages/mask/src/plugins/NextID/components/tip/TipButton.tsx b/packages/mask/src/plugins/NextID/components/tip/TipButton.tsx index b45e72f17998..c49db4065a9d 100644 --- a/packages/mask/src/plugins/NextID/components/tip/TipButton.tsx +++ b/packages/mask/src/plugins/NextID/components/tip/TipButton.tsx @@ -1,28 +1,92 @@ import { Currency } from '@masknet/icons' -import { useAccount } from '@masknet/web3-shared-evm' +import { usePostInfoDetails } from '@masknet/plugin-infra' +import type { ProfileIdentifier } from '@masknet/shared-base' +import { makeStyles } from '@masknet/theme' +import { EMPTY_LIST } from '@masknet/web3-shared-evm' import classnames from 'classnames' -import { FC, HTMLProps, MouseEventHandler, useCallback } from 'react' +import { uniq } from 'lodash-unified' +import { FC, HTMLProps, MouseEventHandler, useCallback, useMemo } from 'react' +import { useAsync, useAsyncFn } from 'react-use' +import Services from '../../../../extension/service' import { PluginNextIdMessages } from '../../messages' +import { Platform } from '../../types' -interface Props extends HTMLProps {} +interface Props extends HTMLProps { + addresses?: string[] + receiver?: ProfileIdentifier +} + +const useStyles = makeStyles()({ + tipButton: { + cursor: 'pointer', + }, + postTipButton: { + display: 'flex', + // temporarily hard code + height: 46, + alignItems: 'center', + }, + disabled: { + opacity: 0.4, + cursor: 'default', + }, +}) + +export const TipButton: FC = ({ className, receiver, addresses = [], children, ...rest }) => { + const { classes } = useStyles() + + const [walletsState, queryBindings] = useAsyncFn(async () => { + if (!receiver) return EMPTY_LIST + + const persona = await Services.Identity.queryPersonaByProfile(receiver) + if (!persona) return EMPTY_LIST + + const bindings = await Services.Helper.queryExistedBinding(persona.identifier) + if (!bindings) return EMPTY_LIST -export const TipButton: FC = ({ className, ...rest }) => { - // So far it is not possible to fetch wallets of user - const account = useAccount() + const wallets = bindings.proofs.filter((p) => p.platform === Platform.ethereum).map((p) => p.identity) + return wallets + }, [receiver]) + + useAsync(queryBindings, [queryBindings]) + + const allAddresses = useMemo(() => { + return uniq([...(walletsState.value || []), ...addresses]) + }, [walletsState.value, addresses]) + + const disabled = allAddresses.length === 0 const sendTip: MouseEventHandler = useCallback( - (evt) => { + async (evt) => { evt.stopPropagation() evt.preventDefault() + if (disabled) return + if (walletsState.loading || !walletsState.value) { + await queryBindings() + } + if (!allAddresses.length || !receiver?.userId) return PluginNextIdMessages.tipTask.sendToLocal({ - addresses: [account], + recipientSnsId: receiver.userId, + addresses: allAddresses, }) }, - [account], + [disabled, walletsState, allAddresses, receiver?.userId], ) + return ( -
+
+ {children}
) } + +export const PostTipButton: FC = (props) => { + const identifier = usePostInfoDetails.author() + const { classes } = useStyles() + return +} diff --git a/packages/mask/src/plugins/NextID/components/tip/TipDialog.tsx b/packages/mask/src/plugins/NextID/components/tip/TipDialog.tsx index 398207d5a360..3667bf0cc642 100644 --- a/packages/mask/src/plugins/NextID/components/tip/TipDialog.tsx +++ b/packages/mask/src/plugins/NextID/components/tip/TipDialog.tsx @@ -1,25 +1,35 @@ +import { SuccessIcon } from '@masknet/icons' import { PluginId, useActivatedPlugin, usePluginIDContext } from '@masknet/plugin-infra' import { makeStyles } from '@masknet/theme' -import { EMPTY_LIST } from '@masknet/web3-shared-evm' +import { EMPTY_LIST, TransactionStateType } from '@masknet/web3-shared-evm' import { DialogContent } from '@mui/material' +import { useCallback, useEffect, useMemo } from 'react' +import { useBoolean } from 'react-use' import { InjectedDialog } from '../../../../components/shared/InjectedDialog' import { NetworkTab } from '../../../../components/shared/NetworkTab' -import { useI18N } from '../../../../utils' -import { TargetChainIdContext } from '../../contexts' +import { activatedSocialNetworkUI } from '../../../../social-network' +import { TargetChainIdContext, useTip } from '../../contexts' +import { useI18N } from '../../locales' +import { TipType } from '../../types' +import { ConfirmModal } from '../ConfirmModal' import { TipForm } from './TipForm' const useStyles = makeStyles()((theme) => ({ - abstractTabWrapper: { - width: '100%', - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(2), - }, content: { + display: 'flex', + flexDirection: 'column', + flexBasis: '100%', paddingTop: 0, '&::-webkit-scrollbar': { display: 'none', }, }, + abstractTabWrapper: { + width: '100%', + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(2), + flexShrink: 0, + }, tab: { height: 36, minHeight: 36, @@ -34,6 +44,10 @@ const useStyles = makeStyles()((theme) => ({ margin: '0 auto', borderRadius: 4, }, + tipForm: { + flexGrow: 1, + overflow: 'auto', + }, })) interface TipDialogProps { @@ -45,24 +59,64 @@ export function TipDialog({ open = false, onClose }: TipDialogProps) { const pluginID = usePluginIDContext() const tipDefinition = useActivatedPlugin(PluginId.NextID, 'any') const chainIdList = tipDefinition?.enableRequirement.web3?.[pluginID]?.supportedChainIds ?? EMPTY_LIST - const { t } = useI18N() + const t = useI18N() const { classes } = useStyles() + const [confirmModalIsOpen, openConfirmModal] = useBoolean(false) const { targetChainId, setTargetChainId } = TargetChainIdContext.useContainer() + const { tipType, amount, token, recipientSnsId, recipient, sendState, erc721Contract } = useTip() + + const shareLink = useMemo(() => { + const assetName = + tipType === TipType.Token + ? `${amount || 'some'} ${token?.symbol || 'token'}` + : erc721Contract?.name + ? `a ${erc721Contract.name} NFT` + : '' + return activatedSocialNetworkUI.utils.getShareLinkURL?.( + `I just tipped ${assetName} to @${recipientSnsId}'s wallet address ${recipient} + +Install https://mask.io/download-links to tip my best friend.`, + ) + }, [amount, tipType, erc721Contract?.name, token, recipient, recipientSnsId]) + + const successMessage = t.send_tip_successfully() + + useEffect(() => { + if (sendState.type !== TransactionStateType.CONFIRMED) return + openConfirmModal(true) + }, [sendState.type]) + + const handleConfirm = useCallback(() => { + window.open(shareLink) + openConfirmModal(false) + onClose?.() + }, [shareLink, onClose]) return ( - - -
- -
- -
-
+ <> + + +
+ +
+ +
+
+ openConfirmModal(false)} + icon={} + message={successMessage} + confirmText={t.tip_share()} + onConfirm={handleConfirm} + /> + ) } diff --git a/packages/mask/src/plugins/NextID/components/tip/TipForm.tsx b/packages/mask/src/plugins/NextID/components/tip/TipForm.tsx index 92c059f21a12..9ce356676010 100644 --- a/packages/mask/src/plugins/NextID/components/tip/TipForm.tsx +++ b/packages/mask/src/plugins/NextID/components/tip/TipForm.tsx @@ -3,14 +3,25 @@ import { SelectTokenDialogEvent, WalletMessages } from '@masknet/plugin-wallet' import { useRemoteControlledDialog } from '@masknet/shared' import { makeStyles } from '@masknet/theme' import { EthereumTokenType, useFungibleTokenBalance } from '@masknet/web3-shared-evm' -import { Box, FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, Typography } from '@mui/material' +import { + Box, + BoxProps, + FormControl, + FormControlLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Typography, +} from '@mui/material' +import classnames from 'classnames' import { FC, memo, useCallback, useRef, useState } from 'react' import { v4 as uuid } from 'uuid' import ActionButton from '../../../../extension/options-page/DashboardComponents/ActionButton' -import { useI18N } from '../../../../utils' import { EthereumChainBoundary } from '../../../../web3/UI/EthereumChainBoundary' import { TokenAmountPanel } from '../../../../web3/UI/TokenAmountPanel' -import { TargetChainIdContext, useTip } from '../../contexts' +import { TargetChainIdContext, useTip, useTipValidate } from '../../contexts' +import { useI18N } from '../../locales' import { TipType } from '../../types' import { NFTSection } from './NFTSection' @@ -20,6 +31,12 @@ const useStyles = makeStyles()((theme) => { display: 'flex', flexDirection: 'column', }, + main: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + overflow: 'auto', + }, tipButton: { marginTop: theme.spacing(1.5), fontSize: 16, @@ -45,8 +62,10 @@ const useStyles = makeStyles()((theme) => { } }) -export const TipForm: FC = memo(() => { - const { t } = useI18N() +interface Props extends BoxProps {} + +export const TipForm: FC = memo(({ className, ...rest }) => { + const t = useI18N() const { targetChainId: chainId } = TargetChainIdContext.useContainer() const { classes } = useStyles() const { @@ -59,8 +78,10 @@ export const TipForm: FC = memo(() => { setToken, amount, setAmount, + isSending, sendTip, } = useTip() + const [isValid, validateMessage] = useTipValidate() const { Utils } = useWeb3State() const selectRef = useRef(null) const [id] = useState(uuid) @@ -76,6 +97,7 @@ export const TipForm: FC = memo(() => { ) const onSelectTokenChipClick = useCallback(() => { setSelectTokenDialog({ + chainId, open: true, uuid: id, disableNativeToken: false, @@ -83,72 +105,92 @@ export const TipForm: FC = memo(() => { selectedTokens: token ? [token.address] : [], }, }) - }, [id, token?.address]) + }, [id, token?.address, chainId]) + // balance const { value: tokenBalance = '0', loading: loadingTokenBalance } = useFungibleTokenBalance( token?.type || EthereumTokenType.Native, token?.address || '', + chainId, ) // #endregion + const buttonLabel = isSending ? t.sending_tip() : isValid || !validateMessage ? t.send_tip() : validateMessage return ( - - To + +
+ {t.tip_to()} - - - - - { - setTipType(e.target.value as TipType) - }}> - } label={t('plugin_tip_token')} /> - } label={t('plugin_tip_nft')} /> - - - {tipType === TipType.Token ? ( - - + + + + { + setTipType(e.target.value as TipType) + }}> + } + label={t.tip_type_token()} + /> + } + label={t.tip_type_nft()} + /> + - ) : ( - - )} + {tipType === TipType.Token ? ( + + + + ) : ( + + )} +
{ size="large" className={classes.tipButton} fullWidth - disabled={false} + disabled={!isValid || isSending} onClick={sendTip}> - {t('plugin_tip_send')} + {buttonLabel}
diff --git a/packages/mask/src/plugins/NextID/contexts/TipContext.tsx b/packages/mask/src/plugins/NextID/contexts/TipContext.tsx deleted file mode 100644 index 580d300c3e3a..000000000000 --- a/packages/mask/src/plugins/NextID/contexts/TipContext.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { rightShift } from '@masknet/web3-shared-base' -import { - ERC721ContractDetailed, - EthereumTokenType, - FungibleTokenDetailed, - isSameAddress, - TransactionState, - useNativeTokenDetailed, - useTokenConstants, - useTokenTransferCallback, -} from '@masknet/web3-shared-evm' -import { noop } from 'lodash-unified' -import { - createContext, - Dispatch, - FC, - SetStateAction, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react' -import { TipTask, TipType } from '../types' - -interface ContextOptions { - recipient: string - setRecipient: Dispatch> - tipType: TipType - setTipType: Dispatch> - recipients: string[] - setRecipients: Dispatch> - token: FungibleTokenDetailed | null - setToken: Dispatch> - amount: string - setAmount: Dispatch> - erc721TokenId: string | null - setErc721TokenId: Dispatch> - erc721Contract: ERC721ContractDetailed | null - setErc721Contract: Dispatch> -} - -export const TipContext = createContext({ - recipient: '', - setRecipient: noop, - tipType: TipType.NFT, - setTipType: noop, - recipients: [], - setRecipients: noop, - token: null, - setToken: noop, - amount: '', - setAmount: noop, - erc721TokenId: null, - setErc721TokenId: noop, - erc721Contract: null, - setErc721Contract: noop, -}) - -interface Props { - task: TipTask -} - -export const TipTaskProvider: FC = ({ children, task }) => { - const [recipient, setRecipient] = useState('') - const [recipients, setRecipients] = useState(task.addresses) - const [tipType, setTipType] = useState(TipType.Token) - const [amount, setAmount] = useState('0') - const [erc721Contract, setErc721Contract] = useState(null) - const { value: nativeTokenDetailed = null } = useNativeTokenDetailed() - const [token, setToken] = useState(nativeTokenDetailed) - const [erc721TokenId, setErc721TokenId] = useState(null) - - useEffect(() => { - setRecipient(task.to || '') - }, [task.to]) - - useEffect(() => { - setRecipients(task.addresses) - }, [task.addresses]) - - useEffect(() => { - if (recipient || recipients.length === 0) return - setRecipient(recipients[0]) - }, [recipient, recipients]) - - useEffect(() => { - if (token || !nativeTokenDetailed) return - setToken(nativeTokenDetailed) - }, [token, nativeTokenDetailed]) - - const contextValue = useMemo(() => { - return { - recipient, - setRecipient, - recipients, - setRecipients, - tipType, - setTipType, - token, - setToken, - amount, - setAmount, - erc721TokenId, - setErc721TokenId, - erc721Contract, - setErc721Contract, - } - }, [recipient, recipients, tipType, amount, erc721TokenId, erc721Contract, token]) - return {children} -} - -type TipTuple = [state: TransactionState, sendTip: () => Promise] - -function useTokenTipTuple(): TipTuple { - const { NATIVE_TOKEN_ADDRESS } = useTokenConstants() - const context = useContext(TipContext) - const { token, amount, recipient } = context - - const isNativeToken = isSameAddress(token?.address, NATIVE_TOKEN_ADDRESS) - - const assetType = isNativeToken ? EthereumTokenType.Native : EthereumTokenType.ERC20 - const [transferState, transferCallback, resetTransferCallback] = useTokenTransferCallback( - assetType, - token?.address || '', - ) - const sendTip = useCallback(async () => { - const transferAmount = rightShift(amount || '0', token?.decimals || 0).toFixed() - await transferCallback(transferAmount, recipient) - resetTransferCallback() - }, [amount, token, recipient, transferCallback, resetTransferCallback]) - - return [transferState, sendTip] -} - -function useNftTipTuple(): TipTuple { - const context = useContext(TipContext) - const { recipient, erc721TokenId, erc721Contract } = context - - const [transferState, transferCallback, resetTransferCallback] = useTokenTransferCallback( - EthereumTokenType.ERC721, - erc721Contract?.address || '', - ) - const sendTip = useCallback(async () => { - if (!erc721TokenId) return - await transferCallback(erc721TokenId, recipient) - resetTransferCallback() - }, [erc721TokenId, recipient, transferCallback, resetTransferCallback]) - - return [transferState, sendTip] -} - -export function useTip() { - const context = useContext(TipContext) - const tokenTipTuple = useTokenTipTuple() - const nftTipTuple = useNftTipTuple() - - const sendTipTuple = useMemo(() => { - return context.tipType === TipType.Token ? tokenTipTuple : nftTipTuple - }, [context.tipType, tokenTipTuple, nftTipTuple]) - - return { - ...context, - sendState: sendTipTuple[0], - sendTip: sendTipTuple[1], - } -} diff --git a/packages/mask/src/plugins/NextID/contexts/index.ts b/packages/mask/src/plugins/NextID/contexts/index.ts index 6c2817e81813..c5c454b87940 100644 --- a/packages/mask/src/plugins/NextID/contexts/index.ts +++ b/packages/mask/src/plugins/NextID/contexts/index.ts @@ -1,3 +1,3 @@ export * from './TargetChainIdContext' export * from './RootContext' -export * from './TipContext' +export * from './tip' diff --git a/packages/mask/src/plugins/NextID/contexts/tip/TipContext.ts b/packages/mask/src/plugins/NextID/contexts/tip/TipContext.ts new file mode 100644 index 000000000000..2082cac59765 --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/TipContext.ts @@ -0,0 +1,49 @@ +import { + ERC721ContractDetailed, + FungibleTokenDetailed, + TransactionState, + TransactionStateType, +} from '@masknet/web3-shared-evm' +import { noop } from 'lodash-unified' +import { createContext, Dispatch, SetStateAction } from 'react' +import { TipType } from '../../types' + +export interface ContextOptions { + recipient: string + recipientSnsId: string + setRecipient: Dispatch> + tipType: TipType + setTipType: Dispatch> + recipients: string[] + token: FungibleTokenDetailed | null + setToken: Dispatch> + amount: string + setAmount: Dispatch> + erc721TokenId: string | null + setErc721TokenId: Dispatch> + erc721Contract: ERC721ContractDetailed | null + setErc721Contract: Dispatch> + sendTip: () => Promise + isSending: boolean + sendState: TransactionState +} + +export const TipContext = createContext({ + recipient: '', + recipientSnsId: '', + setRecipient: noop, + tipType: TipType.NFT, + setTipType: noop, + recipients: [], + token: null, + setToken: noop, + amount: '', + setAmount: noop, + erc721TokenId: null, + setErc721TokenId: noop, + erc721Contract: null, + setErc721Contract: noop, + sendTip: noop as () => Promise, + isSending: false, + sendState: { type: TransactionStateType.UNKNOWN }, +}) diff --git a/packages/mask/src/plugins/NextID/contexts/tip/TipTaskProvider.tsx b/packages/mask/src/plugins/NextID/contexts/tip/TipTaskProvider.tsx new file mode 100644 index 000000000000..8ffa8c12c9fa --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/TipTaskProvider.tsx @@ -0,0 +1,87 @@ +import { TransactionStateType, useNativeTokenDetailed } from '@masknet/web3-shared-evm' +import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { TipTask, TipType } from '../../types' +import { TargetChainIdContext } from '../TargetChainIdContext' +import { ContextOptions, TipContext } from './TipContext' +import { useNftTip } from './useNftTip' +import { useTokenTip } from './useTokenTip' + +interface Props { + task: TipTask +} + +export const TipTaskProvider: FC = ({ children, task }) => { + const [recipient, setRecipient] = useState('') + const [tipType, setTipType] = useState(TipType.Token) + const [amount, setAmount] = useState('') + const { targetChainId } = TargetChainIdContext.useContainer() + const [erc721Contract, setErc721Contract] = useState(null) + const { value: nativeTokenDetailed = null } = useNativeTokenDetailed(targetChainId) + const [token, setToken] = useState(nativeTokenDetailed) + const [erc721TokenId, setErc721TokenId] = useState(null) + + useEffect(() => { + setRecipient(task.to || '') + }, [task.to]) + + useEffect(() => { + if (recipient || task.addresses.length === 0) return + setRecipient(task.addresses[0]) + }, [recipient, task.addresses]) + + useEffect(() => { + if (!nativeTokenDetailed) return + setToken(nativeTokenDetailed) + }, [nativeTokenDetailed]) + const tokenTipTuple = useTokenTip(recipient, token, amount) + const nftTipTuple = useNftTip(recipient, erc721TokenId, erc721Contract) + + const sendTipTuple = tipType === TipType.Token ? tokenTipTuple : nftTipTuple + const sendState = sendTipTuple[0] + const sendTip = sendTipTuple[1] + + const isSending = [ + TransactionStateType.WAIT_FOR_CONFIRMING, + TransactionStateType.HASH, + TransactionStateType.RECEIPT, + ].includes(sendState.type) + + const contextValue = useMemo(() => { + return { + recipient, + recipientSnsId: task.recipientSnsId || '', + setRecipient, + recipients: task.addresses, + tipType, + setTipType, + token, + setToken, + amount, + setAmount, + erc721TokenId, + setErc721TokenId, + erc721Contract, + setErc721Contract, + sendTip, + isSending, + sendState, + } + }, [ + recipient, + task.recipientSnsId, + task.addresses, + tipType, + amount, + erc721TokenId, + erc721Contract, + token, + sendTip, + isSending, + sendState, + ]) + return {children} +} + +export function useTip() { + return useContext(TipContext) +} diff --git a/packages/mask/src/plugins/NextID/contexts/tip/index.ts b/packages/mask/src/plugins/NextID/contexts/tip/index.ts new file mode 100644 index 000000000000..2f9d5b3d67e0 --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/index.ts @@ -0,0 +1,3 @@ +export * from './TipContext' +export * from './TipTaskProvider' +export * from './useTipValidate' diff --git a/packages/mask/src/plugins/NextID/contexts/tip/type.ts b/packages/mask/src/plugins/NextID/contexts/tip/type.ts new file mode 100644 index 000000000000..7f65df6940fa --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/type.ts @@ -0,0 +1,3 @@ +import type { TransactionState } from '@masknet/web3-shared-evm' + +export type TipTuple = [state: TransactionState, sendTip: () => Promise] diff --git a/packages/mask/src/plugins/NextID/contexts/tip/useNftTip.ts b/packages/mask/src/plugins/NextID/contexts/tip/useNftTip.ts new file mode 100644 index 000000000000..398d2cfbe769 --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/useNftTip.ts @@ -0,0 +1,21 @@ +import { ERC721ContractDetailed, EthereumTokenType, useTokenTransferCallback } from '@masknet/web3-shared-evm' +import { useCallback } from 'react' +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 || '', + ) + + const sendTip = useCallback(async () => { + if (!tokenId) return + await transferCallback(tokenId, recipient) + }, [tokenId, 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 new file mode 100644 index 000000000000..741010377f9c --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/useTipValidate.ts @@ -0,0 +1,27 @@ +import { isGreaterThan, isLessThanOrEqualTo, rightShift } from '@masknet/web3-shared-base' +import { EthereumTokenType, useFungibleTokenBalance } from '@masknet/web3-shared-evm' +import { useContext, useMemo } from 'react' +import { useI18N } from '../../locales' +import { TipType } from '../../types' +import { TipContext } from './TipContext' + +type ValidationTuple = [isValid: boolean, message?: string] + +export function useTipValidate(): ValidationTuple { + const { tipType, amount, token, erc721TokenId, erc721Contract } = useContext(TipContext) + const { value: balance = '0' } = useFungibleTokenBalance(token?.type || EthereumTokenType.Native, token?.address) + const t = useI18N() + + const result: ValidationTuple = useMemo(() => { + if (tipType === TipType.Token) { + if (!amount || isLessThanOrEqualTo(amount, 0)) return [false] + if (isGreaterThan(rightShift(amount, token?.decimals), balance)) + return [false, t.token_insufficient_balance()] + } else { + if (!erc721TokenId || !erc721Contract) return [false] + } + return [true] + }, [tipType, amount, token?.decimals, balance, erc721Contract, erc721TokenId, t]) + + return result +} diff --git a/packages/mask/src/plugins/NextID/contexts/tip/useTokenTip.ts b/packages/mask/src/plugins/NextID/contexts/tip/useTokenTip.ts new file mode 100644 index 000000000000..a5bb361c7438 --- /dev/null +++ b/packages/mask/src/plugins/NextID/contexts/tip/useTokenTip.ts @@ -0,0 +1,26 @@ +import { rightShift } from '@masknet/web3-shared-base' +import { + EthereumTokenType, + FungibleTokenDetailed, + isSameAddress, + useTokenConstants, + useTokenTransferCallback, +} from '@masknet/web3-shared-evm' +import { useCallback } from 'react' +import type { TipTuple } from './type' + +export function useTokenTip(recipient: string, token: FungibleTokenDetailed | null, amount: string): TipTuple { + const { NATIVE_TOKEN_ADDRESS } = useTokenConstants() + + const isNativeToken = isSameAddress(token?.address, NATIVE_TOKEN_ADDRESS) + + const assetType = isNativeToken ? EthereumTokenType.Native : EthereumTokenType.ERC20 + const [transferState, transferCallback] = useTokenTransferCallback(assetType, token?.address || '') + + const sendTip = useCallback(async () => { + const transferAmount = rightShift(amount || '0', token?.decimals || 0).toFixed() + await transferCallback(transferAmount, recipient) + }, [amount, token, recipient, transferCallback]) + + return [transferState, sendTip] +} diff --git a/packages/mask/src/plugins/NextID/locales/en-US.json b/packages/mask/src/plugins/NextID/locales/en-US.json index ab8fe62b7871..80e81095f5f4 100644 --- a/packages/mask/src/plugins/NextID/locales/en-US.json +++ b/packages/mask/src/plugins/NextID/locales/en-US.json @@ -31,5 +31,15 @@ "unbind_persona_tip": "Choose either your Persona or the currently connected wallet to sign to disconnect.", "unbind_wallet_tip": "Choose either your Persona or the currently connected wallet to sign to disconnect.", "done": "Done", - "copy_success_of_wallet_address": "Copy wallet address successfully!" + "copy_success_of_wallet_address": "Copy wallet address successfully!", + "tip": "Tip", + "tip_to": "To", + "token_insufficient_balance": "Insufficient balance", + "tip_type_token": "Token", + "tip_type_nft": "NFT", + "send_tip": "Send", + "sending_tip": "Sending...", + "nft_not_belong_to_you": "It doesn't belong to you", + "send_tip_successfully": "Sent tip successfully", + "tip_share": "Share" } diff --git a/packages/mask/src/plugins/NextID/types/tip.ts b/packages/mask/src/plugins/NextID/types/tip.ts index 5a6c6d55d09f..2e6cbde0e5cd 100644 --- a/packages/mask/src/plugins/NextID/types/tip.ts +++ b/packages/mask/src/plugins/NextID/types/tip.ts @@ -5,6 +5,6 @@ export enum TipType { export interface TipTask { to?: string - snsID?: string + recipientSnsId?: string addresses: string[] } diff --git a/packages/web3-shared/evm/hooks/useERC20TokenTransferCallback.ts b/packages/web3-shared/evm/hooks/useERC20TokenTransferCallback.ts index c58062b44612..58e5e724f8b2 100644 --- a/packages/web3-shared/evm/hooks/useERC20TokenTransferCallback.ts +++ b/packages/web3-shared/evm/hooks/useERC20TokenTransferCallback.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { EthereumAddress } from 'wallet.ts' import type { NonPayableTx } from '@masknet/web3-contracts/types/types' import { useAccount } from './useAccount' @@ -76,6 +76,13 @@ export function useERC20TokenTransferCallback(address?: string, amount?: string, }) resolve(hash) }) + .on(TransactionEventType.CONFIRMATION, (no, receipt) => { + setTransferState({ + type: TransactionStateType.CONFIRMED, + no, + receipt, + }) + }) .on(TransactionEventType.ERROR, (error) => { setTransferState({ type: TransactionStateType.FAILED, @@ -94,5 +101,10 @@ export function useERC20TokenTransferCallback(address?: string, amount?: string, }) }, []) + useEffect(() => { + if (transferState.type !== TransactionStateType.CONFIRMED) return + resetCallback() + }, [transferState.type]) + return [transferState, transferCallback, resetCallback] as const } diff --git a/packages/web3-shared/evm/hooks/useERC721TokenTransferCallback.ts b/packages/web3-shared/evm/hooks/useERC721TokenTransferCallback.ts index fb61243453e1..a1a695bd3a7c 100644 --- a/packages/web3-shared/evm/hooks/useERC721TokenTransferCallback.ts +++ b/packages/web3-shared/evm/hooks/useERC721TokenTransferCallback.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { EthereumAddress } from 'wallet.ts' import type { NonPayableTx } from '@masknet/web3-contracts/types/types' import { TransactionStateType, GasConfig, TransactionEventType } from '../types' @@ -76,6 +76,13 @@ export function useERC721TokenTransferCallback(address?: string) { }) resolve(hash) }) + .on(TransactionEventType.CONFIRMATION, (no, receipt) => { + setTransferState({ + type: TransactionStateType.CONFIRMED, + no, + receipt, + }) + }) .on(TransactionEventType.ERROR, (error) => { setTransferState({ type: TransactionStateType.FAILED, @@ -94,5 +101,10 @@ export function useERC721TokenTransferCallback(address?: string) { }) }, []) + useEffect(() => { + if (transferState.type !== TransactionStateType.CONFIRMED) return + resetCallback() + }, [transferState.type]) + return [transferState, transferCallback, resetCallback] as const } diff --git a/packages/web3-shared/evm/hooks/useFungibleTokenBalance.ts b/packages/web3-shared/evm/hooks/useFungibleTokenBalance.ts index a9cffe2dd42d..e555de31ba22 100644 --- a/packages/web3-shared/evm/hooks/useFungibleTokenBalance.ts +++ b/packages/web3-shared/evm/hooks/useFungibleTokenBalance.ts @@ -8,7 +8,7 @@ export function useFungibleTokenBalance( address?: string, chainId?: ChainId, ) { - const r1 = useNativeTokenBalance() + const r1 = useNativeTokenBalance(chainId) const r2 = useERC20TokenBalance(type === EthereumTokenType.ERC20 ? address : undefined, chainId) const type_ = type diff --git a/packages/web3-shared/evm/hooks/useNativeTokenBalance.ts b/packages/web3-shared/evm/hooks/useNativeTokenBalance.ts index 9eb8443bd951..f3a555fa40fe 100644 --- a/packages/web3-shared/evm/hooks/useNativeTokenBalance.ts +++ b/packages/web3-shared/evm/hooks/useNativeTokenBalance.ts @@ -1,14 +1,14 @@ import { useAsyncRetry } from 'react-use' +import type { ChainId } from '../types' import { useAccount } from './useAccount' import { useBalance } from './useBalance' /** * Fetch native token balance from chain - * @param address */ -export function useNativeTokenBalance() { +export function useNativeTokenBalance(chainId?: ChainId) { const account = useAccount() - const { value: balance = '0' } = useBalance() + const { value: balance = '0' } = useBalance(chainId) return useAsyncRetry(async () => { if (!account) return return balance diff --git a/packages/web3-shared/evm/hooks/useNativeTokenTransferCallback.ts b/packages/web3-shared/evm/hooks/useNativeTokenTransferCallback.ts index c2e6e3c6ece7..d1b20fdd25df 100644 --- a/packages/web3-shared/evm/hooks/useNativeTokenTransferCallback.ts +++ b/packages/web3-shared/evm/hooks/useNativeTokenTransferCallback.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { toHex } from 'web3-utils' import { EthereumAddress } from 'wallet.ts' import { useAccount } from './useAccount' @@ -6,7 +6,7 @@ import { useChainId } from './useChainId' import { useTransactionState } from './useTransactionState' import { useWeb3 } from './useWeb3' import { isGreaterThan, isZero } from '@masknet/web3-shared-base' -import { TransactionStateType, GasConfig } from '../types' +import { TransactionStateType, GasConfig, TransactionEventType } from '../types' export function useNativeTransferCallback() { const web3 = useWeb3() @@ -73,21 +73,29 @@ export function useNativeTransferCallback() { // send transaction and wait for hash return new Promise((resolve, reject) => { - web3.eth.sendTransaction(config, (error, hash) => { - if (error) { - setTransferState({ - type: TransactionStateType.FAILED, - error, - }) - reject(error) - } else { + web3.eth + .sendTransaction(config, (error, hash) => { + if (error) { + setTransferState({ + type: TransactionStateType.FAILED, + error, + }) + reject(error) + } else { + setTransferState({ + type: TransactionStateType.HASH, + hash, + }) + resolve(hash) + } + }) + .on(TransactionEventType.CONFIRMATION, (no, receipt) => { setTransferState({ - type: TransactionStateType.HASH, - hash, + type: TransactionStateType.CONFIRMED, + no, + receipt, }) - resolve(hash) - } - }) + }) }) }, [web3, account, chainId], @@ -99,5 +107,10 @@ export function useNativeTransferCallback() { }) }, []) + useEffect(() => { + if (transferState.type !== TransactionStateType.CONFIRMED) return + resetCallback() + }, [transferState.type]) + return [transferState, transferCallback, resetCallback] as const }