"use client"; import styled from "@emotion/styled"; import { CrossCircledIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons"; import Fuse from "fuse.js"; import type React from "react"; import { Fragment, memo, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import type { Chain } from "../../../../chains/types.js"; import { convertApiChainToChain } from "../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import { CustomThemeProvider, useCustomTheme, } from "../../../core/design-system/CustomThemeProvider.js"; import { fontSize, iconSize, media, radius, spacing, type Theme, } from "../../../core/design-system/index.js"; import { useChainsQuery } from "../../../core/hooks/others/useChainQuery.js"; import { useActiveWalletChain } from "../../../core/hooks/wallets/useActiveWalletChain.js"; import { useSwitchActiveWalletChain } from "../../../core/hooks/wallets/useSwitchActiveWalletChain.js"; import { SetRootElementContext } from "../../../core/providers/RootElementContext.js"; import { Container, Line, ModalHeader } from "../components/basic.js"; import { Button } from "../components/buttons.js"; import { ChainActiveDot } from "../components/ChainActiveDot.js"; import { fallbackChainIcon } from "../components/fallbackChainIcon.js"; import { Input } from "../components/formElements.js"; import { Modal } from "../components/Modal.js"; import { ModalTitle } from "../components/modalElements.js"; import { Skeleton } from "../components/Skeleton.js"; import { Spacer } from "../components/Spacer.js"; import { Spinner } from "../components/Spinner.js"; import { Text } from "../components/text.js"; import { StyledButton, StyledP, StyledUl } from "../design-system/elements.js"; import { useDebouncedValue } from "../hooks/useDebouncedValue.js"; import { useShowMore } from "../hooks/useShowMore.js"; import { ChainIcon } from "../prebuilt/Chain/icon.js"; import { ChainName } from "../prebuilt/Chain/name.js"; import { ChainProvider } from "../prebuilt/Chain/provider.js"; import type { LocaleId } from "../types.js"; import { getConnectLocale } from "./locale/getConnectLocale.js"; import type { ConnectLocale } from "./locale/types.js"; /** * @internal */ export type NetworkSelectorChainProps = { /** * `Chain` object to be displayed */ chain: Chain; /** * function to be called for switching to the given chain */ switchChain: () => void; /** * flag indicating whether the SDK is currently switching to the given chain */ switching: boolean; /** * flag indicating whether the SDK failed to switch to the given chain */ switchFailed: boolean; /** * function to close the modal */ close?: () => void; }; type ChainSection = { label: string; chains: Chain[]; }; /** * @connectWallet */ export type NetworkSelectorProps = { /** * Chains to be displayed as "Popular" * @deprecated Use `sections` prop instead * * If `sections` prop is provided, this prop will be ignored */ popularChainIds?: number[]; /** * Chains to be displayed as "Recent" * @deprecated Use `sections` prop instead * * If `sections` prop is provided, this prop will be ignored */ recentChainIds?: number[]; /** * Specify sections of chains to be displayed in the Network Selector Modal * * @example * To display "Polygon", "Avalanche" chains under "Recently used" section and "Ethereum", "Arbitrum" chains under "Popular" section, you can set the prop with the following value * ```ts * import { arbitrum, base, ethereum, polygon } from "thirdweb/chains"; * * const sections = [ * { label: 'Recently used', chains: [arbitrum, polygon] }, * { label: 'Popular', chains: [base, ethereum] }, * ] * ``` */ sections?: Array; /** * Override how the chain button is rendered in the Modal */ renderChain?: React.FC; /** * Callback to be called when a chain is successfully switched * @param chain - The `Chain` of the chain that was switched to */ onSwitch?: (chain: Chain) => void; /** * Callback to be called when the "Add Custom Network" button is clicked * * The "Add Custom Network" button is displayed at the bottom of the modal - only if this prop is provided */ onCustomClick?: () => void; }; type NetworkSelectorContentProps = { onBack?: () => void; closeModal: () => void; chains: Chain[]; networkSelector?: NetworkSelectorProps; showTabs?: boolean; connectLocale: ConnectLocale; client: ThirdwebClient; className?: string; }; /** * @internal */ export function NetworkSelectorContent(props: NetworkSelectorContentProps) { const locale = props.connectLocale.networkSelector; const [searchTerm, setSearchTerm] = useState(""); const [selectedTab, setSelectedTab] = useState<"all" | "mainnet" | "testnet">( "all", ); const deferredSearchTerm = useDebouncedValue(searchTerm, 300); const { onSwitch, onCustomClick } = props.networkSelector || {}; // labels const othersLabel = locale.categoryLabel.others; const popularLabel = locale.categoryLabel.popular; const recentLabel = locale.categoryLabel.recentlyUsed; // create sections, chainToSectionMap and allChains const { chainSections, allChains, allChainsToSectionMap } = useMemo(() => { const chainSectionsValue: ChainSection[] = []; const allChainsValue: Chain[] = []; const allChainsToSectionMapValue: Map = new Map(); function addChain(c: Chain, section: string) { allChainsToSectionMapValue.set(c.id, section); allChainsValue.push(c); } // if new API is used if (props.networkSelector?.sections) { for (const s of props.networkSelector.sections) { const chainsToAdd = s.chains.filter( (c) => !allChainsToSectionMapValue.has(c.id), ); if (chainsToAdd.length > 0) { chainSectionsValue.push({ chains: chainsToAdd, label: s.label, }); for (const c of chainsToAdd) { addChain(c, s.label); } } } } // if old API is used else { const allChainsMap = new Map(props.chains.map((c) => [c.id, c])); // add all recent chains if ( props.networkSelector?.recentChainIds && props.networkSelector?.recentChainIds.length > 0 ) { const recentChains = props.networkSelector.recentChainIds .map((id) => allChainsMap.get(id)) .filter((c) => c !== undefined); chainSectionsValue.push({ chains: recentChains, label: recentLabel, }); for (const c of recentChains) { addChain(c, recentLabel); } } // then add all popular chains ( exclude already added chains ) if ( props.networkSelector?.popularChainIds && props.networkSelector.popularChainIds.length > 0 ) { const popularChains = props.networkSelector.popularChainIds .map((id) => allChainsMap.get(id)) .filter((c) => c !== undefined); const chainsToAdd = popularChains.filter( (c) => !allChainsToSectionMapValue.has(c.id), ); if (chainsToAdd.length > 0) { chainSectionsValue.push({ chains: chainsToAdd, label: popularLabel, }); for (const c of chainsToAdd) { addChain(c, popularLabel); } } } } // add all other chains ( exclude already added chains ) const otherChainsToAdd = props.chains.filter( (c) => !allChainsToSectionMapValue.has(c.id), ); if (otherChainsToAdd.length > 0) { chainSectionsValue.push({ chains: otherChainsToAdd, label: othersLabel, }); for (const c of otherChainsToAdd) { addChain(c, othersLabel); } } return { allChains: allChainsValue, allChainsToSectionMap: allChainsToSectionMapValue, chainSections: chainSectionsValue, }; }, [ props.networkSelector?.sections, props.networkSelector?.recentChainIds, props.networkSelector?.popularChainIds, props.chains, recentLabel, popularLabel, othersLabel, ]); // optimizing for dashboard - if we already have names - don't query - we already have the data we want const chainsHaveName = allChains.every((c) => !!c.name); const allChainsQuery = useChainsQuery(chainsHaveName ? [] : allChains, 10); const isAllChainsQueryLoading = chainsHaveName ? false : allChainsQuery.some((q) => q.isLoading); const allChainsMetadata: Chain[] = chainsHaveName ? allChains : !isAllChainsQueryLoading ? allChainsQuery .filter((x) => !!x.data) .map((q) => convertApiChainToChain(q.data)) : []; // fuse instance for searching const fuse = useMemo(() => { return new Fuse(allChainsMetadata, { keys: [ { name: "name", weight: 1, }, { name: "chainId", weight: 1, }, ], threshold: 0.4, }); }, [allChainsMetadata]); // chains filtered by search term const searchedChainSections = useMemo(() => { if (deferredSearchTerm === "") { return undefined; } const filteredChainSectionsValue: ChainSection[] = []; const filteredAllChains = fuse .search(deferredSearchTerm) .map((r) => r.item); for (const c of filteredAllChains) { const label = allChainsToSectionMap.get(c.id); if (!label) { return; // just a type guard, this never happens } const section = filteredChainSectionsValue.find( (s) => s.label === label, ); if (section) { section.chains.push(c); } else { filteredChainSectionsValue.push({ chains: [c], label, }); } } return filteredChainSectionsValue; }, [deferredSearchTerm, fuse, allChainsToSectionMap]) || chainSections; const filteredChainSections = useMemo(() => { if (selectedTab === "all") { return searchedChainSections; } return searchedChainSections.map((section) => ({ chains: section.chains.filter( (c) => (selectedTab === "mainnet" && !c.testnet) || (selectedTab === "testnet" && c.testnet), ), label: section.label, })); }, [searchedChainSections, selectedTab]); const handleSwitch = useCallback( (chain: Chain) => { if (onSwitch) { onSwitch(chain); } props.closeModal(); }, [onSwitch, props], ); return ( {props.onBack ? ( ) : ( {locale.title} )} {/* Tabs */} {props.showTabs !== false && ( <> setSelectedTab("all")} > {locale.allNetworks} setSelectedTab("mainnet")} > {locale.mainnets} setSelectedTab("testnet")} > {locale.testnets} )} {props.showTabs === false && } {/* Search */}
{ setSearchTerm(e.target.value); }} placeholder={ isAllChainsQueryLoading ? "Loading chains..." : locale.inputPlaceholder } style={{ padding: `${spacing.sm} ${spacing.md} ${spacing.sm} ${spacing.xxl}`, }} tabIndex={-1} value={searchTerm} variant="outline" /> {/* Searching Spinner */} {(deferredSearchTerm !== searchTerm || isAllChainsQueryLoading) && (
)}
{onCustomClick && ( <> )}
); } /** * * @internal */ const NetworkTabContent = (props: { chainSections: Array; onSwitch: (chain: Chain) => void; renderChain?: React.FC; close?: () => void; connectLocale: ConnectLocale; client: ThirdwebClient; }) => { const { chainSections } = props; const noChainsToShow = chainSections.every( (section) => section.chains.length === 0, ); return ( {/* empty state */} {noChainsToShow ? ( No Results ) : ( chainSections.map((section, idx) => { if (section.chains.length === 0) { return null; } return ( {idx !== 0 && } {section.label} ); }) )} ); }; type NetworkListProps = { chains: Chain[]; onSwitch: (chain: Chain) => void; renderChain?: React.FC; close?: () => void; client: ThirdwebClient; connectLocale: ConnectLocale; }; /** * @internal Exported for tests */ export const NetworkList = /* @__PURE__ */ memo(function NetworkList( props: NetworkListProps, ) { // show 10 items first, when reaching the last item, show 10 more const { itemsToShow, lastItemRef } = useShowMore(10, 10); const switchChain = useSwitchActiveWalletChain(); const activeChain = useActiveWalletChain(); const [switchingChainId, setSwitchingChainId] = useState(-1); const [errorSwitchingChainId, setErrorSwitchingChainId] = useState(-1); const close = props.close; useEffect(() => { // if switching and switched successfully - close modal if (switchingChainId !== -1 && activeChain?.id === switchingChainId) { if (close) { close(); } } }, [switchingChainId, close, activeChain?.id]); const handleSwitch = async (chain: Chain) => { setErrorSwitchingChainId(-1); setSwitchingChainId(chain.id); try { await switchChain(chain); props.onSwitch(chain); } catch (e) { setErrorSwitchingChainId(chain.id); console.error(e); } finally { setSwitchingChainId(-1); } }; const RenderChain = props.renderChain; const chainsToShow = props.chains.slice(0, itemsToShow); return ( {chainsToShow.map((chain, i) => { if (!chain) { return null; } const confirming = switchingChainId === chain.id; const switchingFailed = errorSwitchingChainId === chain.id; const isLast = i === chainsToShow.length - 1; return (
  • {RenderChain ? ( { handleSwitch(chain); }} switchFailed={errorSwitchingChainId === chain.id} switching={switchingChainId === chain.id} /> ) : ( handleSwitch(chain)} switchingFailed={switchingFailed} /> )}
  • ); })}
    ); } as React.FC); /** * @internal */ export const ChainButton = /* @__PURE__ */ memo(function ChainButton(props: { chain: Chain; onClick: () => void; confirming: boolean; switchingFailed: boolean; client: ThirdwebClient; connectLocale: ConnectLocale; }) { const locale = props.connectLocale; const { chain, confirming, switchingFailed } = props; const activeChain = useActiveWalletChain(); return ( } loading="lazy" loadingComponent={ } style={{ height: `${iconSize.lg}px`, width: `${iconSize.lg}px`, }} /> {activeChain?.id === chain.id && ( )} {confirming || switchingFailed ? ( } /> {confirming && ( <> {locale.switchingNetwork} )} {switchingFailed && ( {locale.networkSelector.failedToSwitch} )} ) : ( } /> )} ); }); /** * @internal Exported for tests */ export const TabButton = /* @__PURE__ */ (() => styled.button((_) => { const theme = useCustomTheme(); return { all: "unset", "&[data-active='true']": { background: theme.colors.secondaryButtonBg, color: theme.colors.primaryText, }, borderRadius: radius.lg, color: theme.colors.secondaryText, cursor: "pointer", fontSize: fontSize.sm, fontWeight: 500, padding: `${spacing.sm} ${spacing.sm}`, transition: "background 0.2s ease, color 0.2s ease", WebkitTapHighlightColor: "transparent", }; }))(); /** * @internal Exported for tests */ export const SectionLabel = /* @__PURE__ */ StyledP(() => { const theme = useCustomTheme(); return { color: theme.colors.secondaryText, display: "block", fontSize: fontSize.sm, margin: 0, padding: `0 ${spacing.xs}`, }; }); const NetworkListUl = /* @__PURE__ */ StyledUl({ boxSizing: "border-box", display: "flex", flexDirection: "column", gap: spacing.xs, listStyle: "none", margin: 0, padding: 0, }); /** * @internal Exported for tests */ export const NetworkButton = /* @__PURE__ */ StyledButton((_) => { const theme = useCustomTheme(); return { "&:hover": { background: theme.colors.secondaryButtonBg, }, all: "unset", alignItems: "center", borderRadius: radius.md, boxSizing: "border-box", color: theme.colors.primaryText, cursor: "pointer", display: "flex", fontSize: fontSize.md, fontWeight: 500, gap: spacing.md, padding: `${spacing.xs} ${spacing.sm}`, transition: "background 0.2s ease", width: "100%", [media.mobile]: { fontSize: fontSize.sm, }, }; }); /** * @internal Exported for tests */ export const StyledMagnifyingGlassIcon = /* @__PURE__ */ styled( MagnifyingGlassIcon, )((_) => { const theme = useCustomTheme(); return { color: theme.colors.secondaryText, left: spacing.sm, position: "absolute", }; }); /** * Options for the `useNetworkSwitcherModal` hook's returned `open` function * @connectWallet */ export type UseNetworkSwitcherModalOptions = { /** * Set the theme for the `NetworkSwitcher` Modal. By default it is set to `"dark"` * * theme can be set to either `"dark"`, `"light"` or a custom theme object. * * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) * functions from `thirdweb/react` to use the default themes as base and overrides parts of it. * @example * ```ts * import { lightTheme } from "thirdweb/react"; * * const customTheme = lightTheme({ * colors: { * modalBg: 'red' * } * }) * ``` */ theme?: Theme | "dark" | "light"; /** * Specify sections of chains to be displayed in the Network Selector Modal * * @example * To display "Polygon", "Avalanche" chains under "Recently used" section and "Ethereum", "Arbitrum" chains under "Popular" section, you can set the prop with the following value * ```ts * import { arbitrum, base, ethereum, polygon } from "thirdweb/chains"; * * const sections = [ * { label: 'Recently used', chains: [arbitrum, polygon] }, * { label: 'Popular', chains: [base, ethereum] }, * ] * ``` */ sections?: Array; /** * Override how the chain button is rendered in the Modal */ renderChain?: React.FC; /** * Callback to be called when a chain is successfully switched * @param chain - The `Chain` of the chain that was switched to */ onSwitch?: (chain: Chain) => void; /** * Callback to be called when the "Add Custom Network" button is clicked * * The "Add Custom Network" button is displayed at the bottom of the modal - only if this prop is provided */ onCustomClick?: () => void; /** * A client is the entry point to the thirdweb SDK. * It is required for all other actions. * You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information. * * You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage. * * ```tsx * import { createThirdwebClient } from "thirdweb"; * * const client = createThirdwebClient({ * clientId: "", * }) * ``` */ client: ThirdwebClient; /** * By default - NetworkSwitcher UI uses the `en-US` locale for english language users. * * You can customize the language used in the ConnectButton UI by setting the `locale` prop. * * Refer to the [`LocaleId`](https://portal.thirdweb.com/references/typescript/v5/LocaleId) type for supported locales. */ locale?: LocaleId; }; /** * Hook to open the Wallet Network Switcher Modal that shows allows users to switch to different network. * * @example * ```tsx * import { createThirdwebClient } from "thirdweb"; * import { useNetworkSwitcherModal } from "thirdweb/react"; * import { base, ethereum, polygon, sepolia, arbitrum } from "thirdweb/chains"; * * const client = createThirdwebClient({ * clientId: "", * }); * * function Example() { * const networkSwitcher = useNetworkSwitcherModal(); * * function handleClick() { * networkSwitcher.open({ * client, * theme: 'light' * sections: [ * { label: 'Recently used', chains: [arbitrum, polygon] }, * { label: 'Popular', chains: [base, ethereum, sepolia] }, * ] * }); * } * * return * } * ``` * @wallet */ export function useNetworkSwitcherModal() { const activeChain = useActiveWalletChain(); const setRootEl = useContext(SetRootElementContext); const closeModal = useCallback(() => { setRootEl(null); }, [setRootEl]); const openNetworkSwitcher = useCallback( async (props: UseNetworkSwitcherModalOptions) => { if (!activeChain) { throw new Error("No active wallet found"); } const locale = await getConnectLocale(props.locale || "en_US"); setRootEl( { if (!value) { closeModal(); } }} size="compact" style={{ paddingBottom: props.onCustomClick ? spacing.md : "0px", }} > , ); }, [setRootEl, closeModal, activeChain], ); return { close: closeModal, open: openNetworkSwitcher, }; }