Skip to content

Commit

Permalink
Manage Token
Browse files Browse the repository at this point in the history
Filter Token
Token Info
Add Token
  • Loading branch information
Ivan-Mahda committed Dec 20, 2024
1 parent e086040 commit 5d559af
Show file tree
Hide file tree
Showing 21 changed files with 319 additions and 36 deletions.
5 changes: 5 additions & 0 deletions packages/browser-wallet/src/assets/svgX/info-black.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MakeOptional } from 'wallet-common-helpers';

export const TOKENS_PAGE_SIZE = 20;

type TokenWithPageID = MakeOptional<ContractTokenDetails, 'metadata'> & {
export type TokenWithPageID = MakeOptional<ContractTokenDetails, 'metadata'> & {
pageId: number;
};

Expand Down
6 changes: 6 additions & 0 deletions packages/browser-wallet/src/popup/popupX/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ export const relativeRoutes = {
path: 'manageTokenList',
addToken: {
path: 'addToken',
contractIndex: {
path: ':contractIndex',
details: {
path: 'details',
},
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import React, { useCallback, useEffect, useRef } from 'react';
import Page from '@popup/popupX/shared/Page';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Page from '@popup/popupX/shared/Page';
import Text from '@popup/popupX/shared/Text';
import FormSearch from '@popup/popupX/shared/Form/Search';
import { useForm } from '@popup/popupX/shared/Form';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { grpcClientAtom } from '@popup/store/settings';
import { confirmCIS2Contract, ContractDetails } from '@shared/utils/token-helpers';
import { fetchTokensConfigure, FetchTokensResponse } from '@popup/pages/Account/Tokens/ManageTokens/utils';
import {
fetchTokensConfigure,
FetchTokensResponse,
TokenWithPageID,
} from '@popup/pages/Account/Tokens/ManageTokens/utils';
import { selectedAccountAtom } from '@popup/store/account';
import { contractDetailsAtom, contractTokensAtom } from '@popup/pages/Account/Tokens/ManageTokens/state';
import { SubmitHandler } from 'react-hook-form';
Expand All @@ -16,25 +21,40 @@ import { ContractAddress } from '@concordium/web-sdk';
import { logWarningMessage } from '@shared/utils/log-helpers';
import Form from '@popup/popupX/shared/Form/Form';
import TokenList from '@popup/popupX/shared/TokenList';
import { LoaderInline } from '@popup/popupX/shared/Loader';
import Button from '@popup/popupX/shared/Button';
import { absoluteRoutes } from '@popup/popupX/constants/routes';
import { currentAccountTokensAtom } from '@popup/store/token';
import { TokenIdAndMetadata } from '@shared/storage/types';
import { useGenericToast } from '@popup/popupX/shared/utils/hooks';

const VALIDATE_INDEX_DELAY_MS = 500;

type FormValues = {
contractIndex: string;
tokenId: string;
};

// ToDo page UI need full rework
// ToDo update token infinity-load, check, add
function AddToken({ account }: { account: string }) {
const { t } = useTranslation('x', { keyPrefix: 'mangeTokens' });
const params = useParams();
const nav = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [checkedTokens, setCheckedTokens] = useState<TokenWithPageID[]>([]);
const [filteredTokens, setFilteredTokens] = useState<TokenWithPageID[]>([]);
const toast = useGenericToast();
const form = useForm<FormValues>({
defaultValues: { contractIndex: '' },
defaultValues: { contractIndex: params.contractIndex || '' },
});
const contractIndexValue = form.watch('contractIndex');
const tokenIdValue = form.watch('tokenId');
const client = useAtomValue(grpcClientAtom);
const validContract = useRef<{ details: ContractDetails; tokens: FetchTokensResponse } | undefined>();

const setContractDetails = useSetAtom(contractDetailsAtom);
const [, updateTokens] = useAtom(contractTokensAtom);
const [, setAccountTokens] = useAtom(currentAccountTokensAtom);
const onSubmit: SubmitHandler<FormValues> = async () => {
if (validContract.current === undefined) {
throw new Error('Expected contract details');
Expand Down Expand Up @@ -62,10 +82,12 @@ function AddToken({ account }: { account: string }) {
return t('indexMax');
}

setIsLoading(true);
let instanceInfo;
try {
instanceInfo = await client.getInstanceInfo(ContractAddress.create(index));
} catch {
setIsLoading(false);
return t('noContractFound');
}

Expand All @@ -74,6 +96,7 @@ function AddToken({ account }: { account: string }) {
const error = await confirmCIS2Contract(client, cd);

if (error !== undefined) {
setIsLoading(false);
return error;
}

Expand All @@ -92,10 +115,13 @@ function AddToken({ account }: { account: string }) {
}

if (response.tokens.length === 0) {
setIsLoading(false);
return t('noTokensError');
}

validContract.current = { details: cd, tokens: response };
setFilteredTokens(response.tokens);
setIsLoading(false);
return true;
},
VALIDATE_INDEX_DELAY_MS,
Expand All @@ -108,35 +134,77 @@ function AddToken({ account }: { account: string }) {
validContract.current = undefined;
}, [contractIndexValue]);

useEffect(() => {
setFilteredTokens([
...(validContract.current?.tokens.tokens || []).filter(
({ id, metadata }) => id.includes(tokenIdValue) || (metadata?.name || '').includes(tokenIdValue)
),
]);
}, [tokenIdValue]);

const navToTokenDetails = (token: TokenWithPageID, contractIndex: string) =>
nav(
absoluteRoutes.home.manageTokenList.addToken.contractIndex.details.path.replace(
':contractIndex',
contractIndex
),
{ state: { token } }
);

const checkToken = (checked: boolean, token: TokenWithPageID) => {
if (checked) {
setCheckedTokens([...checkedTokens, token]);
} else {
setCheckedTokens(checkedTokens.filter((c) => c.id !== token.id));
}
};

const setTokens = () => {
setAccountTokens({ contractIndex: contractIndexValue, newTokens: checkedTokens as TokenIdAndMetadata[] });
toast('Token list updated', `Update count ${checkedTokens.length}`);
nav(absoluteRoutes.home.manageTokenList.path);
};

const haveTokens = validContract.current?.tokens.tokens.length || 0;

return (
<Page className="add-token-x">
<Page.Top heading={t('addToken')} />
<Page.Main>
<Text.MainRegular>{t('enterContract')}</Text.MainRegular>
<Form formMethods={form} onSubmit={onSubmit}>
{(f) => (
<FormSearch
autoFocus
control={f.control}
label={t('contractIndex')}
name="contractIndex"
rules={{
required: t('indexRequired'),
validate: validateIndex,
}}
/>
<>
<FormSearch
autoFocus
control={f.control}
label={t('contractIndex')}
name="contractIndex"
rules={{
required: t('indexRequired'),
validate: validateIndex,
}}
/>
{haveTokens > 4 && <FormSearch control={f.control} label={t('tokenName')} name="tokenId" />}
</>
)}
</Form>
{isLoading && !haveTokens && <LoaderInline />}
<TokenList>
{validContract.current?.tokens.tokens.map((token) => (
{filteredTokens.map((token) => (
<TokenList.Item
key={token.metadata?.description}
key={token.id}
thumbnail={token.metadata?.display?.url}
symbol={token.metadata?.description}
symbol={token.metadata?.name}
onClick={() => navToTokenDetails(token, contractIndexValue)}
onSelect={(checked: boolean) => {
checkToken(checked, token);
}}
/>
))}
</TokenList>
</Page.Main>
<Page.Footer>{!!haveTokens && <Button.Main label={t('addSelected')} onClick={setTokens} />}</Page.Footer>
</Page>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { useFlattenedAccountTokens } from '@popup/pages/Account/Tokens/utils';
import { getMetadataUnique } from '@shared/utils/token-helpers';
import { withSelectedCredential } from '@popup/popupX/shared/utils/hoc';
import TokenList from '@popup/popupX/shared/TokenList';
import { useUpdateAtom } from 'jotai/utils';
import { removeTokenFromCurrentAccountAtom } from '@popup/store/token';
import { useGenericToast } from '@popup/popupX/shared/utils/hooks';

/** Hook loading every fungible token added to the account. */
function useAccountFungibleTokens(account: WalletCredential) {
Expand All @@ -19,10 +22,17 @@ function useAccountFungibleTokens(account: WalletCredential) {

function ManageTokenList({ credential }: { credential: WalletCredential }) {
const { t } = useTranslation('x', { keyPrefix: 'mangeTokens' });
const remove = useUpdateAtom(removeTokenFromCurrentAccountAtom);
const nav = useNavigate();
const toast = useGenericToast();
const navToAddToken = () => nav(relativeRoutes.home.manageTokenList.addToken.path);
const tokens = useAccountFungibleTokens(credential);

const removeToken = (contractIndex: string, id: string, name?: string) => {
remove({ contractIndex, tokenId: id });
toast(t('removed'), name);
};

return (
<Page className="manage-token-list-x">
<Page.Top heading={t('manageTokenList')}>
Expand All @@ -31,7 +41,12 @@ function ManageTokenList({ credential }: { credential: WalletCredential }) {
<Page.Main>
<TokenList>
{tokens.map((token) => (
<TokenList.Item thumbnail={token.metadata.thumbnail?.url} symbol={token.metadata.symbol} />
<TokenList.ItemHide
key={`${token.contractIndex}.${token.id}`}
thumbnail={token.metadata.thumbnail?.url}
symbol={token.metadata.symbol}
onClick={() => removeToken(token.contractIndex, token.id, token?.metadata?.name)}
/>
))}
</TokenList>
</Page.Main>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@
}

.add-token-x {
.text__main_regular {
margin-bottom: rem(20px);
}

.form-search {
margin-top: rem(24px);
margin-top: rem(4px);
}

.token-list-x {
margin-top: rem(8px);
overflow: auto;
max-height: rem(200px);

&::-webkit-scrollbar {
display: none;
}
}

.loader-x {
margin: rem(120px) auto 0 auto;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Page from '@popup/popupX/shared/Page';
import Card from '@popup/popupX/shared/Card';
import Img from '@popup/shared/Img';
import Text from '@popup/popupX/shared/Text';
import Button from '@popup/popupX/shared/Button';
import { TokenWithPageID } from '@popup/pages/Account/Tokens/ManageTokens/utils';
import FullscreenNotice from '@popup/popupX/shared/FullscreenNotice';
import { useCopyToClipboard } from '@popup/popupX/shared/utils/hooks';
import Copy from '@assets/svgX/copy.svg';
import Notebook from '@assets/svgX/notebook.svg';

const SUB_INDEX = 0;

interface Location {
state: {
token: TokenWithPageID;
};
}

export default function SearchTokenDetails() {
const { t } = useTranslation('x', { keyPrefix: 'tokenDetails' });
const [isOpen, setIsOpen] = React.useState(false);
const copyToClipboard = useCopyToClipboard();
const params = useParams();
const location = useLocation() as Location;
const { metadata = {}, id } = location.state.token || { metadata: {} };
const { thumbnail, display, symbol, name, description, decimals } = metadata;

return (
<>
<FullscreenNotice open={isOpen} onClose={() => setIsOpen(false)}>
<Page>
<Page.Top heading={t('rawMetadata')}>
<Button.Icon
icon={<Copy />}
onClick={() => copyToClipboard(JSON.stringify(metadata, null, 2))}
/>
</Page.Top>
<Page.Main>
<Card>
{Object.entries(metadata).map(([k, v]) => (
<Card.RowDetails key={k} title={k} value={JSON.stringify(v)} />
))}
</Card>
</Page.Main>
</Page>
</FullscreenNotice>
<Page className="token-details-x">
<Page.Main>
<Card>
<div className="token-details-x__token">
<Img
className="token-icon"
src={thumbnail?.url || display?.url || ''}
alt={symbol}
withDefaults
/>
<Text.Main>{name}</Text.Main>
</div>
<Card.RowDetails title={t('description')} value={description} />
{decimals && <Card.RowDetails title={t('decimals')} value={`0 - ${decimals}`} />}
{id && <Card.RowDetails title={t('tokenId')} value={id} />}
<Card.RowDetails title={t('indexSubindex')} value={`${params.contractIndex}, ${SUB_INDEX}`} />
</Card>
<Button.IconText
icon={<Notebook />}
label={t('showRawMetadata')}
onClick={() => {
setIsOpen(true);
}}
/>
</Page.Main>
</Page>
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const t = {
manageTokenList: 'Manage token list',
addToken: 'Add token',
addSelected: 'Add selected tokens',
removed: 'Token removed',
enterContract: 'Enter a contract index to select tokens from.',
contractIndex: 'Contract index',
tokenName: 'Token name',
invalidIndex: 'Contract index must be an integer',
indexMax: 'Contract index can not exceed 18446744073709551615',
noContractFound: 'No contract found on index',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as ManageTokenList } from './ManageTokenList';
export { default as AddToken } from './AddToken';
export { default as SearchTokenDetails } from './SearchTokenDetails';
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
margin-top: rem(24px);
align-self: flex-start;

&:last-child {
&:last-child:not(:only-of-type) {
margin-top: rem(12px);

.label__main {
Expand Down
Loading

0 comments on commit 5d559af

Please sign in to comment.