From 29b519cb1d07fe48464779a0d3ec63aad03bac2d Mon Sep 17 00:00:00 2001 From: richardo2016x <104543757+richardo2016x@users.noreply.github.com> Date: Fri, 26 Apr 2024 19:36:13 +0800 Subject: [PATCH] feat: support standalone add token entry. (#2227) * feat: support standalone add token entry. * build: shellscript executable. * feat: adjust operation. * chore: i18n. * fix: style & behaviors. * style: tuning. * feat: robust change. * feat: filter out core token, style tuning. * style: tuning. --- _raw/locales/en/messages.json | 14 +- scripts/pack-debug.sh | 0 src/background/service/preference.ts | 21 +- src/ui/assets/dashboard/tab-history.svg | 7 - src/ui/assets/dashboard/tab-list.svg | 8 - src/ui/assets/dashboard/tab-summary.svg | 5 - src/ui/assets/open-external-cc.svg | 8 +- src/ui/hooks/useRefState.ts | 19 + src/ui/hooks/useSearchToken.ts | 268 ++++++++++- src/ui/models/account.ts | 4 + src/ui/utils/portfolio/project.ts | 6 +- src/ui/views/AddressDetail/GnosisSafeInfo.tsx | 2 +- .../Approval/components/Connect/index.tsx | 2 +- .../components/TypedDataActions/utils.ts | 4 +- .../CommonPopup/AssetList/AddTokenEntry.tsx | 96 ++++ .../AssetList/AssetListContainer.tsx | 51 +-- .../CustomAssetList/AddCustomTokenPopup.tsx | 431 ++++++++++++++++++ .../AddCustomTestnetTokenPopup.tsx | 21 +- .../CustomTestnetTokenDetail.tsx | 1 + .../AssetList/CustomizedButton.tsx | 12 +- .../CommonPopup/AssetList/ProtocolList.tsx | 3 +- .../views/CommonPopup/AssetList/TokenList.tsx | 9 +- .../views/CommonPopup/AssetList/TokenTabs.tsx | 67 --- .../AssetList/components/TokenButton.tsx | 121 +++-- .../AssetList/icons/add-entry-cc.svg | 5 + .../TokenDetailPopup/CustomizedButton.tsx | 2 +- .../TokenDetailPopup/TokenDetail.tsx | 5 +- .../components/TokenDetailPopup/index.tsx | 2 +- src/utils/chain.ts | 3 +- 29 files changed, 1004 insertions(+), 193 deletions(-) mode change 100644 => 100755 scripts/pack-debug.sh delete mode 100644 src/ui/assets/dashboard/tab-history.svg delete mode 100644 src/ui/assets/dashboard/tab-list.svg delete mode 100644 src/ui/assets/dashboard/tab-summary.svg create mode 100644 src/ui/hooks/useRefState.ts create mode 100644 src/ui/views/CommonPopup/AssetList/AddTokenEntry.tsx create mode 100644 src/ui/views/CommonPopup/AssetList/CustomAssetList/AddCustomTokenPopup.tsx delete mode 100644 src/ui/views/CommonPopup/AssetList/TokenTabs.tsx create mode 100644 src/ui/views/CommonPopup/AssetList/icons/add-entry-cc.svg diff --git a/_raw/locales/en/messages.json b/_raw/locales/en/messages.json index f7e15cefd32..ba94f463e76 100644 --- a/_raw/locales/en/messages.json +++ b/_raw/locales/en/messages.json @@ -1104,22 +1104,32 @@ "blockDescription": "Token blocked by you will be shown here", "unfoldChain": "Unfold 1 chain", "unfoldChainPlural": "Unfold {{moreLen}} chains", - "customLinkText": "Search address to add custom token", + "customButtonText": "Add custom token", "customDescription": "Custom token added by you will be shown here", "comingSoon": "Coming Soon...", "searchPlaceholder": "Tokens", + "AddMainnetToken": { + "title": "Add Custom Token", + "selectChain": "Select chain", + "searching": "Searching Token", + "tokenAddress": "Token Address", + "tokenAddressPlaceholder": "Token Address", + "notFound": "Token not found" + }, "AddTestnetToken": { "title": "Add Custom Network Token", "selectChain": "Select chain", "searching": "Searching Token", "tokenAddress": "Token Address", + "tokenAddressPlaceholder": "Token Address", "notFound": "Token not found" }, "TestnetAssetListContainer": { "add": "Token", "addTestnet": "Network" }, - "noTestnetAssets": "No Custom Network Assets" + "noTestnetAssets": "No Custom Network Assets", + "addTokenEntryText": "Token" }, "hd": { "howToConnectLedger": "How to Connect Ledger", diff --git a/scripts/pack-debug.sh b/scripts/pack-debug.sh old mode 100644 new mode 100755 diff --git a/src/background/service/preference.ts b/src/background/service/preference.ts index ceb90b20a90..effc812dbd6 100644 --- a/src/background/service/preference.ts +++ b/src/background/service/preference.ts @@ -605,19 +605,18 @@ class PreferenceService { getCustomizedToken = () => { return this.store.customizedToken || []; }; + hasCustomizedToken = (token: Token) => { + return !!this.store.customizedToken?.find( + (item) => + isSameAddress(item.address, token.address) && item.chain === token.chain + ); + }; addCustomizedToken = (token: Token) => { - if ( - !this.store.customizedToken?.find( - (item) => - isSameAddress(item.address, token.address) && - item.chain === token.chain - ) - ) { - this.store.customizedToken = [ - ...(this.store.customizedToken || []), - token, - ]; + if (this.hasCustomizedToken(token)) { + throw new Error('Token already added'); } + + this.store.customizedToken = [...(this.store.customizedToken || []), token]; }; removeCustomizedToken = (token: Token) => { this.store.customizedToken = this.store.customizedToken?.filter( diff --git a/src/ui/assets/dashboard/tab-history.svg b/src/ui/assets/dashboard/tab-history.svg deleted file mode 100644 index 99480a5458c..00000000000 --- a/src/ui/assets/dashboard/tab-history.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/ui/assets/dashboard/tab-list.svg b/src/ui/assets/dashboard/tab-list.svg deleted file mode 100644 index 491f6e329ec..00000000000 --- a/src/ui/assets/dashboard/tab-list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/ui/assets/dashboard/tab-summary.svg b/src/ui/assets/dashboard/tab-summary.svg deleted file mode 100644 index 0b2a9f14545..00000000000 --- a/src/ui/assets/dashboard/tab-summary.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/ui/assets/open-external-cc.svg b/src/ui/assets/open-external-cc.svg index fd0bcf5c7ed..77c42109219 100644 --- a/src/ui/assets/open-external-cc.svg +++ b/src/ui/assets/open-external-cc.svg @@ -1,3 +1,5 @@ - - - + + + + + \ No newline at end of file diff --git a/src/ui/hooks/useRefState.ts b/src/ui/hooks/useRefState.ts new file mode 100644 index 00000000000..7c55f78b9df --- /dev/null +++ b/src/ui/hooks/useRefState.ts @@ -0,0 +1,19 @@ +import React, { useCallback, useRef, useState } from 'react'; + +export function useRefState(initValue: T | null) { + const stateRef = useRef(initValue) as React.MutableRefObject; + const [, setSpinner] = useState(false); + + const setRefState = useCallback((newState: T, triggerRerender = true) => { + stateRef.current = newState; + if (triggerRerender) { + setSpinner((prev) => !prev); + } + }, []); + + return { + state: stateRef.current, + stateRef, + setRefState, + } as const; +} diff --git a/src/ui/hooks/useSearchToken.ts b/src/ui/hooks/useSearchToken.ts index bf2106d4fbb..6de8e52469d 100644 --- a/src/ui/hooks/useSearchToken.ts +++ b/src/ui/hooks/useSearchToken.ts @@ -1,12 +1,274 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useWallet } from '../utils/WalletContext'; import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; -import { DisplayedToken } from '../utils/portfolio/project'; +import { + DisplayedToken, + encodeProjectTokenId, +} from '../utils/portfolio/project'; import { AbstractPortfolioToken } from '../utils/portfolio/types'; -import { useRabbySelector } from 'ui/store'; +import { useRabbyDispatch, useRabbySelector } from 'ui/store'; import { isSameAddress } from '../utils'; import { requestOpenApiWithChainId } from '../utils/openapi'; import { findChainByServerID } from '@/utils/chain'; +import { Chain } from '@debank/common'; +import useDebounceValue from './useDebounceValue'; +import { useRefState } from './useRefState'; + +function isSearchInputWeb3Address(q: string) { + return q.length === 42 && q.toLowerCase().startsWith('0x'); +} + +export function useIsTokenAddedLocally(token?: TokenItem | null) { + const { customize, blocked } = useRabbySelector( + (state) => state.account.tokens + ); + + const addedInfo = useMemo(() => { + if (!token) return { onCustomize: false, onBlocked: false, isLocal: false }; + + const onCustomize = !!customize.find( + (t) => t.id === encodeProjectTokenId(token) + ); + const onBlocked = + !onCustomize && + !!blocked.find( + (t) => t.chain && token.chain && isSameAddress(t.id, token.id) + ); + + return { + onCustomize, + onBlocked, + isLocal: onCustomize || onBlocked, + }; + }, [customize, token?.id]); + + return addedInfo; +} + +export function varyTokensByLocal< + T extends TokenItem[] | AbstractPortfolioToken[] +>( + tokenList: T, + input: { + customize?: AbstractPortfolioToken[]; + blocked?: AbstractPortfolioToken[]; + } +) { + const { customize = [], blocked = [] } = input; + + const varied = { + remote: [] as TokenItem[], + local: [] as TokenItem[], + }; + const localMap = {} as Record; + const wholeList = customize.concat(blocked); + for (let i = 0; i < wholeList.length; i++) { + const item = wholeList[i]; + localMap[`${item.chain}-${item.id}`] = item; + } + + tokenList.forEach((token) => { + const matched = localMap[`${token.chain}-${token.id}`]; + if (matched) { + varied.local.push(matched); + } else { + varied.remote.push(token); + } + }); + + return varied; +} + +export function useVaryTokensByLocal< + T extends TokenItem[] | AbstractPortfolioToken[] +>(tokenList: T) { + const { customize, blocked } = useRabbySelector( + (state) => state.account.tokens + ); + + const varied = useMemo(() => { + return varyTokensByLocal(tokenList, { customize, blocked }); + }, [customize, blocked]); + + return varied; +} + +export function useOperateCustomToken() { + const dispatch = useRabbyDispatch(); + + const addToken = useCallback(async (tokenWithAmount: TokenItem) => { + if (!tokenWithAmount) return; + + if (tokenWithAmount.is_core) { + return dispatch.account.addBlockedToken( + new DisplayedToken(tokenWithAmount) as AbstractPortfolioToken + ); + } else { + return dispatch.account.addCustomizeToken( + new DisplayedToken(tokenWithAmount) as AbstractPortfolioToken + ); + } + }, []); + + const removeToken = useCallback(async (tokenWithAmount: TokenItem) => { + if (!tokenWithAmount) return; + + if (tokenWithAmount?.is_core) { + return dispatch.account.removeBlockedToken( + new DisplayedToken(tokenWithAmount) as AbstractPortfolioToken + ); + } else { + return dispatch.account.removeCustomizeToken( + new DisplayedToken(tokenWithAmount) as AbstractPortfolioToken + ); + } + }, []); + + return { + addToken, + removeToken, + }; +} + +/** eslint-enable react-hooks/exhaustive-deps */ +export function useFindCustomToken(input?: { + // address: string, + chainServerId?: Chain['serverId']; + isTestnet?: boolean; + autoSearch?: boolean; +}) { + const { + // address, + // chainServerId: _propchainServerId, + isTestnet = false, + autoSearch, + } = input || {}; + + const wallet = useWallet(); + const [{ tokenList, portfolioTokenList }, setLists] = useState<{ + tokenList: TokenItem[]; + portfolioTokenList: AbstractPortfolioToken[]; + }>({ + tokenList: [], + portfolioTokenList: [], + }); + const [isLoading, setIsLoading] = useState(false); + const { + state: searchKeyword, + setRefState: setSearchKeyword, + stateRef: skRef, + } = useRefState(''); + const debouncedSearchKeyword = useDebounceValue(searchKeyword, 150); + const { customize, blocked } = useRabbySelector( + (state) => state.account.tokens + ); + + const searchCustomToken = useCallback( + async ( + opt: { + address?: string; + q?: string; + chainServerId?: Chain['serverId']; + } = {} + ) => { + const { address, q = debouncedSearchKeyword, chainServerId } = opt || {}; + + const lists: { + tokenList: TokenItem[]; + portfolioTokenList: AbstractPortfolioToken[]; + } = { + tokenList: [], + portfolioTokenList: [], + }; + if (!address) return lists; + + const chainItem = !chainServerId + ? null + : findChainByServerID(chainServerId); + if (isTestnet || chainItem?.isTestnet) { + return; + } + + setIsLoading(true); + + try { + if (isSearchInputWeb3Address(q)) { + lists.tokenList = await requestOpenApiWithChainId( + (ctx) => ctx.openapi.searchToken(address, q, chainServerId, true), + { + isTestnet: !!isTestnet || !!chainItem?.isTestnet, + wallet, + } + // filter out core tokens + ).then((res) => res.filter((item) => !item.is_core)); + } else { + // lists.tokenList = await requestOpenApiWithChainId( + // (ctx) => ctx.openapi.searchToken(address, q, chainServerId), + // { + // isTestnet: !!isTestnet || !!chainItem?.isTestnet, + // wallet, + // } + // ); + } + + if (q === skRef.current) { + // const reg = new RegExp(debouncedSearchKeyword, 'i'); + // const matchCustomTokens = customize.filter((token) => { + // return ( + // reg.test(token.name) || + // reg.test(token.symbol) || + // reg.test(token.display_symbol || '') + // ); + // }); + + lists.portfolioTokenList = [ + ...(lists.tokenList.map( + (item) => new DisplayedToken(item) + ) as AbstractPortfolioToken[]), + // ...matchCustomTokens, + ].filter((item) => { + const isBlocked = !!blocked.find((b) => + isSameAddress(b.id, item.id) + ); + return !isBlocked; + }); + } + setLists(lists); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + + return lists; + }, + [debouncedSearchKeyword, blocked, isTestnet] + ); + + useEffect(() => { + if (autoSearch) { + searchCustomToken(); + } + }, [autoSearch, searchCustomToken]); + + const resetSearchResult = useCallback(() => { + setLists({ + tokenList: [], + portfolioTokenList: [], + }); + }, []); + + return { + searchCustomToken, + debouncedSearchKeyword, + setSearchKeyword, + resetSearchResult, + tokenList, + foundTokenList: portfolioTokenList, + isLoading, + }; +} +/** eslint-disable react-hooks/exhaustive-deps */ const useSearchToken = ( address: string | undefined, diff --git a/src/ui/models/account.ts b/src/ui/models/account.ts index f86f52168de..1a54594d965 100644 --- a/src/ui/models/account.ts +++ b/src/ui/models/account.ts @@ -326,6 +326,8 @@ export const account = createModel()({ ? store.account.testnetTokens.list : store.account.tokens.list; setTokenList(tokenList.filter((item) => item.id !== token.id)); + + return token; }, async removeBlockedToken(token: AbstractPortfolioToken, store) { @@ -354,6 +356,8 @@ export const account = createModel()({ : store.account.tokens.list; setTokenList([...tokenList, token]); } + + return token; }, async triggerFetchBalanceOnBackground( diff --git a/src/ui/utils/portfolio/project.ts b/src/ui/utils/portfolio/project.ts index 00b653832da..5ba0ccae7c4 100644 --- a/src/ui/utils/portfolio/project.ts +++ b/src/ui/utils/portfolio/project.ts @@ -297,6 +297,10 @@ class DisplayedPortfolio implements AbstractPortfolio { // static createFromHistory(h: PortfolioItem) {} } +export function encodeProjectTokenId(token: PortfolioItemToken) { + return token.id + token.chain; +} + export class DisplayedToken implements AbstractPortfolioToken { [immerable] = true; id: string; @@ -332,7 +336,7 @@ export class DisplayedToken implements AbstractPortfolioToken { constructor(token: PortfolioItemToken) { this._tokenId = token.id; this.amount = token.amount || 0; - this.id = token.id + token.chain; + this.id = encodeProjectTokenId(token); this.chain = token.chain; this.logo_url = token.logo_url; this.price = token.price || 0; diff --git a/src/ui/views/AddressDetail/GnosisSafeInfo.tsx b/src/ui/views/AddressDetail/GnosisSafeInfo.tsx index 94cc109d357..b1c0b7c2e57 100644 --- a/src/ui/views/AddressDetail/GnosisSafeInfo.tsx +++ b/src/ui/views/AddressDetail/GnosisSafeInfo.tsx @@ -50,7 +50,7 @@ export const GnonisSafeInfo = ({ const wallet = useWallet(); const [activeData, setActiveData] = useState< | { - chain?: Chain; + chain?: Chain | null; data: BasicSafeInfo; } | undefined diff --git a/src/ui/views/Approval/components/Connect/index.tsx b/src/ui/views/Approval/components/Connect/index.tsx index 02fbaa93f91..a9a041c799d 100644 --- a/src/ui/views/Approval/components/Connect/index.tsx +++ b/src/ui/views/Approval/components/Connect/index.tsx @@ -491,7 +491,7 @@ const Connect = ({ params: { icon, origin } }: ConnectProps) => { account!.address, origin ); - let targetChain: Chain | undefined; + let targetChain: Chain | null | undefined; for (let i = 0; i < recommendChains.length; i++) { targetChain = findChain({ serverId: recommendChains[i].id, diff --git a/src/ui/views/Approval/components/TypedDataActions/utils.ts b/src/ui/views/Approval/components/TypedDataActions/utils.ts index 3b16ef078a5..83c44940053 100644 --- a/src/ui/views/Approval/components/TypedDataActions/utils.ts +++ b/src/ui/views/Approval/components/TypedDataActions/utils.ts @@ -692,7 +692,7 @@ export const fetchRequireData = async ( sender: string, wallet: WalletControllerType ): Promise => { - let chain: Chain | undefined; + let chain: Chain | null | undefined; if (actionData.chainId) { chain = findChain({ id: Number(actionData.chainId), @@ -929,7 +929,7 @@ export const formatSecurityEngineCtx = async ({ requireData: TypedDataRequireData; wallet: WalletControllerType; }): Promise => { - let chain: Chain | undefined; + let chain: Chain | null | undefined; if (actionData?.chainId) { chain = findChain({ id: Number(actionData.chainId), diff --git a/src/ui/views/CommonPopup/AssetList/AddTokenEntry.tsx b/src/ui/views/CommonPopup/AssetList/AddTokenEntry.tsx new file mode 100644 index 00000000000..ea3403006a9 --- /dev/null +++ b/src/ui/views/CommonPopup/AssetList/AddTokenEntry.tsx @@ -0,0 +1,96 @@ +import React, { useImperativeHandle } from 'react'; + +import { ReactComponent as RcAddEntryCC } from './icons/add-entry-cc.svg'; +import clsx from 'clsx'; +import { AddCustomTokenPopup } from './CustomAssetList/AddCustomTokenPopup'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { useTranslation } from 'react-i18next'; +import { SpecialTokenListPopup } from './components/TokenButton'; +import { useRabbySelector } from '@/ui/store'; +import useSortToken from '@/ui/hooks/useSortTokens'; + +type Props = { + onConfirm?: React.ComponentProps['onConfirm']; +}; +export type AddTokenEntryInst = { + startAddToken: () => void; +}; +const AddTokenEntry = React.forwardRef( + function AddTokenEntryPorto({ onConfirm }, ref) { + const { t } = useTranslation(); + const [isShowAddModal, setIsShowAddModal] = React.useState(false); + + useImperativeHandle(ref, () => ({ + startAddToken: () => { + setIsShowAddModal(true); + }, + })); + + // const [focusingToken, setFocusingToken] = React.useState( + // null + // ); + + const { customize } = useRabbySelector((store) => store.account.tokens); + const tokens = useSortToken(customize); + + const [showCustomizedTokens, setShowCustomizedTokens] = React.useState( + false + ); + + return ( + <> +
{ + setIsShowAddModal(true); + }} + > + + {t('page.dashboard.assets.addTokenEntryText')} +
+ + { + setIsShowAddModal(false); + }} + onConfirm={(addedToken) => { + setIsShowAddModal(false); + setShowCustomizedTokens(true); + + // setFocusingToken(addedToken?.token || null); + // refreshAsync(); + }} + /> + + {/* setFocusingToken(null)} + /> */} + + { + setShowCustomizedTokens(true); + }} + tokens={tokens} + visible={showCustomizedTokens} + onClose={() => setShowCustomizedTokens(false)} + /> + + ); + } +); + +export default AddTokenEntry; diff --git a/src/ui/views/CommonPopup/AssetList/AssetListContainer.tsx b/src/ui/views/CommonPopup/AssetList/AssetListContainer.tsx index 29396e893d3..3e61c9003d2 100644 --- a/src/ui/views/CommonPopup/AssetList/AssetListContainer.tsx +++ b/src/ui/views/CommonPopup/AssetList/AssetListContainer.tsx @@ -1,8 +1,8 @@ import React, { useMemo } from 'react'; import { TokenSearchInput } from './TokenSearchInput'; -import { TokenTabEnum, TokenTabs } from './TokenTabs'; +import AddTokenEntry, { AddTokenEntryInst } from './AddTokenEntry'; import { useRabbySelector } from '@/ui/store'; -import { TokenList } from './TokenList'; +import { HomeTokenList } from './TokenList'; import useSortTokens from 'ui/hooks/useSortTokens'; import useSearchToken from '@/ui/hooks/useSearchToken'; import { @@ -12,8 +12,6 @@ import { import ProtocolList from './ProtocolList'; import { useQueryProjects } from 'ui/utils/portfolio'; import { Input } from 'antd'; -import { SummaryList } from './SummaryList'; -import { HistoryList } from './HisotryList'; import { useFilterProtocolList } from './useFilterProtocolList'; interface Props { @@ -48,9 +46,7 @@ export const AssetListContainer: React.FC = ({ blockedTokens, customizeTokens, } = useQueryProjects(currentAccount?.address, false, visible, isTestnet); - const [activeTab, setActiveTab] = React.useState( - TokenTabEnum.List - ); + const isEmptyAssets = !isTokensLoading && !tokenList.length && @@ -98,7 +94,6 @@ export const AssetListContainer: React.FC = ({ React.useEffect(() => { if (!visible) { - setActiveTab(TokenTabEnum.List); setSearch(''); inputRef.current?.setValue(''); inputRef.current?.focus(); @@ -106,6 +101,8 @@ export const AssetListContainer: React.FC = ({ } }, [visible]); + const addTokenEntryRef = React.useRef(null); + if (isTokensLoading && !hasTokens) { return ; } @@ -132,40 +129,30 @@ export const AssetListContainer: React.FC = ({ }} className={isFocus || search ? 'w-[360px]' : 'w-[160px]'} /> - {isFocus || search ? null : ( - - )} + {isFocus || search ? null : } {isTokensLoading || isSearching ? ( ) : (
- {(activeTab === TokenTabEnum.List || search) && ( - - )} - {activeTab === TokenTabEnum.Summary && !search && ( - - )} - {activeTab === TokenTabEnum.History && !search && } + { + addTokenEntryRef.current?.startAddToken(); + }} + isSearch={!!search} + isNoResults={isNoResults} + blockedTokens={blockedTokens} + customizeTokens={customizeTokens} + isTestnet={isTestnet} + />
)}
{isPortfoliosLoading ? ( diff --git a/src/ui/views/CommonPopup/AssetList/CustomAssetList/AddCustomTokenPopup.tsx b/src/ui/views/CommonPopup/AssetList/CustomAssetList/AddCustomTokenPopup.tsx new file mode 100644 index 00000000000..4df4f17bada --- /dev/null +++ b/src/ui/views/CommonPopup/AssetList/CustomAssetList/AddCustomTokenPopup.tsx @@ -0,0 +1,431 @@ +/** eslint-enable react-hooks/exhaustive-deps */ +import IconUnknown from '@/ui/assets/token-default.svg'; +import { Popup } from '@/ui/component'; +import ChainSelectorModal from '@/ui/component/ChainSelector/Modal'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import { formatAmount, useWallet } from '@/ui/utils'; +import { findChain, getChainList } from '@/utils/chain'; +import { CHAINS_ENUM } from '@debank/common'; +import { useRequest, useSetState } from 'ahooks'; +import { Button, Form, Input, Spin, message } from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { Loading3QuartersOutlined } from '@ant-design/icons'; +import { ReactComponent as RcIconDown } from '@/ui/assets/dashboard/portfolio/cc-down.svg'; +import { ReactComponent as RcIconCheck } from '@/ui/assets/dashboard/portfolio/cc-check.svg'; +import { ReactComponent as RcIconChecked } from '@/ui/assets/dashboard/portfolio/cc-checked.svg'; +import clsx from 'clsx'; +import { useThemeMode } from '@/ui/hooks/usePreference'; +import { + useOperateCustomToken, + useFindCustomToken, + useIsTokenAddedLocally, +} from '@/ui/hooks/useSearchToken'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { AbstractPortfolioToken } from '@/ui/utils/portfolio/types'; + +interface Props { + visible?: boolean; + onClose?(): void; + onConfirm?: ( + addedInfo: { + token: TokenItem; + portofolioToken?: AbstractPortfolioToken | null; + } | null + ) => void; +} + +const Wraper = styled.div` + .ant-form-item { + margin-bottom: 16px; + } + .ant-form-item-label > label { + color: var(--r-neutral-body, #3e495e); + font-size: 13px; + line-height: 16px; + } + + .ant-input { + height: 52px; + width: 100%; + margin-left: auto; + margin-right: auto; + background: transparent; + border: 1px solid var(--r-neutral-line, #d3d8e0); + border-radius: 6px; + + color: var(--r-neutral-title1, #192945); + font-size: 15px; + font-weight: 500; + + &:focus { + border-color: var(--r-blue-default, #7084ff); + } + + &::placeholder { + font-size: 14px; + font-weight: 400; + } + } + .ant-input[disabled] { + background: var(--r-neutral-card2, #f2f4f7); + border-color: transparent; + &:hover { + border-color: transparent; + } + } + .ant-form-item-has-error .ant-input, + .ant-form-item-has-error .ant-input:hover { + border: 1px solid var(--r-red-default, #e34935); + } + + .ant-form-item-explain.ant-form-item-explain-error { + color: var(--r-red-default, #e34935); + font-size: 13px; + line-height: 16px; + min-height: 16px; + } +`; + +const Footer = styled.div` + height: 76px; + border-top: 0.5px solid var(--r-neutral-line, rgba(255, 255, 255, 0.1)); + background: var(--r-neutral-card-1, rgba(255, 255, 255, 0.06)); + padding: 16px 20px; + display: flex; + justify-content: space-between; + gap: 16px; + width: 100%; + position: absolute; + left: 0; + right: 0; + bottom: 0; +`; + +/** + * @description now this popup only server mainnet chains' token list + */ +export const AddCustomTokenPopup = ({ visible, onClose, onConfirm }: Props) => { + const wallet = useWallet(); + const [chainSelectorState, setChainSelectorState] = useSetState<{ + visible: boolean; + chain: CHAINS_ENUM | null; + }>({ + visible: false, + chain: getChainList('mainnet')?.[0]?.enum || null, + }); + + const chain = findChain({ enum: chainSelectorState.chain }); + const [tokenId, setTokenId] = useState(''); + + const [checked, setChecked] = useState(false); + const { t } = useTranslation(); + const [form] = useForm(); + + const { + tokenList, + resetSearchResult, + searchCustomToken, + } = useFindCustomToken(); + + const { runAsync: doSearch, loading: isSearchingToken, error } = useRequest( + async () => { + if (!chain?.id || !tokenId) { + return null; + } + + const currentAccount = await wallet.getCurrentAccount(); + setChecked(false); + form.setFields([ + { + name: 'address', + errors: [], + }, + ]); + + await searchCustomToken({ + address: currentAccount!.address, + chainServerId: chain.serverId, + q: tokenId, + }).then((lists) => { + if (!lists?.tokenList.length) { + form.setFields([ + { + name: 'address', + errors: [t('page.dashboard.assets.AddMainnetToken.notFound')], + }, + ]); + } + }); + }, + { + manual: true, + + onError: (e) => { + form.setFields([ + { + name: 'address', + errors: [t('page.dashboard.assets.AddMainnetToken.notFound')], + }, + ]); + }, + } + ); + + useEffect(() => { + if (tokenId) { + doSearch(); + } + }, [chain?.serverId, tokenId]); + + const token = useMemo(() => tokenList?.[0], [tokenList]); + // const { isLocal: isLocalToken } = useIsTokenAddedLocally(token); + + const { addToken } = useOperateCustomToken(); + + const { runAsync: runAddToken, loading: isSubmitting } = useRequest( + async () => { + if (!token || !chain?.id || !tokenId) { + return null; + } + const portofolioToken = (await addToken(token)) || null; + + return { + token, + portofolioToken, + }; + }, + { + manual: true, + } + ); + + const handleConfirm = useCallback(async () => { + try { + const addedInfo = await runAddToken(); + onConfirm?.(addedInfo); + } catch (e) { + message.error(e?.message); + } + }, [runAddToken, onConfirm]); + + useEffect(() => { + if (!visible) { + setChainSelectorState({ + visible: false, + chain: getChainList('mainnet')?.[0]?.enum || null, + }); + resetSearchResult(); + setTokenId(''); + setChecked(false); + form.resetFields(); + } + }, [visible, resetSearchResult]); + + const inputRef = useRef(null); + useEffect(() => { + if (visible) { + inputRef.current?.focus(); + } + }, [visible]); + + const { isDarkTheme } = useThemeMode(); + + return ( + <> + + {t('page.dashboard.assets.AddMainnetToken.title')} +
+ } + maskStyle={ + isDarkTheme + ? { + backgroundColor: 'transparent', + } + : undefined + } + > + +
+ +
{ + setChainSelectorState({ + visible: true, + }); + }} + > + {!chain ? ( +
+
+ {t('page.dashboard.assets.AddMainnetToken.selectChain')} +
+
+ +
+
+ ) : ( +
+ +
+ {chain?.name} +
+
+ +
+
+ )} +
+
+ + { + setTokenId(e.target.value); + }} + autoComplete="off" + /> + + {isSearchingToken ? ( +
+ {' '} + {t('page.dashboard.assets.AddMainnetToken.searching')} +
+ ) : ( + <> + {token && !error ? ( + +
{ + // if (isLocalToken) return; + setChecked((v) => !v); + }} + className={clsx( + 'flex items-center gap-[12px] rounded-[6px] cursor-pointer', + 'bg-r-neutral-card2 min-h-[52px] px-[16px] py-[14px]', + 'border-[1px] border-transparent', + checked && 'border-rabby-blue-default' + // isLocalToken && 'opacity-60 cursor-not-allowed' + )} + > +
+ + + {chain?.name} + +
+
+ {formatAmount(token.amount || 0)} {token.symbol} +
+ {checked ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ ) : null} + + )} +
+
+ + +
+
+ + { + setChainSelectorState({ + visible: false, + }); + }} + onChange={(value) => { + setChainSelectorState({ + visible: false, + chain: value, + }); + }} + /> + + ); +}; diff --git a/src/ui/views/CommonPopup/AssetList/CustomTestnetAssetList/AddCustomTestnetTokenPopup.tsx b/src/ui/views/CommonPopup/AssetList/CustomTestnetAssetList/AddCustomTestnetTokenPopup.tsx index 6aa8780b2be..e744be11fb0 100644 --- a/src/ui/views/CommonPopup/AssetList/CustomTestnetAssetList/AddCustomTestnetTokenPopup.tsx +++ b/src/ui/views/CommonPopup/AssetList/CustomTestnetAssetList/AddCustomTestnetTokenPopup.tsx @@ -8,7 +8,7 @@ import { CHAINS_ENUM } from '@debank/common'; import { useRequest, useSetState } from 'ahooks'; import { Button, Form, Input, Spin, message } from 'antd'; import { useForm } from 'antd/lib/form/Form'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { Loading3QuartersOutlined } from '@ant-design/icons'; @@ -17,7 +17,6 @@ import { ReactComponent as RcIconCheck } from '@/ui/assets/dashboard/portfolio/c import { ReactComponent as RcIconChecked } from '@/ui/assets/dashboard/portfolio/cc-checked.svg'; import clsx from 'clsx'; import { useThemeMode } from '@/ui/hooks/usePreference'; - interface Props { visible?: boolean; onClose?(): void; @@ -50,6 +49,11 @@ const Wraper = styled.div` &:focus { border-color: var(--r-blue-default, #7084ff); } + + &::placeholder { + font-size: 14px; + font-weight: 400; + } } .ant-input[disabled] { background: var(--r-neutral-card2, #f2f4f7); @@ -177,6 +181,13 @@ export const AddCustomTestnetTokenPopup = ({ } }, [visible]); + const inputRef = useRef(null); + useEffect(() => { + if (visible) { + inputRef.current?.focus(); + } + }, [visible]); + const { isDarkTheme } = useThemeMode(); return ( @@ -256,6 +267,11 @@ export const AddCustomTestnetTokenPopup = ({ name="address" > { setTokenId(e.target.value); }} @@ -343,6 +359,7 @@ export const AddCustomTestnetTokenPopup = ({ { setChainSelectorState({ diff --git a/src/ui/views/CommonPopup/AssetList/CustomTestnetTokenDetailPopup/CustomTestnetTokenDetail.tsx b/src/ui/views/CommonPopup/AssetList/CustomTestnetTokenDetailPopup/CustomTestnetTokenDetail.tsx index 77ed8ce6a67..0aab86fd99a 100644 --- a/src/ui/views/CommonPopup/AssetList/CustomTestnetTokenDetailPopup/CustomTestnetTokenDetail.tsx +++ b/src/ui/views/CommonPopup/AssetList/CustomTestnetTokenDetailPopup/CustomTestnetTokenDetail.tsx @@ -159,6 +159,7 @@ export const CustomTestnetTokenDetail = ({ type="primary" size="large" disabled + className="wrapped-button w-[114px]" style={{ width: 114, }} diff --git a/src/ui/views/CommonPopup/AssetList/CustomizedButton.tsx b/src/ui/views/CommonPopup/AssetList/CustomizedButton.tsx index e37bc4f29fa..49254caa404 100644 --- a/src/ui/views/CommonPopup/AssetList/CustomizedButton.tsx +++ b/src/ui/views/CommonPopup/AssetList/CustomizedButton.tsx @@ -4,13 +4,13 @@ import { useRabbySelector } from '@/ui/store'; import useSortToken from '@/ui/hooks/useSortTokens'; import { useTranslation } from 'react-i18next'; -interface Props { - onClickLink: () => void; +type Props = { + onClickButton: () => void; isTestnet: boolean; -} +}; export const CustomizedButton: React.FC = ({ - onClickLink, + onClickButton, isTestnet, }) => { const { customize } = useRabbySelector((store) => @@ -22,10 +22,10 @@ export const CustomizedButton: React.FC = ({ return ( ); }; diff --git a/src/ui/views/CommonPopup/AssetList/ProtocolList.tsx b/src/ui/views/CommonPopup/AssetList/ProtocolList.tsx index b7b881b37a5..a70d72ab8b8 100644 --- a/src/ui/views/CommonPopup/AssetList/ProtocolList.tsx +++ b/src/ui/views/CommonPopup/AssetList/ProtocolList.tsx @@ -65,7 +65,6 @@ const ProtocolItemWrapper = styled.div` font-size: 13px; line-height: 15px; color: var(--r-neutral-title-1, #192945); - margin-left: 8px; } .net-worth { font-weight: 500; @@ -134,7 +133,7 @@ const ProtocolItem = ({ isShowChainTooltip={true} />
{ evt.stopPropagation(); openInTab(protocol.site_url, false); diff --git a/src/ui/views/CommonPopup/AssetList/TokenList.tsx b/src/ui/views/CommonPopup/AssetList/TokenList.tsx index 4f16b6c62dc..ef7873123c1 100644 --- a/src/ui/views/CommonPopup/AssetList/TokenList.tsx +++ b/src/ui/views/CommonPopup/AssetList/TokenList.tsx @@ -13,15 +13,17 @@ export interface Props { list?: TokenItemProps['item'][]; isSearch: boolean; onFocusInput: () => void; + onOpenAddEntryPopup: () => void; isNoResults?: boolean; blockedTokens?: TokenItemProps['item'][]; customizeTokens?: TokenItemProps['item'][]; isTestnet: boolean; } -export const TokenList: React.FC = ({ +export const HomeTokenList = ({ list, onFocusInput, + onOpenAddEntryPopup, isSearch, isNoResults, blockedTokens, @@ -68,7 +70,10 @@ export const TokenList: React.FC = ({
{!isSearch && hasList && (
- +
)} diff --git a/src/ui/views/CommonPopup/AssetList/TokenTabs.tsx b/src/ui/views/CommonPopup/AssetList/TokenTabs.tsx deleted file mode 100644 index d02a2bff906..00000000000 --- a/src/ui/views/CommonPopup/AssetList/TokenTabs.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Radio } from 'antd'; -import React from 'react'; -import { ReactComponent as TabHistorySVG } from '@/ui/assets/dashboard/tab-history.svg'; -import { ReactComponent as TabListSVG } from '@/ui/assets/dashboard/tab-list.svg'; -import { ReactComponent as TabSummarySVG } from '@/ui/assets/dashboard/tab-summary.svg'; -import styled from 'styled-components'; - -export enum TokenTabEnum { - List = 'list', - Summary = 'summary', - History = 'history', -} - -const TabsStyled = styled(Radio.Group)` - background: var(--r-neutral-card-2, #f2f4f7); - border-radius: 6px; - padding: 2px; - - .ant-radio { - display: none; - - & + span { - padding: 0; - } - } - - .ant-radio-wrapper { - border-radius: 4px; - padding: 6px 8px; - margin-right: 0; - color: var(--r-neutral-foot, #6a7587); - - &:after { - display: none; - } - } - - .ant-radio-wrapper-checked { - background: var(--r-neutral-card-1, #fff); - color: var(--r-neutral-title-1, rgba(247, 250, 252, 1)); - } -`; - -export interface Props { - activeTab?: TokenTabEnum; - onTabChange?: (tab: TokenTabEnum) => void; -} - -export const TokenTabs: React.FC = ({ - activeTab = TokenTabEnum.List, - onTabChange, -}) => { - return ( -
- , value: TokenTabEnum.List }, - { label: , value: TokenTabEnum.Summary }, - { label: , value: TokenTabEnum.History }, - ]} - value={activeTab} - onChange={(e) => onTabChange?.(e.target.value)} - defaultValue={TokenTabEnum.List} - /> -
- ); -}; diff --git a/src/ui/views/CommonPopup/AssetList/components/TokenButton.tsx b/src/ui/views/CommonPopup/AssetList/components/TokenButton.tsx index cd74152589c..8a23ca98760 100644 --- a/src/ui/views/CommonPopup/AssetList/components/TokenButton.tsx +++ b/src/ui/views/CommonPopup/AssetList/components/TokenButton.tsx @@ -7,33 +7,106 @@ import { ReactComponent as EmptySVG } from '@/ui/assets/dashboard/empty.svg'; import { TokenTable } from './TokenTable'; import { useCommonPopupView } from '@/ui/utils'; import { useTranslation } from 'react-i18next'; +import { Button } from 'antd'; -export interface Props { +interface TokenButtonPopupProps { + visible?: boolean; + onClose?: () => void; label: string; - onClickLink: () => void; + onClickButton?: () => void; tokens?: AbstractPortfolioToken[]; + onClickLink?: () => void; linkText?: string; + buttonText?: string; description?: string; hiddenSubTitle?: boolean; } +export function SpecialTokenListPopup({ + visible, + onClose, + label, + tokens, + onClickLink, + linkText, + onClickButton, + buttonText, + description, + hiddenSubTitle, +}: TokenButtonPopupProps) { + const { t } = useTranslation(); + const len = tokens?.length ?? 0; + + return ( + + {!hiddenSubTitle && ( +
+ {t('page.dashboard.assets.tokenButton.subTitle')} +
+ )} + + +
{description}
+ {linkText && ( +
+ {linkText} +
+ )} + {buttonText && ( + + )} + + } + /> +
+ ); +} + +export type Props = TokenButtonPopupProps; + export const TokenButton: React.FC = ({ label, tokens, onClickLink, linkText, + onClickButton, + buttonText, description, hiddenSubTitle, }) => { const { visible: commonPopupVisible } = useCommonPopupView(); const [visible, setVisible] = React.useState(false); const len = tokens?.length ?? 0; - const { t } = useTranslation(); const handleClickLink = React.useCallback(() => { setVisible(false); - onClickLink(); - }, []); + onClickLink?.(); + }, [onClickLink]); + + const handleClickButton = React.useCallback(() => { + setVisible(false); + onClickButton?.(); + }, [onClickButton]); React.useEffect(() => { if (!commonPopupVisible) { @@ -58,36 +131,18 @@ export const TokenButton: React.FC = ({ - setVisible(false)} - title={`${len} ${label}`} - isSupportDarkMode - > - {!hiddenSubTitle && ( -
- {t('page.dashboard.assets.tokenButton.subTitle')} -
- )} - - -
{description}
-
- {linkText} -
- - } - /> -
+ label={label} + tokens={tokens} + onClickLink={handleClickLink} + linkText={linkText} + onClickButton={handleClickButton} + buttonText={buttonText} + description={description} + hiddenSubTitle={hiddenSubTitle} + /> ); }; diff --git a/src/ui/views/CommonPopup/AssetList/icons/add-entry-cc.svg b/src/ui/views/CommonPopup/AssetList/icons/add-entry-cc.svg new file mode 100644 index 00000000000..9fe8f2cb8d9 --- /dev/null +++ b/src/ui/views/CommonPopup/AssetList/icons/add-entry-cc.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/ui/views/Dashboard/components/TokenDetailPopup/CustomizedButton.tsx b/src/ui/views/Dashboard/components/TokenDetailPopup/CustomizedButton.tsx index bdbc6b4acab..554957d1948 100644 --- a/src/ui/views/Dashboard/components/TokenDetailPopup/CustomizedButton.tsx +++ b/src/ui/views/Dashboard/components/TokenDetailPopup/CustomizedButton.tsx @@ -29,7 +29,7 @@ const SwitchStyled = styled(Switch)` } `; -export const CustomizedButton: React.FC = ({ +export const CustomizedSwitch: React.FC = ({ selected, onOpen, onClose, diff --git a/src/ui/views/Dashboard/components/TokenDetailPopup/TokenDetail.tsx b/src/ui/views/Dashboard/components/TokenDetailPopup/TokenDetail.tsx index 1ed69b39ad6..c2ce5045c84 100644 --- a/src/ui/views/Dashboard/components/TokenDetailPopup/TokenDetail.tsx +++ b/src/ui/views/Dashboard/components/TokenDetailPopup/TokenDetail.tsx @@ -24,7 +24,7 @@ import { CHAINS } from 'consts'; import { ellipsisOverflowedText } from 'ui/utils'; import { getTokenSymbol } from '@/ui/utils/token'; import { SWAP_SUPPORT_CHAINS } from '@/constant'; -import { CustomizedButton } from './CustomizedButton'; +import { CustomizedSwitch } from './CustomizedButton'; import { BlockedButton } from './BlockedButton'; import { useRabbySelector } from '@/ui/store'; import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; @@ -218,7 +218,7 @@ const TokenDetail = ({ onClose={() => removeToken(tokenWithAmount)} /> ) : ( - addToken(tokenWithAmount)} onClose={() => removeToken(tokenWithAmount)} @@ -269,6 +269,7 @@ const TokenDetail = ({ size="large" onClick={goToSwap} disabled={!tokenSupportSwap} + className="w-[114px]" style={{ width: 114, }} diff --git a/src/ui/views/Dashboard/components/TokenDetailPopup/index.tsx b/src/ui/views/Dashboard/components/TokenDetailPopup/index.tsx index 68ba8e029e2..7985a93edc3 100644 --- a/src/ui/views/Dashboard/components/TokenDetailPopup/index.tsx +++ b/src/ui/views/Dashboard/components/TokenDetailPopup/index.tsx @@ -98,7 +98,7 @@ export const TokenDetailPopup = ({ onClose={onClose} canClickToken={canClickToken} hideOperationButtons={hideOperationButtons} - > + /> )} ); diff --git a/src/utils/chain.ts b/src/utils/chain.ts index d0dcb4f63df..be474411409 100644 --- a/src/utils/chain.ts +++ b/src/utils/chain.ts @@ -70,7 +70,7 @@ export const findChain = (params: { serverId?: string | null; hex?: string | null; networkId?: string | null; -}): Chain | TestnetChain | undefined => { +}): Chain | TestnetChain | null | undefined => { const { enum: chainEnum, id, serverId, hex, networkId } = params; if (chainEnum && chainEnum.startsWith('CUSTOM_')) { return findChain({ @@ -85,6 +85,7 @@ export const findChain = (params: { item.hex === hex || item.network === networkId ); + return chain; };