diff --git a/src/constants/chains.ts b/src/constants/chains.ts index 289107ba..cef0d224 100755 --- a/src/constants/chains.ts +++ b/src/constants/chains.ts @@ -8,8 +8,12 @@ import { } from '@/services/useink/chains' import { ArrayOneOrMore, ChainExtended } from '@/types' import { CHAINS_IMG_PATH } from './images' +import { RpcUrl } from '@/services/useink/chains/data/types' export const DEFAULT_DECIMALS = 12 +export const OPTION_FOR_CUSTOM_NETWORK = 'custom' +export const OPTION_FOR_ADD_CUSTOM_NETWORK = 'add-custom' +export const OPTION_FOR_EDIT_CUSTOM_NETWORK = 'edit-custom' export const CHAINS: ArrayOneOrMore = [ Astar, @@ -18,30 +22,54 @@ export const CHAINS: ArrayOneOrMore = [ RococoContractsTestnet ] -export const CHAINS_ALLOWED: ChainExtended[] = CHAINS.map(chain => { +export const CHAINS_ALLOWED = CHAINS.map(chain => { return { ...chain, logo: { - src: `${CHAINS_IMG_PATH}${chain.id ? chain.id : 'custom'}.png`, + src: `${CHAINS_IMG_PATH}${ + chain.id ? chain.id : OPTION_FOR_CUSTOM_NETWORK + }.png`, alt: `${chain.name} img` } } }) -export const UNKNOWN_CHAIN = { - name: 'UNKNOWN', - id: 'unknown-network', - logo: { - src: `${CHAINS_IMG_PATH}custom.png`, - alt: `unknown chain img` +export const addNewChain = (chain: ChainExtended): ChainExtended[] => { + const newChains = [...CHAINS_ALLOWED, chain] + return newChains +} + +export const updateChain = (chains: ChainExtended[], chain: ChainExtended) => { + const chainIndex = CHAINS_ALLOWED.findIndex( + chain => chain.id === OPTION_FOR_CUSTOM_NETWORK + ) + chains[chainIndex] = chain + return chains +} + +export function createIChainWithRPCAndSave( + name: string, + rpc: RpcUrl +): ChainExtended { + const customChain: ChainExtended = { + id: OPTION_FOR_CUSTOM_NETWORK, + name: `Custom ${name}`, + account: '*25519', + rpcs: [rpc], + logo: { + src: `${CHAINS_IMG_PATH}${OPTION_FOR_CUSTOM_NETWORK}.png`, + alt: `custom img` + } } + return customChain } -export function getChain(chainId?: ChainId): ChainExtended { - if (!chainId) return UNKNOWN_CHAIN as ChainExtended +export function getChain(chainId?: ChainId) { + const chain = CHAINS_ALLOWED.find(_chain => _chain.id === chainId) + if (chain !== undefined) { + return chain + } - return ( - CHAINS_ALLOWED.find(_chain => _chain.id === chainId) ?? - (UNKNOWN_CHAIN as ChainExtended) - ) + const customChain = JSON.parse(localStorage.getItem('customChain') as string) + return customChain } diff --git a/src/constants/config.ts b/src/constants/config.ts index aa4658d3..30c30c50 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -1,9 +1,9 @@ -import { Chain } from '@/services/useink/chains' +import { Chain, ChainId } from '@/services/useink/chains' export const IS_PRODUCTION = process.env.NODE_ENV === ('production' as string) export const IS_DEVELOPMENT = process.env.NODE_ENV === ('development' as string) -export const DEFAULT_CHAIN: Chain['id'] = IS_DEVELOPMENT +export const DEFAULT_CHAIN: ChainId = IS_DEVELOPMENT ? 'shibuya-testnet' : 'astar' diff --git a/src/context/NetworkAccountsContext.tsx b/src/context/NetworkAccountsContext.tsx index 0a726586..1f3d111c 100644 --- a/src/context/NetworkAccountsContext.tsx +++ b/src/context/NetworkAccountsContext.tsx @@ -17,6 +17,8 @@ import { useLocalDbContext } from './LocalDbContext' import { ChainId } from '@/services/useink/chains' import { createNotImplementedWarning } from '@/utils/error' import { WalletConnectionEvents } from '@/domain' +import { OPTION_FOR_CUSTOM_NETWORK } from '@/constants/chains' +import { ChainExtended } from '@/types' type NetworkState = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'ERROR' export const OPTION_FOR_DISCONNECTING = 'disconnect' @@ -46,6 +48,7 @@ interface NetworkContextProps { setCurrentAccount: (account: WalletAccount) => void setCurrentWallet: (wallet: Wallet) => void setCurrentChain: (chain: ChainId) => void + setCustomChain: (chain: ChainExtended) => void connect: (walletName: string) => void disconnectWallet: () => void } @@ -58,6 +61,7 @@ export const NetworkAccountsContext = createContext({ setCurrentAccount: () => createNotImplementedWarning('setCurrentAccount'), setCurrentWallet: () => createNotImplementedWarning('setCurrentWallet'), setCurrentChain: () => createNotImplementedWarning('setCurrentChain'), + setCustomChain: () => createNotImplementedWarning('setCustomChain'), connect: () => createNotImplementedWarning('connect'), disconnectWallet: () => createNotImplementedWarning('disconnectWallet') }) @@ -75,8 +79,10 @@ export function NetworkAccountsContextProvider({ const [networkId, setNetworkId] = useState(DEFAULT_CHAIN) const loadNetworkConnected = useCallback(() => { - const networkSelected = networkRepository.getNetworkSelected() - + let networkSelected = networkRepository.getNetworkSelected() + if (networkSelected.id === OPTION_FOR_CUSTOM_NETWORK) { + networkSelected = networkRepository.getCustomChain() + } setNetworkId(networkSelected.id) }, [networkRepository]) @@ -122,6 +128,16 @@ export function NetworkAccountsContextProvider({ [networkRepository] ) + const setCustomChain = useCallback( + async (chain: ChainExtended) => { + networkRepository.setCustomChain(chain) + document.dispatchEvent( + new CustomEvent(WalletConnectionEvents.networkChanged) + ) + }, + [networkRepository] + ) + return ( => { + try { + const wsProvider = new WsProvider(networkSelected.rpcs[0]) + const apiInstance = await ApiPromise.create({ provider: wsProvider }) + return apiInstance + } catch (error) { + console.error('Error initializing API:', error) + } +} + export function useNetworkApi(): UseNetworkApi { const { networkConnected } = useNetworkAccountsContext() - const api = useApi(networkConnected) - const firstLoadCompleted = useDelay(5000) + const [api, setApi] = useState(undefined) + const fetchApi = useApi(networkConnected) + + useEffect(() => { + if (networkConnected) { + if (!fetchApi) { + ;(async () => { + const chain = getChain(networkConnected) + const apiInstance = await initializeApi(chain) + setApi(apiInstance) + })() + } + } + }, [networkConnected]) + + const firstLoadCompleted = useDelay(5000) return { - apiPromise: api?.api, + apiPromise: fetchApi?.api ?? api, network: networkConnected, - firstLoadCompleted: !!api?.api || firstLoadCompleted + firstLoadCompleted: !!api || firstLoadCompleted } } diff --git a/src/services/LocalStorageNetworkRepository.ts b/src/services/LocalStorageNetworkRepository.ts index f6f98d34..78e26246 100644 --- a/src/services/LocalStorageNetworkRepository.ts +++ b/src/services/LocalStorageNetworkRepository.ts @@ -12,6 +12,7 @@ type ReturnChainId = ChainId export class LocalStorageNetworkRepository implements INetworkRepository { private readonly storageKey = 'networkSelected' + private readonly customChainKey = 'customChain' getNetworkSelected(): ChainExtended { const result = getLocalStorageState( @@ -22,6 +23,19 @@ export class LocalStorageNetworkRepository implements INetworkRepository { return getChain(result) as ChainExtended } + getCustomChain(): ChainExtended { + const result = getLocalStorageState( + this.customChainKey, + DEFAULT_CHAIN + ) as ChainId + + return getChain(result) as ChainExtended + } + + setCustomChain(chain: ChainExtended): void { + setLocalStorageState(this.customChainKey, JSON.stringify(chain)) + } + setNetworkSelected(networkId: ReturnChainId): void { setLocalStorageState(this.storageKey, networkId) } diff --git a/src/services/useink/chains/data/testnet-chaindata.ts b/src/services/useink/chains/data/testnet-chaindata.ts index 2a4c4e9a..b2317d0c 100644 --- a/src/services/useink/chains/data/testnet-chaindata.ts +++ b/src/services/useink/chains/data/testnet-chaindata.ts @@ -16,3 +16,10 @@ export const ShibuyaTestnet: IChain<'shibuya-testnet'> = { subscanUrl: 'https://shibuya.subscan.io/', rpcs: ['wss://rpc.shibuya.astar.network', 'wss://shibuya-rpc.dwellir.com'] } as const + +export const Custom: IChain<'custom'> = { + id: 'custom', + name: 'Custom', + account: '*25519', + rpcs: [] +} as const diff --git a/src/types/chain.ts b/src/types/chain.ts index 9e79dbe2..f8caea9a 100644 --- a/src/types/chain.ts +++ b/src/types/chain.ts @@ -1,6 +1,7 @@ -import { Chain } from '@/services/useink/chains' +import { Chain, ChainId } from '@/services/useink/chains' export type ChainExtended = Chain & { + id: ChainId logo: { src: string alt: string diff --git a/src/view/components/ConfirmationDialog/index.tsx b/src/view/components/ConfirmationDialog/index.tsx index 41137538..61f76153 100644 --- a/src/view/components/ConfirmationDialog/index.tsx +++ b/src/view/components/ConfirmationDialog/index.tsx @@ -8,7 +8,6 @@ import { Typography, Box } from '@mui/material' -import { title } from 'process' const style = { backgroundColor: 'background.paper' @@ -36,7 +35,7 @@ function ConfirmationDialog({ - + {title} diff --git a/src/view/components/DeleteContractModal/index.tsx b/src/view/components/DeleteContractModal/index.tsx index f4e098e9..94736a5c 100644 --- a/src/view/components/DeleteContractModal/index.tsx +++ b/src/view/components/DeleteContractModal/index.tsx @@ -106,7 +106,6 @@ export function DeleteContractModal({ open, handleClose, contract }: Props) { > - ) diff --git a/src/view/components/ModalView/index.tsx b/src/view/components/ModalView/index.tsx new file mode 100644 index 00000000..5fded1b7 --- /dev/null +++ b/src/view/components/ModalView/index.tsx @@ -0,0 +1,89 @@ +import React, { ReactNode } from 'react' +import { Button, Typography, Box, Modal, IconButton } from '@mui/material' +import { ModalStyled, ModalTypography } from './styled' +import CloseIcon from '@mui/icons-material/Close' +interface ModalViewProps { + open: boolean + onClose: () => void + onFunction?: () => void + message?: string + title?: string + subTitle?: string + okBtn?: { text: string; validation: boolean } + children: ReactNode +} + +const MODAL_TITLE = 'Modal Title' +const MODAL_SUBTITLE = 'Modal Subtitle' +const OK_BTN = 'OK' + +function ModalView({ + open, + onClose, + onFunction, + title = MODAL_TITLE, + subTitle = MODAL_SUBTITLE, + okBtn = { text: OK_BTN, validation: false }, + children +}: ModalViewProps) { + return ( + + + + {title} + + + {subTitle} + + {children} + + + + + + theme.palette.grey[500] + }} + > + + + + + ) +} + +export default ModalView diff --git a/src/view/components/ModalView/styled.tsx b/src/view/components/ModalView/styled.tsx new file mode 100644 index 00000000..2c3289f9 --- /dev/null +++ b/src/view/components/ModalView/styled.tsx @@ -0,0 +1,62 @@ +import { + styled, + ListItemButton, + ListItemButtonProps, + Box, + BoxProps, + Typography, + TypographyProps, + Divider, + List, + ListProps +} from '@mui/material' + +export const ModalStyledList = styled(List)(() => ({ + margin: '0 auto', + width: '22rem', + + '&:hover': { + borderRadius: '1.8rem' + } +})) + +export const ModalStyledListItem = styled(ListItemButton)( + () => ({ + borderRadius: '1.8rem', + + '&:hover': { + borderRadius: '1.8rem', + backgroundColor: 'rgba(98, 98, 98, 0.26)' + } + }) +) + +export const ModalTypography = styled(Typography)(() => ({ + textAlign: 'left', + fontWeight: 'normal', + marginTop: '1rem', + marginBottom: '1.5rem' +})) + +export const ModalStyledDivider = styled(Divider)(() => ({ + margin: '1rem 0', + borderColor: 'rgba(255, 255, 255, 0.1)' +})) + +export const ModalStyled = styled(Box)(({}) => ({ + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 720, + minHeight: 320, + textAlign: 'justify', + backgroundColor: 'rgba(0, 0, 0, 1)', + border: '2px solid #000', + borderRadius: '2rem', + padding: '1rem 3rem', + boxShadow: '0px 4px 50px 0px rgba(255, 255, 255, 0.1);', + color: 'white', + display: 'flex', + flexDirection: 'column' +})) diff --git a/src/view/components/WalletConnectButton/NetworkSelect.tsx b/src/view/components/WalletConnectButton/NetworkSelect.tsx index 05422b31..9ff6984d 100644 --- a/src/view/components/WalletConnectButton/NetworkSelect.tsx +++ b/src/view/components/WalletConnectButton/NetworkSelect.tsx @@ -6,16 +6,33 @@ import { SelectChangeEvent, Stack, styled, - Avatar + Avatar, + Box } from '@mui/material' -import { CHAINS_ALLOWED, getChain } from '@/constants/chains' +import { + CHAINS_ALLOWED, + OPTION_FOR_ADD_CUSTOM_NETWORK, + OPTION_FOR_CUSTOM_NETWORK, + OPTION_FOR_EDIT_CUSTOM_NETWORK, + addNewChain, + createIChainWithRPCAndSave, + getChain, + updateChain +} from '@/constants/chains' import { ChainId } from '@/services/useink/chains/types' import ConfirmationDialog from '../ConfirmationDialog' import { useModalBehaviour } from '@/hooks/useModalBehaviour' import { useCompareCurrentPath } from '@/hooks/useCompareCurrentPath' import { ROUTES } from '@/constants' import { useEffect, useState } from 'react' -import { useRouter } from 'next/router' +import AddIcon from '@mui/icons-material/Add' +import EditIcon from '@mui/icons-material/Edit' +import ModalView from '../ModalView' +import { useFormInput } from '@/hooks' +import { notEmpty } from '@/utils/inputValidation' +import { StyledTextField } from '../Input' +import { RpcUrl } from '@/services/useink/chains/data/types' +import { ChainExtended } from '@/types' const StyledSelect = styled(Select)(() => ({ color: 'white', @@ -60,39 +77,98 @@ const StyledMenuItem = styled(MenuItem)(() => ({ export function NetworkSelect({ currentChain, - onChange + onChange, + setCustomChain }: { currentChain: ChainId onChange: (chain: ChainId) => void + setCustomChain: (chain: ChainExtended) => void }) { const chain = getChain(currentChain) + const { + closeModal: closeDialog, + isOpen: isOpenDialog, + openModal: openDialog + } = useModalBehaviour() const { closeModal, isOpen, openModal } = useModalBehaviour() const { isEqual: isCurrentPathHome } = useCompareCurrentPath(ROUTES.HOME) const [newChainId, setNewChainId] = useState(currentChain) + if (chain.id === OPTION_FOR_CUSTOM_NETWORK && CHAINS_ALLOWED.length <= 4) { + CHAINS_ALLOWED.push(chain) + } + + const [chains, setChains] = useState(CHAINS_ALLOWED) + useEffect(() => { setNewChainId(currentChain) }, [currentChain]) const _handleChangeChain = (event: SelectChangeEvent) => { - const chainId = event.target.value as ChainId - setNewChainId(chainId) - + const chainId = event.target.value + if ( + chainId === OPTION_FOR_ADD_CUSTOM_NETWORK || + chainId == OPTION_FOR_EDIT_CUSTOM_NETWORK + ) { + openModal() + return + } + setNewChainId(chainId as ChainId) if (isCurrentPathHome) { - onChange(chainId) + onChange(chainId as ChainId) } else { - openModal() + openDialog() } } + const formData = { + name: useFormInput('test', [notEmpty]), + rpc: useFormInput('wss://rpc.shibuya.astar.network', [notEmpty]) + } + + const editNetwork = + chains.some(chain => chain.id === OPTION_FOR_CUSTOM_NETWORK) && + chains.length === 5 + + const anyInvalidField: boolean = Object.values(formData).some( + field => (field.required && !field.value) || field.error !== null + ) + + const _resetModalInputs = () => { + formData.name.setValue('') + formData.rpc.setValue('' as RpcUrl) + } + + const addCustomNetwork = async () => { + let newChainList: ChainExtended[] = [] + const customChain = createIChainWithRPCAndSave( + formData.name.value, + formData.rpc.value + ) + + if (editNetwork) { + newChainList = updateChain(chains, customChain) + } else { + _resetModalInputs() + newChainList = addNewChain(customChain) + } + setCustomChain(customChain) + setChains(newChainList) + onChange(customChain.id) + closeModal() + } + + const customExist = chains.some( + element => element.id === OPTION_FOR_CUSTOM_NETWORK + ) return ( <> - {CHAINS_ALLOWED.map(option => ( + {chains.map(option => ( ))} + {customExist ? ( + + + +

Edit Chain

+
+
+ ) : ( + + + +

Add chain

+
+
+ )}
{ - closeModal() + closeDialog() onChange(newChainId) }} /> + + { + addCustomNetwork() + }} + title={`${editNetwork ? 'Edit' : 'Add '} network`} + subTitle={`${editNetwork ? 'Edit' : 'Add '} network details`} + okBtn={{ + text: `${editNetwork ? 'Update' : 'Add'}`, + validation: anyInvalidField + }} + > + + + + + ) } diff --git a/src/view/components/WalletConnectButton/index.tsx b/src/view/components/WalletConnectButton/index.tsx index 715f07e2..79ab92ab 100644 --- a/src/view/components/WalletConnectButton/index.tsx +++ b/src/view/components/WalletConnectButton/index.tsx @@ -32,7 +32,8 @@ export const WalletConnectButton = () => { networkConnected, setCurrentAccount, setCurrentWallet, - setCurrentChain + setCurrentChain, + setCustomChain } = useNetworkAccountsContext() const isDelayFinished = useDelay() const [openModal, setOpenModal] = useState(false) @@ -70,6 +71,7 @@ export const WalletConnectButton = () => { )}