diff --git a/components/ChainConnect/ChainDigest.tsx b/components/ChainConnect/ChainDigest.tsx new file mode 100644 index 0000000..39e185b --- /dev/null +++ b/components/ChainConnect/ChainDigest.tsx @@ -0,0 +1,104 @@ +import { useChains } from "@/context/ChainsContext"; +import { deleteLocalChainFromStorage } from "@/context/ChainsContext/storage"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { CheckCircle, ChevronsUpDown, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import ButtonWithConfirm from "../inputs/ButtonWithConfirm"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; + +interface ChainItemProps { + readonly chain: ChainInfo; + readonly simplify?: boolean; +} + +export default function ChainDigest({ chain, simplify }: ChainItemProps) { + const { chain: connectedChain, chains } = useChains(); + + return ( +
+
+ + {!simplify && connectedChain.registryName === chain.registryName ? ( + + ) : null} + + {chain.registryName.slice(0, 1).toUpperCase()} + +

{chain.chainDisplayName}

+ {chain.chainId} +
+

Fee token: {chain.displayDenom}

+ +
+ {!simplify && chain.nodeAddresses.length > 1 ? ( + + + + ) : ( +

RPC endpoint:

+ )} +
+
+ {chain.nodeAddress || chain.nodeAddresses[0]} +
+ + {chain.nodeAddresses + .filter( + (address, _, nodeAddresses) => + (chain.nodeAddress && address !== chain.nodeAddress) || + (!chain.nodeAddress && address !== nodeAddresses[0]), + ) + .map((address) => ( +
+ {address} +
+ ))} +
+
+ {!simplify && chains.localnets.has(chain.registryName) ? ( +
+ { + deleteLocalChainFromStorage(chain.registryName, chains); + }} + text="Delete custom chain" + confirmText="Confirm deletion?" + disabled={connectedChain.registryName === chain.registryName} + /> +
+ ) : !simplify ? ( +
+ + + Check it out on the registry + +
+ ) : null} +
+ ); +} diff --git a/components/ChainConnect/ChainItem.tsx b/components/ChainConnect/ChainItem.tsx new file mode 100644 index 0000000..a8bf14e --- /dev/null +++ b/components/ChainConnect/ChainItem.tsx @@ -0,0 +1,72 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { cn } from "@/lib/utils"; +import { CheckCircle } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { CommandItem } from "../ui/command"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card"; +import ChainDigest from "./ChainDigest"; + +interface ChainItemProps { + readonly chain: ChainInfo; + readonly hoverCardElementBoundary: HTMLDivElement | null; +} + +export default function ChainItem({ chain, hoverCardElementBoundary }: ChainItemProps) { + const { chain: connectedChain, chainsDispatch } = useChains(); + + return ( + + + {} + : () => { + setNewConnection(chainsDispatch, { action: "confirm", chain }); + } + } + className={cn( + "group relative inline-flex cursor-pointer items-center justify-center gap-2 rounded-full border-2 border-white p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-4 focus:ring-red-100 aria-selected:text-gray-900", + connectedChain.registryName === chain.registryName + ? "cursor-not-allowed border-green-600 bg-green-300 text-green-900 aria-selected:bg-green-300" + : "transparent cursor-pointer border-white", + )} + > + {connectedChain.registryName === chain.registryName ? ( + + ) : null} + + + {chain.registryName.slice(0, 1).toUpperCase()} + + {chain.registryName} + + + + + + + ); +} diff --git a/components/ChainConnect/ChainsGroup.tsx b/components/ChainConnect/ChainsGroup.tsx new file mode 100644 index 0000000..96007db --- /dev/null +++ b/components/ChainConnect/ChainsGroup.tsx @@ -0,0 +1,35 @@ +import { ChainInfo } from "@/context/ChainsContext/types"; +import { CommandGroup } from "../ui/command"; +import ChainItem from "./ChainItem"; +import { useRef } from "react"; + +interface ChainsGroupProps { + readonly chains: readonly ChainInfo[]; + readonly heading: string; + readonly emptyMsg: string; +} + +export default function ChainsGroup({ chains, heading, emptyMsg }: ChainsGroupProps) { + const containerRef = useRef(null); + + return ( + + {chains.length ? ( +
+ {chains.map((chain) => ( + + ))} +
+ ) : ( +

{emptyMsg}

+ )} +
+ ); +} diff --git a/components/ChainConnect/ChooseChain.tsx b/components/ChainConnect/ChooseChain.tsx new file mode 100644 index 0000000..4074bba --- /dev/null +++ b/components/ChainConnect/ChooseChain.tsx @@ -0,0 +1,58 @@ +import { useChains } from "@/context/ChainsContext"; +import { getRecentChainsFromStorage } from "@/context/ChainsContext/storage"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { useLayoutEffect, useState } from "react"; +import { Command, CommandEmpty, CommandInput, CommandList, CommandSeparator } from "../ui/command"; +import ChainsGroup from "./ChainsGroup"; + +export default function ChooseChain() { + const { chains } = useChains(); + const [recentChains, setRecentChains] = useState([]); + + useLayoutEffect(() => { + const newRecentChains = getRecentChainsFromStorage(chains); + setRecentChains(newRecentChains); + }, [chains]); + + return ( + + +
+ + No results found. + + + + + + + + +
+
+ ); +} diff --git a/components/ChainConnect/ConfirmConnection.tsx b/components/ChainConnect/ConfirmConnection.tsx new file mode 100644 index 0000000..07732ed --- /dev/null +++ b/components/ChainConnect/ConfirmConnection.tsx @@ -0,0 +1,61 @@ +import { useChains } from "@/context/ChainsContext"; +import { setChain, setNewConnection } from "@/context/ChainsContext/helpers"; +import { useRouter } from "next/router"; +import { Button } from "../ui/button"; +import ChainDigest from "./ChainDigest"; + +interface ConfirmConnectionProps { + readonly closeDialog: () => void; +} + +export default function ConfirmConnection({ closeDialog }: ConfirmConnectionProps) { + const router = useRouter(); + const { chain, newConnection, chainsDispatch } = useChains(); + + if (newConnection.action !== "confirm") { + return null; + } + + return ( + <> +

+ Disconnect from "{chain.registryName}" and connect to "{newConnection.chain.registryName}"? +

+

+ You will be redirected to the homepage and any filled form will be lost +

+
+
+ +
+
+ +
+
+
+ + +
+ + ); +} diff --git a/components/ChainConnect/CustomChainForm.tsx b/components/ChainConnect/CustomChainForm.tsx new file mode 100644 index 0000000..bb34867 --- /dev/null +++ b/components/ChainConnect/CustomChainForm.tsx @@ -0,0 +1,256 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { RegistryAsset } from "@/types/chainRegistry"; +import { zodResolver } from "@hookform/resolvers/zod"; +import dynamic from "next/dynamic"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "../ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; + +const JsonEditor = dynamic(() => import("../inputs/JsonEditor"), { ssr: false }); + +export default function CustomChainForm() { + const { chain, chains, newConnection, chainsDispatch } = useChains(); + + const formSchema = z + .object({ + localRegistryName: z + .string({ required_error: "Local registry name is required" }) + .refine((val) => !chains.mainnets.has(val) && !chains.testnets.has(val), { + message: "Name already exists in remote registry", + }), + chainName: z.string({ required_error: "Chain name is required" }), + chainId: z.string({ required_error: "Chain ID is required" }), + baseDenom: z.string({ required_error: "Base denom is required" }), + displayDenom: z.string({ required_error: "Display denom is required" }), + denomExponent: z.string({ required_error: "Denom exponent is required" }), + bech32Prefix: z.string({ required_error: "Address prefix is required" }), + gasPrice: z.string({ required_error: "Gas price is required" }), + rpcNodes: z.string({ required_error: "Comma separated rpc nodes are required" }), + explorerLink: z.string({ required_error: "Explorer url is required" }), + logo: z.string({ required_error: "Logo url is required" }), + assets: z.string({ required_error: "Assets json is required" }), + }) + .required(); + + const defaultChain = newConnection.chain ?? chain; + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + localRegistryName: defaultChain.registryName, + chainName: defaultChain.chainDisplayName, + chainId: defaultChain.chainId, + baseDenom: defaultChain.denom, + displayDenom: defaultChain.displayDenom, + denomExponent: String(defaultChain.displayDenomExponent), + bech32Prefix: defaultChain.addressPrefix, + gasPrice: defaultChain.gasPrice, + rpcNodes: defaultChain.nodeAddresses.join(", "), + explorerLink: defaultChain.explorerLink, + logo: defaultChain.logo, + assets: JSON.stringify(defaultChain.assets), + }, + }); + + function onSubmit(chainFromForm: z.infer) { + const rpcNodes = chainFromForm.rpcNodes.split(", "); + + setNewConnection(chainsDispatch, { + action: "confirm", + chain: { + registryName: chainFromForm.localRegistryName, + logo: chainFromForm.logo, + chainId: chainFromForm.chainId, + chainDisplayName: chainFromForm.chainName, + nodeAddress: "", + nodeAddresses: rpcNodes, + denom: chainFromForm.baseDenom, + displayDenom: chainFromForm.displayDenom, + displayDenomExponent: Number(chainFromForm.denomExponent), + assets: JSON.parse(chainFromForm.assets) as RegistryAsset[], + gasPrice: chainFromForm.gasPrice, + addressPrefix: chainFromForm.bech32Prefix, + explorerLink: chainFromForm.explorerLink, + }, + }); + } + + return ( +
+ +
+ ( + + Local Registry Name + + + + + A unique key to store this chain on your local registry + + + + )} + /> + ( + + Chain Name + + + + + + )} + /> + ( + + Chain ID + + + + + + )} + /> + ( + + Base Denom + + + + + + )} + /> + ( + + Display Denom + + + + + + )} + /> + ( + + Denom Exponent + + + + + + )} + /> + ( + + Address Prefix + + + + Needs to be bech32 + + + )} + /> + ( + + Gas Price + + + + + + )} + /> + ( + + Explorer Link + + + + with {"'${txHash}'"} included + + + )} + /> +
+ ( + + RPC nodes + + + + Can be one or more, separated by commas + + + )} + /> + ( + + Logo URI + + + + + + )} + /> + ( + + Assets + +
+ { + field.onChange("text" in newMsgContent ? newMsgContent.text ?? "{}" : "{}"); + }} + /> +
+
+ +
+ )} + /> + + + + ); +} diff --git a/components/ChainConnect/DialogButton.tsx b/components/ChainConnect/DialogButton.tsx new file mode 100644 index 0000000..cfa2ccd --- /dev/null +++ b/components/ChainConnect/DialogButton.tsx @@ -0,0 +1,43 @@ +import { useChains } from "@/context/ChainsContext"; +import { isChainInfoFilled } from "@/context/ChainsContext/helpers"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { DialogTrigger } from "../ui/dialog"; +import { Skeleton } from "../ui/skeleton"; + +export function ChainHeader() { + const { chain } = useChains(); + + return isChainInfoFilled(chain) ? ( + <> + + + {chain.registryName.slice(0, 1).toUpperCase()} + +

{chain.chainDisplayName} Multisig

+ + ) : ( + <> + +
+ + +
+ + ); +} + +export default function DialogButton() { + const showChainSelect = process.env.NEXT_PUBLIC_MULTICHAIN?.toLowerCase() === "true"; + + return showChainSelect ? ( + +
+ +
+
+ ) : ( +
+ +
+ ); +} diff --git a/components/ChainConnect/TabButton.tsx b/components/ChainConnect/TabButton.tsx new file mode 100644 index 0000000..0feff93 --- /dev/null +++ b/components/ChainConnect/TabButton.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; +import { ComponentProps } from "react"; +import { TabsTrigger } from "../ui/tabs"; + +export default function TabButton({ + value, + children, + className, + ...restProps +}: ComponentProps) { + return ( + + {children} + + ); +} diff --git a/components/ChainConnect/index.tsx b/components/ChainConnect/index.tsx new file mode 100644 index 0000000..9f8b6ae --- /dev/null +++ b/components/ChainConnect/index.tsx @@ -0,0 +1,52 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader } from "../ui/dialog"; +import { Tabs, TabsContent, TabsList } from "../ui/tabs"; +import ChooseChain from "./ChooseChain"; +import ConfirmConnection from "./ConfirmConnection"; +import CustomChainForm from "./CustomChainForm"; +import DialogButton from "./DialogButton"; +import TabButton from "./TabButton"; + +const tabs = { choose: "choose", custom: "custom" }; + +export default function ChainConnect() { + const { newConnection, chainsDispatch } = useChains(); + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + { + setDialogOpen(open); + setNewConnection(chainsDispatch, { action: "edit" }); + }} + > + + + {newConnection.action === "confirm" ? ( + setDialogOpen(false)} /> + ) : ( + + + + Choose chain + Custom chain + + + + + + + + + + )} + + + ); +}