Add new ChainConnect

This commit is contained in:
abefernan
2023-11-20 14:29:53 +01:00
parent 80d264b471
commit a75b379012
9 changed files with 704 additions and 0 deletions
+104
View File
@@ -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 (
<div className="space-y-1">
<div className="column flex flex-wrap items-center gap-2">
<Avatar className="overflow-visible">
{!simplify && connectedChain.registryName === chain.registryName ? (
<CheckCircle
style={{
position: "absolute",
top: "-6px",
left: "-6px",
border: "2px solid rgb(134 239 172 / var(--tw-bg-opacity))",
borderRadius: "50%",
width: "20px",
height: "20px",
background: "rgb(134 239 172 / var(--tw-bg-opacity))",
color: "rgb(20 83 45 / var(--tw-text-opacity))",
}}
/>
) : null}
<AvatarImage src={chain.logo} alt={`${chain.chainDisplayName} logo`} className="h-auto" />
<AvatarFallback>{chain.registryName.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<h4 className="text-sm font-semibold">{chain.chainDisplayName}</h4>
<Badge>{chain.chainId}</Badge>
</div>
<h4 className="text-sm font-semibold">Fee token: {chain.displayDenom}</h4>
<Collapsible className="w-[350px] space-y-2">
<div className="flex items-center space-x-4">
{!simplify && chain.nodeAddresses.length > 1 ? (
<CollapsibleTrigger asChild>
<Button size="sm" className="p-1">
<ChevronsUpDown className="black h-4 w-4" />
<span className="sr-only">Toggle</span>
<h4 className="text-sm font-semibold">RPC endpoints:</h4>
</Button>
</CollapsibleTrigger>
) : (
<h4 className="text-sm font-semibold">RPC endpoint:</h4>
)}
</div>
<div className="rounded-md border px-2 py-1 font-mono text-sm">
{chain.nodeAddress || chain.nodeAddresses[0]}
</div>
<CollapsibleContent className="space-y-2">
{chain.nodeAddresses
.filter(
(address, _, nodeAddresses) =>
(chain.nodeAddress && address !== chain.nodeAddress) ||
(!chain.nodeAddress && address !== nodeAddresses[0]),
)
.map((address) => (
<div key={address} className="rounded-md border px-2 py-1 font-mono text-sm">
{address}
</div>
))}
</CollapsibleContent>
</Collapsible>
{!simplify && chains.localnets.has(chain.registryName) ? (
<div className="flex justify-center pt-2">
<ButtonWithConfirm
onClick={() => {
deleteLocalChainFromStorage(chain.registryName, chains);
}}
text="Delete custom chain"
confirmText="Confirm deletion?"
disabled={connectedChain.registryName === chain.registryName}
/>
</div>
) : !simplify ? (
<div className="flex items-center pt-2 hover:cursor-pointer">
<ExternalLink className="mr-2 h-4 w-4" />
<Link
href={`https://github.com/cosmos/chain-registry/tree/master/${
chains.testnets.has(chain.registryName) ? `testnets/` : ""
}${chain.registryName}`}
target="_blank"
className="text-xs underline"
>
Check it out on the registry
</Link>
</div>
) : null}
</div>
);
}
+72
View File
@@ -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 (
<HoverCard key={chain.registryName} openDelay={300}>
<HoverCardTrigger asChild>
<CommandItem
value={chain.registryName}
onSelect={
connectedChain.registryName === chain.registryName
? () => {}
: () => {
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 ? (
<CheckCircle
style={{
position: "absolute",
top: "-6px",
left: "-6px",
border: "2px solid rgb(134 239 172 / var(--tw-bg-opacity))",
borderRadius: "50%",
width: "25px",
height: "25px",
background: "rgb(134 239 172 / var(--tw-bg-opacity))",
color: "rgb(20 83 45 / var(--tw-text-opacity))",
}}
/>
) : null}
<Avatar>
<AvatarImage
src={chain.logo}
alt={`${chain.chainDisplayName} logo`}
className="h-auto"
/>
<AvatarFallback>{chain.registryName.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
{chain.registryName}
</CommandItem>
</HoverCardTrigger>
<HoverCardContent
className="w-auto bg-fuchsia-900"
collisionBoundary={hoverCardElementBoundary}
>
<ChainDigest chain={chain} />
</HoverCardContent>
</HoverCard>
);
}
+35
View File
@@ -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<HTMLDivElement>(null);
return (
<CommandGroup
heading={heading}
className="[&_[cmdk-group-heading]]:my-3 [&_[cmdk-group-heading]]:text-base [&_[cmdk-group-heading]]:leading-[0] [&_[cmdk-group-heading]]:text-white [&_[cmdk-group-heading]]:underline"
>
{chains.length ? (
<div ref={containerRef} className="flex flex-wrap gap-2">
{chains.map((chain) => (
<ChainItem
key={chain.registryName}
chain={chain}
hoverCardElementBoundary={containerRef.current}
/>
))}
</div>
) : (
<p className="ml-3 max-w-none">{emptyMsg}</p>
)}
</CommandGroup>
);
}
+58
View File
@@ -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<readonly ChainInfo[]>([]);
useLayoutEffect(() => {
const newRecentChains = getRecentChainsFromStorage(chains);
setRecentChains(newRecentChains);
}, [chains]);
return (
<Command
className="bg-fuchsia-900 text-white"
style={
{
"--accent": "0, 100%, 100%",
"--border": "0, 100%, 100%",
} as React.CSSProperties
}
>
<CommandInput placeholder="Type a chain name or id…" />
<div className="overflow-x-auto overflow-y-auto">
<CommandList className="max-h-full overflow-x-hidden overflow-y-visible">
<CommandEmpty>No results found.</CommandEmpty>
<ChainsGroup
chains={recentChains}
heading="Recently used chains:"
emptyMsg="No recent chains found."
/>
<CommandSeparator className="m-2" />
<ChainsGroup
chains={Array.from(chains.localnets.values())}
heading="Custom chains:"
emptyMsg={`No custom chains found. You can add one on the "Custom chain" tab.`}
/>
<CommandSeparator className="m-2" />
<ChainsGroup
chains={Array.from(chains.mainnets.values())}
heading="Mainnets:"
emptyMsg="No mainnets chains found."
/>
<CommandSeparator className="m-2" />
<ChainsGroup
chains={Array.from(chains.testnets.values())}
heading="Testnets:"
emptyMsg="No testnets chains found."
/>
</CommandList>
</div>
</Command>
);
}
@@ -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 (
<>
<h3>
Disconnect from "{chain.registryName}" and connect to "{newConnection.chain.registryName}"?
</h3>
<p className="max-w-none">
You will be redirected to the homepage and any filled form will be lost
</p>
<div
className="flex flex-wrap items-center justify-around gap-4"
style={{ "--border": "0, 100%, 100%" } as React.CSSProperties}
>
<div className="rounded-md border border-white p-4">
<ChainDigest chain={chain} simplify />
</div>
<div className="rounded-md border border-white p-4">
<ChainDigest chain={newConnection.chain} simplify />
</div>
</div>
<div className="flex gap-4">
<Button
variant="secondary"
className="mt-4"
onClick={() => {
setNewConnection(chainsDispatch, { ...newConnection, action: "edit" });
}}
>
Edit chain
</Button>
<Button
className="mt-4"
onClick={() => {
setChain(chainsDispatch, newConnection.chain);
closeDialog();
router.push("/");
}}
>
Connect
</Button>
</div>
</>
);
}
+256
View File
@@ -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<z.infer<typeof formSchema>>({
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<typeof formSchema>) {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid grid-cols-3 gap-4">
<FormField
name="localRegistryName"
render={({ field }) => (
<FormItem>
<FormLabel>Local Registry Name</FormLabel>
<FormControl>
<Input placeholder="mynetwork" className="border-white" {...field} />
</FormControl>
<FormDescription>
A unique key to store this chain on your local registry
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="chainName"
render={({ field }) => (
<FormItem>
<FormLabel>Chain Name</FormLabel>
<FormControl>
<Input placeholder="My Network" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="chainId"
render={({ field }) => (
<FormItem>
<FormLabel>Chain ID</FormLabel>
<FormControl>
<Input placeholder="my-net-4" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="baseDenom"
render={({ field }) => (
<FormItem>
<FormLabel>Base Denom</FormLabel>
<FormControl>
<Input placeholder="umycoin" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="displayDenom"
render={({ field }) => (
<FormItem>
<FormLabel>Display Denom</FormLabel>
<FormControl>
<Input placeholder="MYCOIN" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="denomExponent"
render={({ field }) => (
<FormItem>
<FormLabel>Denom Exponent</FormLabel>
<FormControl>
<Input placeholder="6" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="bech32Prefix"
render={({ field }) => (
<FormItem>
<FormLabel>Address Prefix</FormLabel>
<FormControl>
<Input placeholder="mynet" className="border-white" {...field} />
</FormControl>
<FormDescription>Needs to be bech32</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="gasPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Gas Price</FormLabel>
<FormControl>
<Input placeholder="0.04umycoin" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="explorerLink"
render={({ field }) => (
<FormItem>
<FormLabel>Explorer Link</FormLabel>
<FormControl>
<Input placeholder="url" className="border-white" {...field} />
</FormControl>
<FormDescription>with {"'${txHash}'"} included</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
name="rpcNodes"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>RPC nodes</FormLabel>
<FormControl>
<Input placeholder="url1, url2, …, urln" className="border-white" {...field} />
</FormControl>
<FormDescription>Can be one or more, separated by commas</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="logo"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URI</FormLabel>
<FormControl>
<Input placeholder="logo" className="border-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="assets"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Assets</FormLabel>
<FormControl>
<div>
<JsonEditor
content={{ text: field.value }}
onChange={(newMsgContent) => {
field.onChange("text" in newMsgContent ? newMsgContent.text ?? "{}" : "{}");
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-4">
Submit
</Button>
</form>
</Form>
);
}
+43
View File
@@ -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) ? (
<>
<Avatar>
<AvatarImage src={chain.logo} alt={`${chain.chainDisplayName} logo`} />
<AvatarFallback>{chain.registryName.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<h1>{chain.chainDisplayName} Multisig</h1>
</>
) : (
<>
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
</div>
</>
);
}
export default function DialogButton() {
const showChainSelect = process.env.NEXT_PUBLIC_MULTICHAIN?.toLowerCase() === "true";
return showChainSelect ? (
<DialogTrigger>
<div className="group relative m-1 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 hover:bg-white hover:text-gray-900 focus:outline-none focus:ring-4 focus:ring-red-100">
<ChainHeader />
</div>
</DialogTrigger>
) : (
<div className="group relative m-1 inline-flex items-center justify-center gap-2 p-2.5 text-sm font-medium text-white">
<ChainHeader />
</div>
);
}
+23
View File
@@ -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<typeof TabsTrigger>) {
return (
<TabsTrigger
value={value}
className={cn(
"border-2 border-solid border-white bg-transparent text-white data-[state=active]:bg-white data-[state=active]:text-gray-900",
className,
)}
{...restProps}
>
{children}
</TabsTrigger>
);
}
+52
View File
@@ -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 (
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
setNewConnection(chainsDispatch, { action: "edit" });
}}
>
<DialogButton />
<DialogContent
className={"max-h-[75%] max-w-[75%] overflow-y-auto bg-fuchsia-900"}
style={newConnection.action === "confirm" ? { width: "auto" } : {}}
>
{newConnection.action === "confirm" ? (
<ConfirmConnection closeDialog={() => setDialogOpen(false)} />
) : (
<Tabs defaultValue={newConnection.chain ? tabs.custom : tabs.choose}>
<DialogHeader>
<TabsList className="justify-start gap-2 bg-transparent">
<TabButton value={tabs.choose}>Choose chain</TabButton>
<TabButton value={tabs.custom}>Custom chain</TabButton>
</TabsList>
</DialogHeader>
<TabsContent value={tabs.choose}>
<ChooseChain />
</TabsContent>
<TabsContent value={tabs.custom}>
<CustomChainForm />
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}