From a75b379012ceaccdc49499b49d67042447ebe97d Mon Sep 17 00:00:00 2001
From: abefernan <44572727+abefernan@users.noreply.github.com>
Date: Mon, 20 Nov 2023 14:29:53 +0100
Subject: [PATCH] Add new ChainConnect
---
components/ChainConnect/ChainDigest.tsx | 104 +++++++
components/ChainConnect/ChainItem.tsx | 72 +++++
components/ChainConnect/ChainsGroup.tsx | 35 +++
components/ChainConnect/ChooseChain.tsx | 58 ++++
components/ChainConnect/ConfirmConnection.tsx | 61 +++++
components/ChainConnect/CustomChainForm.tsx | 256 ++++++++++++++++++
components/ChainConnect/DialogButton.tsx | 43 +++
components/ChainConnect/TabButton.tsx | 23 ++
components/ChainConnect/index.tsx | 52 ++++
9 files changed, 704 insertions(+)
create mode 100644 components/ChainConnect/ChainDigest.tsx
create mode 100644 components/ChainConnect/ChainItem.tsx
create mode 100644 components/ChainConnect/ChainsGroup.tsx
create mode 100644 components/ChainConnect/ChooseChain.tsx
create mode 100644 components/ChainConnect/ConfirmConnection.tsx
create mode 100644 components/ChainConnect/CustomChainForm.tsx
create mode 100644 components/ChainConnect/DialogButton.tsx
create mode 100644 components/ChainConnect/TabButton.tsx
create mode 100644 components/ChainConnect/index.tsx
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 (
+
+
+ );
+}
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 (
+
+ );
+}