diff --git a/apps/tangle-dapp/components/NetworkSelector/CustomRpcEndpointInput.tsx b/apps/tangle-dapp/components/NetworkSelector/CustomRpcEndpointInput.tsx index 6cff48b5d..dc1f466c4 100644 --- a/apps/tangle-dapp/components/NetworkSelector/CustomRpcEndpointInput.tsx +++ b/apps/tangle-dapp/components/NetworkSelector/CustomRpcEndpointInput.tsx @@ -43,19 +43,21 @@ const CustomRpcEndpointInput: FC = ({ } }, [getCachedCustomRpcEndpoint, value]); + const rightIcon = + value !== '' ? ( + + ) : ( + + ); + return ( - ) : ( - - ) - } + isControlled + rightIcon={rightIcon} /> ); }; diff --git a/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx b/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx index 0a147bb33..7d88dd9f7 100644 --- a/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx +++ b/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx @@ -46,9 +46,11 @@ const NetworkSelectionButton: FC = () => { ); const networkName = useMemo(() => { - if (isConnecting) return 'Connecting...'; - - if (loading) return 'Loading...'; + if (isConnecting) { + return 'Connecting...'; + } else if (loading) { + return 'Loading...'; + } return network?.name ?? 'Unknown Network'; }, [isConnecting, loading, network?.name]); @@ -57,10 +59,14 @@ const NetworkSelectionButton: FC = () => { // since it would have no effect there. const isInLiquidStakingPath = pathname.startsWith(PagePath.LIQUID_STAKING); - const isBridgePage = useMemo(() => pathname === '/bridge', [pathname]); + const isInBridgePath = useMemo( + () => pathname.startsWith(PagePath.BRIDGE), + [pathname], + ); const isWrongEvmNetwork = useMemo(() => { const isEvmWallet = activeWallet?.platform === 'EVM'; + return ( isEvmWallet && network.evmChainId !== undefined && @@ -69,18 +75,26 @@ const NetworkSelectionButton: FC = () => { }, [activeChain?.id, activeWallet?.platform, network.evmChainId]); const switchToCorrectEvmChain = useCallback(() => { - if (!network.evmChainId || !activeWallet) return; + if (!network.evmChainId || !activeWallet) { + return; + } + const typedChainId = calculateTypedChainId( ChainType.EVM, network.evmChainId, ); + const targetChain = chainsPopulated[typedChainId]; + switchChain(targetChain, activeWallet); }, [activeWallet, network.evmChainId, switchChain]); - if (isBridgePage) return null; - - if (isInLiquidStakingPath) { + if (isInBridgePath) { + return null; + } + // Network can't be switched from the Tangle Restaking Parachain while + // on liquid staking page. + else if (isInLiquidStakingPath) { return ( @@ -121,6 +135,7 @@ const NetworkSelectionButton: FC = () => { Wrong EVM Chain Connected )} + = ({ } /> - {/* Tangle restaking parachain (local dev) network */} - {IS_PRODUCTION_ENV && ( - - onNetworkChange(TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK) - } - /> - )} - {/* Custom network */} { return new Promise((resolve) => { try { diff --git a/apps/tangle-dapp/components/UpdateMetadataButton.tsx b/apps/tangle-dapp/components/UpdateMetadataButton.tsx index 93590a714..ab37f0297 100644 --- a/apps/tangle-dapp/components/UpdateMetadataButton.tsx +++ b/apps/tangle-dapp/components/UpdateMetadataButton.tsx @@ -39,7 +39,7 @@ const UpdateMetadataButton: FC = () => { ); const { setWithPreviousValue: setCache, valueOpt: cachedMetadata } = - useLocalStorage(LocalStorageKey.SUBSTRATE_WALLETS_METADATA, true); + useLocalStorage(LocalStorageKey.SUBSTRATE_WALLETS_METADATA); const updateCache = useCallback( (genesisHash: HexString, metadata: SubstrateWalletsMetadataEntry) => { diff --git a/apps/tangle-dapp/containers/BalancesTableContainer/BalancesTableContainer.tsx b/apps/tangle-dapp/containers/BalancesTableContainer/BalancesTableContainer.tsx index b1b12b3d6..0f246238f 100644 --- a/apps/tangle-dapp/containers/BalancesTableContainer/BalancesTableContainer.tsx +++ b/apps/tangle-dapp/containers/BalancesTableContainer/BalancesTableContainer.tsx @@ -43,10 +43,7 @@ const BalancesTableContainer: FC = () => { const { set: setCachedIsDetailsCollapsed, valueOpt: cachedIsDetailsCollapsedOpt, - } = useLocalStorage( - LocalStorageKey.IS_BALANCES_TABLE_DETAILS_COLLAPSED, - false, - ); + } = useLocalStorage(LocalStorageKey.IS_BALANCES_TABLE_DETAILS_COLLAPSED); const { result: locks } = useApiRx( useCallback( diff --git a/apps/tangle-dapp/data/NominationsPayouts/usePayouts.ts b/apps/tangle-dapp/data/NominationsPayouts/usePayouts.ts index 4ece55e09..89981bcbb 100644 --- a/apps/tangle-dapp/data/NominationsPayouts/usePayouts.ts +++ b/apps/tangle-dapp/data/NominationsPayouts/usePayouts.ts @@ -42,7 +42,6 @@ export default function usePayouts(): UsePayoutsReturnType { const { setWithPreviousValue: setCachedPayouts } = useLocalStorage( LocalStorageKey.PAYOUTS, - true, ); const { rpcEndpoint, network } = useNetworkStore(); diff --git a/apps/tangle-dapp/data/liquidStaking/useLiquidStakingItems.ts b/apps/tangle-dapp/data/liquidStaking/useLiquidStakingItems.ts index 41314846e..b9ce42418 100644 --- a/apps/tangle-dapp/data/liquidStaking/useLiquidStakingItems.ts +++ b/apps/tangle-dapp/data/liquidStaking/useLiquidStakingItems.ts @@ -28,21 +28,17 @@ import { } from './helper'; const useLiquidStakingItems = (selectedChain: ParachainChainId) => { - const { - // valueOpt: liquidStakingTableData, - setWithPreviousValue: setLiquidStakingTableData, - } = useLocalStorage(LocalStorageKey.LIQUID_STAKING_TABLE_DATA, true); + const { setWithPreviousValue: setLiquidStakingTableData } = useLocalStorage( + LocalStorageKey.LIQUID_STAKING_TABLE_DATA, + ); + const [isLoading, setIsLoading] = useState(false); + const [items, setItems] = useState< Validator[] | VaultOrStakePool[] | Dapp[] | Collator[] >([]); - const dataType = useMemo(() => getDataType(selectedChain), [selectedChain]); - // const cachedData = useMemo( - // () => liquidStakingTableData.value[selectedChain] || [], - // [liquidStakingTableData, selectedChain], - // ); - // console.debug('cachedData', cachedData); + const dataType = useMemo(() => getDataType(selectedChain), [selectedChain]); const fetchData = useCallback( async (chain: ParachainChainId) => { diff --git a/apps/tangle-dapp/data/payouts/usePayoutsAvailability.ts b/apps/tangle-dapp/data/payouts/usePayoutsAvailability.ts index 079c2b2b1..00be67518 100644 --- a/apps/tangle-dapp/data/payouts/usePayoutsAvailability.ts +++ b/apps/tangle-dapp/data/payouts/usePayoutsAvailability.ts @@ -5,13 +5,8 @@ import useLocalStorage, { LocalStorageKey } from '../../hooks/useLocalStorage'; import useSubstrateAddress from '../../hooks/useSubstrateAddress'; const usePayoutsAvailability = () => { - const { valueOpt: cachedPayouts } = useLocalStorage( - LocalStorageKey.PAYOUTS, - true, - ); - + const { valueOpt: cachedPayouts } = useLocalStorage(LocalStorageKey.PAYOUTS); const { rpcEndpoint } = useNetworkStore(); - const address = useSubstrateAddress(); const payoutsData = useMemo(() => { diff --git a/apps/tangle-dapp/hooks/useInitialNetwork.ts b/apps/tangle-dapp/hooks/useInitialNetwork.ts new file mode 100644 index 000000000..033420420 --- /dev/null +++ b/apps/tangle-dapp/hooks/useInitialNetwork.ts @@ -0,0 +1,132 @@ +import { + Network, + NETWORK_MAP, + NetworkId, +} from '@webb-tools/webb-ui-components/constants/networks'; +import { useCallback } from 'react'; +import { z } from 'zod'; + +import testRpcEndpointConnection from '../components/NetworkSelector/testRpcEndpointConnection'; +import { DEFAULT_NETWORK } from '../constants/networks'; +import createCustomNetwork from '../utils/createCustomNetwork'; +import useLocalStorage, { LocalStorageKey } from './useLocalStorage'; + +const useCachedNetworkId = (): (( + cachedNetworkId: number, +) => Promise) => { + const { remove: removeCachedNetworkId } = useLocalStorage( + LocalStorageKey.KNOWN_NETWORK_ID, + ); + + return useCallback( + async (cachedNetworkId: number) => { + // If there is a cached network id, check if it is a known network. + const parsedNetworkId = z + .nativeEnum(NetworkId) + .safeParse(cachedNetworkId); + + if (!parsedNetworkId.success) { + console.warn( + `Cached network id appears to be invalid: ${cachedNetworkId}, deleting from local storage`, + ); + + removeCachedNetworkId(); + + return DEFAULT_NETWORK; + } + + const id = parsedNetworkId.data; + const knownNetwork = NETWORK_MAP[id]; + + if (knownNetwork === undefined) { + console.warn( + `Could not find an associated network for cached network id: ${id}, deleting from local storage`, + ); + + removeCachedNetworkId(); + + return DEFAULT_NETWORK; + } + + const connectionEstablished = await testRpcEndpointConnection( + knownNetwork.wsRpcEndpoint, + ); + + if (!connectionEstablished) { + console.warn( + `Could not connect to cached network: ${knownNetwork.name}, deleting from local storage and connecting to default network instead`, + ); + + removeCachedNetworkId(); + + return DEFAULT_NETWORK; + } + + return knownNetwork; + }, + [removeCachedNetworkId], + ); +}; + +const useCachedCustomRpcEndpoint = (): (( + cachedCustomRpcEndpoint: string, +) => Promise) => { + const { remove: removeCachedCustomRpcEndpoint } = useLocalStorage( + LocalStorageKey.CUSTOM_RPC_ENDPOINT, + ); + + return useCallback( + async (cachedCustomRpcEndpoint: string) => { + const connectionEstablished = await testRpcEndpointConnection( + cachedCustomRpcEndpoint, + ); + + if (!connectionEstablished) { + console.warn( + `Could not connect to cached custom RPC endpoint: ${cachedCustomRpcEndpoint}, deleting from local storage`, + ); + + removeCachedCustomRpcEndpoint(); + + return DEFAULT_NETWORK; + } + + return createCustomNetwork(cachedCustomRpcEndpoint); + }, + [removeCachedCustomRpcEndpoint], + ); +}; + +const useInitialNetwork = () => { + const { refresh: getCachedCustomRpcEndpoint } = useLocalStorage( + LocalStorageKey.CUSTOM_RPC_ENDPOINT, + ); + + const { refresh: getCachedNetworkId } = useLocalStorage( + LocalStorageKey.KNOWN_NETWORK_ID, + ); + + const getCustomNetwork = useCachedCustomRpcEndpoint(); + const getKnownNetwork = useCachedNetworkId(); + + return useCallback(async () => { + const cachedCustomRpcEndpointOpt = getCachedCustomRpcEndpoint(); + const cachedNetworkIdOpt = getCachedNetworkId(); + + // If there is a cached custom RPC endpoint, return it as a custom network. + // If there is a cached network id, check if it is a known network. + // Otherwise, return the default network. + return cachedCustomRpcEndpointOpt.value !== null + ? getCustomNetwork(cachedCustomRpcEndpointOpt.value) + : cachedNetworkIdOpt.value !== null + ? getKnownNetwork(cachedNetworkIdOpt.value) + : DEFAULT_NETWORK; + }, [ + getCachedCustomRpcEndpoint, + getCachedNetworkId, + getCustomNetwork, + getKnownNetwork, + ]); +}; + +export default useInitialNetwork; diff --git a/apps/tangle-dapp/hooks/useLocalStorage.ts b/apps/tangle-dapp/hooks/useLocalStorage.ts index 260eff86d..024c3fb67 100644 --- a/apps/tangle-dapp/hooks/useLocalStorage.ts +++ b/apps/tangle-dapp/hooks/useLocalStorage.ts @@ -82,33 +82,29 @@ export type LocalStorageValueOf = ? LiquidStakingTableData : never; -export const extractFromLocalStorage = ( +export const getJsonFromLocalStorage = ( key: Key, - canClearIfInvalid: boolean, ): LocalStorageValueOf | null => { - type Value = LocalStorageValueOf; - - const jsonString = localStorage.getItem(key); + const valueString = localStorage.getItem(key); // Item was not present in local storage. - if (jsonString === null) { + if (valueString === null) { return null; } - let value: Value | null = null; - - // Clear the local storage value if parsing fails, and the - // entry is set to be cleared if invalid. try { - // TODO: Use zod to validate the value, this helps prevent logic errors. - value = JSON.parse(jsonString) as Value; + // TODO: Move to using zod to validate the value at runtime. + return JSON.parse(valueString) as LocalStorageValueOf; } catch { - if (canClearIfInvalid) { - localStorage.removeItem(key); - } - } + localStorage.removeItem(key); - return value; + console.warn( + 'Removed corrupted local storage key, which failed to be parsed as JSON:', + key, + ); + + return null; + } }; // TODO: During development cycles, changing local storage value types will lead to @@ -124,10 +120,7 @@ export const extractFromLocalStorage = ( * it's recommended to instead use a `useEffect` and manually retrieve the * value from local storage on mount, using the `get` method. */ -const useLocalStorage = ( - key: Key, - isUsedAsCache = false, -) => { +const useLocalStorage = (key: Key) => { type Value = LocalStorageValueOf; // Initially, the value is `null` until the component is mounted @@ -135,7 +128,7 @@ const useLocalStorage = ( const [valueOpt, setValueOpt] = useState | null>(null); const refresh = useCallback(() => { - const freshValue = extractFromLocalStorage(key, isUsedAsCache); + const freshValue = getJsonFromLocalStorage(key); const freshValueOpt = new Optional( freshValue === null ? undefined : freshValue, @@ -144,7 +137,7 @@ const useLocalStorage = ( setValueOpt(freshValueOpt); return freshValueOpt; - }, [isUsedAsCache, key]); + }, [key]); // Extract the value from local storage on mount. useEffect(() => { diff --git a/apps/tangle-dapp/hooks/useNetworkSwitcher.ts b/apps/tangle-dapp/hooks/useNetworkSwitcher.ts index a2504e266..9d79e26af 100644 --- a/apps/tangle-dapp/hooks/useNetworkSwitcher.ts +++ b/apps/tangle-dapp/hooks/useNetworkSwitcher.ts @@ -11,49 +11,21 @@ import { calculateTypedChainId, ChainType } from '@webb-tools/utils'; import { notificationApi } from '@webb-tools/webb-ui-components'; import { Network, - NETWORK_MAP, NetworkId, } from '@webb-tools/webb-ui-components/constants/networks'; import { useCallback, useEffect, useState } from 'react'; import { createPublicClient, fallback, http, webSocket } from 'viem'; -import z from 'zod'; -import { DEFAULT_NETWORK } from '../constants/networks'; +import testRpcEndpointConnection from '../components/NetworkSelector/testRpcEndpointConnection'; import useNetworkStore from '../context/useNetworkStore'; -import createCustomNetwork from '../utils/createCustomNetwork'; import ensureError from '../utils/ensureError'; import { getApiPromise } from '../utils/polkadot'; +import useInitialNetwork from './useInitialNetwork'; import useLocalStorage, { LocalStorageKey } from './useLocalStorage'; -function testRpcEndpointConnection(rpcEndpoint: string): Promise { - return new Promise((resolve) => { - try { - const ws = new WebSocket(rpcEndpoint); - - const handleOpen = () => { - ws.removeEventListener('open', handleOpen); - ws.close(); - resolve(true); - }; - - const handleCloseEvent = () => { - ws.removeEventListener('close', handleCloseEvent); - resolve(false); - }; - - ws.addEventListener('open', handleOpen); - ws.addEventListener('close', handleCloseEvent); - } catch { - resolve(false); - } - }); -} - const useNetworkSwitcher = () => { const { switchChain, activeWallet } = useWebContext(); - const [isCustom, setIsCustom] = useState(false); - const { network, setNetwork } = useNetworkStore(); // TODO: Should utilize the zustand middleware to cache this @@ -78,54 +50,20 @@ const useNetworkSwitcher = () => { remove: removeCachedNetworkId, } = useLocalStorage(LocalStorageKey.KNOWN_NETWORK_ID); + const getCachedInitialNetwork = useInitialNetwork(); + // Load the initial network from local storage. useEffect(() => { - const getCachedInitialNetwork = () => { - const cachedNetworkNameOpt = getCachedNetworkId(); - - // If the cached network name is present, that indicates that - // the cached network is a Webb network. Find it in the list of - // all Webb networks, and return it. - if (cachedNetworkNameOpt.value !== null) { - const parsedNetworkId = z - .nativeEnum(NetworkId) - .safeParse(cachedNetworkNameOpt.value); - - if (parsedNetworkId.success) { - const id = parsedNetworkId.data as keyof typeof NETWORK_MAP; - const knownNetwork = NETWORK_MAP[id]; - - if (knownNetwork !== undefined) { - return knownNetwork; - } - } - - console.warn( - `Could not find an associated network for cached network id: ${cachedNetworkNameOpt.value}, deleting from local storage`, - ); - - removeCachedNetworkId(); - - return DEFAULT_NETWORK; - } - - const cachedCustomRpcEndpointOpt = getCachedCustomRpcEndpoint(); - - // If a custom RPC endpoint is cached, return it as a custom network. - if (cachedCustomRpcEndpointOpt.value !== null) { + getCachedInitialNetwork().then((initialNetwork) => { + if (initialNetwork.id === NetworkId.CUSTOM) { setIsCustom(true); - - return createCustomNetwork(cachedCustomRpcEndpointOpt.value); } - // Otherwise, use the default network. - return DEFAULT_NETWORK; - }; - - // TODO: Test connection to the initial cached network, if it fails, use the default network instead. If the initial cached network IS the default network already and the connection is failing... it's a bit more complicated. - setNetwork(getCachedInitialNetwork()); + setNetwork(initialNetwork); + }); }, [ getCachedCustomRpcEndpoint, + getCachedInitialNetwork, getCachedNetworkId, removeCachedNetworkId, setNetwork, @@ -138,7 +76,9 @@ const useNetworkSwitcher = () => { // Already on the requested network. if (network.id === newNetwork.id) { return; - } else if (!(await testRpcEndpointConnection(newNetwork.wsRpcEndpoint))) { + } + // Test connection to the new network. + else if (!(await testRpcEndpointConnection(newNetwork.wsRpcEndpoint))) { notificationApi({ variant: 'error', message: `Unable to connect to the requested network: ${newNetwork.wsRpcEndpoint}`, @@ -149,7 +89,7 @@ const useNetworkSwitcher = () => { if (activeWallet !== undefined) { try { - const chain = await netWorkToChain(newNetwork, activeWallet); + const chain = await mapNetworkToChain(newNetwork, activeWallet); const switchChainResult = await switchChain(chain, activeWallet); @@ -183,8 +123,16 @@ const useNetworkSwitcher = () => { setIsCustom(isCustom); setNetwork(newNetwork); }, - // prettier-ignore - [activeWallet, network.id, removeCachedCustomRpcEndpoint, removeCachedNetworkId, setCachedCustomRpcEndpoint, setCachedNetworkId, setNetwork, switchChain], + [ + activeWallet, + network.id, + removeCachedCustomRpcEndpoint, + removeCachedNetworkId, + setCachedCustomRpcEndpoint, + setCachedNetworkId, + setNetwork, + switchChain, + ], ); return { @@ -193,15 +141,10 @@ const useNetworkSwitcher = () => { }; }; -/** - * Map a network to a chain - * @param network the network to map to a chain - * @param chainsConfig the chains configuration - * @param activeWallet the active wallet - * - * @returns the chain - */ -async function netWorkToChain(network: Network, activeWallet: WalletConfig) { +async function mapNetworkToChain( + network: Network, + activeWallet: WalletConfig, +): Promise { if (activeWallet.platform === 'Substrate') { const api = await getApiPromise(network.wsRpcEndpoint); @@ -213,7 +156,7 @@ async function netWorkToChain(network: Network, activeWallet: WalletConfig) { : DEFAULT_SS58.toNumber(); } - const deciamls = + const decimals = api.registry.chainDecimals.length > 0 ? api.registry.chainDecimals[0] : DEFAULT_DECIMALS; @@ -231,7 +174,7 @@ async function netWorkToChain(network: Network, activeWallet: WalletConfig) { network.substrateChainId, ChainType.Substrate, typedChainId, - deciamls, + decimals, ); return chain; diff --git a/libs/webb-ui-components/src/components/ErrorFallback/ErrorFallback.tsx b/libs/webb-ui-components/src/components/ErrorFallback/ErrorFallback.tsx index e5174aa8a..4f8b5f169 100644 --- a/libs/webb-ui-components/src/components/ErrorFallback/ErrorFallback.tsx +++ b/libs/webb-ui-components/src/components/ErrorFallback/ErrorFallback.tsx @@ -1,4 +1,6 @@ -import { Fragment, forwardRef, useMemo } from 'react'; +'use client'; + +import { Fragment, forwardRef, useCallback, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import * as constants from '../../constants'; @@ -9,11 +11,13 @@ import { ErrorFallbackProps } from './types'; const telegramInfo = constants.defaultSocialConfigs.find( (c) => c.name === 'telegram', ); + const contactLink = telegramInfo?.href ?? ''; const githubInfo = constants.defaultSocialConfigs.find( (c) => c.name === 'github', ); + const reportIssueLink = `${githubInfo?.href ?? ''}/webb-dapp/issues/new/choose`; /** @@ -48,6 +52,8 @@ export const ErrorFallback = forwardRef( }, ref, ) => { + const [hasClearedCache, setHasClearedCache] = useState(false); + const description = useMemo(() => { if (descriptionProp) { return descriptionProp; @@ -76,6 +82,11 @@ export const ErrorFallback = forwardRef( ]; }, [contactUsLinkProps, descriptionProp]); + const handleClearCache = useCallback(() => { + localStorage.clear(); + setHasClearedCache(true); + }, []); + const buttonProps = useMemo>(() => { if (buttons) { return buttons; @@ -96,6 +107,14 @@ export const ErrorFallback = forwardRef( variant: 'primary', children: 'Refresh Page', }, + { + onClick: handleClearCache, + ...refreshPageButtonProps, + ...commonButtonProps, + variant: 'secondary', + children: 'Clear cache', + isDisabled: hasClearedCache, + }, { href: reportIssueLink, target: '_blank', @@ -105,7 +124,13 @@ export const ErrorFallback = forwardRef( children: 'Report issue', }, ]; - }, [buttons, refreshPageButtonProps, reportIssueButtonProps]); + }, [ + buttons, + handleClearCache, + hasClearedCache, + refreshPageButtonProps, + reportIssueButtonProps, + ]); return (
> = { [NetworkId.TANGLE_MAINNET]: TANGLE_MAINNET_NETWORK, [NetworkId.TANGLE_TESTNET]: TANGLE_TESTNET_NATIVE_NETWORK, [NetworkId.TANGLE_LOCAL_DEV]: TANGLE_LOCAL_DEV_NETWORK, @@ -137,4 +137,4 @@ export const NETWORK_MAP = { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK, [NetworkId.TANGLE_RESTAKING_PARACHAIN_TESTNET]: TANGLE_RESTAKING_PARACHAIN_TESTNET_NETWORK, -} as const satisfies Partial>; +};