feat: network picker redesign (#164)
* feat: network picker redesign * fix default endpoint in networks with only 1 RPC
This commit is contained in:
@@ -9,7 +9,7 @@ import { BehaviorSubject } from "rxjs"
|
||||
|
||||
export const chopsticksInstance$ = new BehaviorSubject<Blockchain | null>(null)
|
||||
|
||||
export const createChopsticksProvider = (endpoint: string) =>
|
||||
export const createChopsticksProvider = (endpoint: string | string[]) =>
|
||||
withChopsticksEnhancer(
|
||||
getSyncProvider((onReady) => {
|
||||
let isRunning = true
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { CommandPopover } from "@/components/CommandPopover"
|
||||
import { CopyText } from "@/components/Copy"
|
||||
import { Chopsticks } from "@/components/Icons"
|
||||
import { Chopsticks, Spinner } from "@/components/Icons"
|
||||
import SliderToggle from "@/components/Toggle"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
@@ -20,9 +21,10 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
isValidUri,
|
||||
AUTO_RPC_ENDPOINT,
|
||||
currentWsStatus$,
|
||||
LIGHT_CLIENT_ENDPOINT,
|
||||
Network,
|
||||
networkCategories,
|
||||
onChangeChain,
|
||||
@@ -33,53 +35,14 @@ import { addCustomNetwork, getCustomNetwork } from "@/state/chains/networks"
|
||||
import { Input } from "@polkahub/ui-components"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { FC, useState } from "react"
|
||||
import { StatusChange } from "polkadot-api/ws"
|
||||
import { FC, useMemo, useState } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function NetworkSwitcher({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedChain = useStateObservable(selectedChain$)
|
||||
|
||||
const getChainName = () => {
|
||||
if (selectedChain.network.id === "custom") {
|
||||
try {
|
||||
const url = new URL(selectedChain.endpoint)
|
||||
if (["127.0.0.1", "localhost"].includes(url.hostname)) {
|
||||
return "Localhost"
|
||||
}
|
||||
|
||||
return url.hostname
|
||||
} catch {
|
||||
return selectedChain.endpoint
|
||||
}
|
||||
}
|
||||
return selectedChain.network.display
|
||||
}
|
||||
const getNodeName = () => {
|
||||
if (selectedChain.withChopsticks) {
|
||||
return <Chopsticks className="inline-block align-text-top" />
|
||||
}
|
||||
|
||||
if (selectedChain.network.id === "localhost") {
|
||||
try {
|
||||
const url = new URL(selectedChain.endpoint)
|
||||
|
||||
return url.port
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedChain.endpoint === "light-client") {
|
||||
return "Smoldot"
|
||||
}
|
||||
|
||||
return (
|
||||
Object.entries(selectedChain.network.endpoints).find(
|
||||
([, e]) => selectedChain.endpoint === e,
|
||||
)?.[0] ?? selectedChain.endpoint
|
||||
)
|
||||
}
|
||||
const websocketStatus = useStateObservable(currentWsStatus$)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -87,21 +50,24 @@ export function NetworkSwitcher({ className }: { className?: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={twMerge(
|
||||
"gap-0 justify-between text-base px-3 border border-border bg-input self-center",
|
||||
"min-w-0 gap-2 justify-between text-base px-3 border border-border bg-input",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{getChainName()}
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
{getNodeName()}
|
||||
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-left flex items-center gap-1">
|
||||
{getChainLabel(selectedChain)}
|
||||
{selectedChain.withChopsticks ? (
|
||||
<Chopsticks className="inline-block align-text-top" />
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getConnectionLabel(selectedChain, websocketStatus)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<NetworkSwitchDialogContent
|
||||
key={selectedChain.endpoint}
|
||||
key={`${selectedChain.network.id}-${selectedChain.endpoint}`}
|
||||
selectedChain={selectedChain}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
@@ -113,55 +79,82 @@ const NetworkSwitchDialogContent: FC<{
|
||||
selectedChain: SelectedChain
|
||||
onClose: () => void
|
||||
}> = ({ selectedChain, onClose }) => {
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<Network>(
|
||||
selectedChain.network,
|
||||
)
|
||||
const currentRpc = selectedChain.endpoint ?? "light-client"
|
||||
const [selectedRpc, setSelectedRpc] = useState<string>(currentRpc)
|
||||
const [enteredText, setEnteredText] = useState<string>("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [network, setNetwork] = useState(selectedChain.network)
|
||||
const [endpoint, setEndpoint] = useState(selectedChain.endpoint)
|
||||
const [withChopsticks, setWithChopsticks] = useState(
|
||||
selectedChain.withChopsticks ?? false,
|
||||
selectedChain.withChopsticks,
|
||||
)
|
||||
|
||||
const customUrl = normalizeWsUrl(query)
|
||||
const canFork = endpoint !== LIGHT_CLIENT_ENDPOINT
|
||||
const forked = canFork && withChopsticks
|
||||
const hasChanged =
|
||||
selectedNetwork.id !== selectedChain.network.id ||
|
||||
selectedRpc !== currentRpc ||
|
||||
selectedChain.withChopsticks !== withChopsticks
|
||||
network.id !== selectedChain.network.id ||
|
||||
endpoint !== selectedChain.endpoint ||
|
||||
forked !== selectedChain.withChopsticks
|
||||
|
||||
const handleNetworkSelect = (network: Network) => {
|
||||
if (network === selectedNetwork) return
|
||||
|
||||
setSelectedNetwork(network)
|
||||
setSelectedRpc(
|
||||
network.lightclient
|
||||
? "light-client"
|
||||
: Object.values(network.endpoints)[0],
|
||||
)
|
||||
const selectNetwork = (
|
||||
next: Network,
|
||||
nextEndpoint = defaultEndpoint(next),
|
||||
) => {
|
||||
setNetwork(next)
|
||||
setEndpoint(nextEndpoint)
|
||||
if (nextEndpoint === LIGHT_CLIENT_ENDPOINT) {
|
||||
setWithChopsticks(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const chopsticksEnabled = selectedRpc !== "light-client" && withChopsticks
|
||||
if (selectedNetwork.id === "custom") {
|
||||
addCustomNetwork(selectedRpc)
|
||||
const selectCustomUrl = (url: string) => {
|
||||
setNetwork(createCustomNetwork(url))
|
||||
setEndpoint(url)
|
||||
}
|
||||
|
||||
const setConnection = (nextEndpoint: string) => {
|
||||
setEndpoint(nextEndpoint)
|
||||
if (nextEndpoint === LIGHT_CLIENT_ENDPOINT) {
|
||||
setWithChopsticks(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChopsticks = () => {
|
||||
if (endpoint === LIGHT_CLIENT_ENDPOINT) return
|
||||
setWithChopsticks(!withChopsticks)
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (network.id === "custom") {
|
||||
if (
|
||||
endpoint.startsWith("ws://localhost") ||
|
||||
endpoint.startsWith("ws://127.0.0.1")
|
||||
) {
|
||||
const localNetwork = networkCategories.find(
|
||||
(cat) => cat.name === "Localhost",
|
||||
)?.networks[0]
|
||||
if (localNetwork) {
|
||||
onChangeChain({
|
||||
network: localNetwork,
|
||||
endpoint,
|
||||
withChopsticks: forked,
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
addCustomNetwork(endpoint)
|
||||
onChangeChain({
|
||||
network: getCustomNetwork(),
|
||||
endpoint: selectedRpc,
|
||||
withChopsticks: chopsticksEnabled,
|
||||
endpoint,
|
||||
withChopsticks: forked,
|
||||
})
|
||||
setEnteredText("")
|
||||
} else {
|
||||
onChangeChain({
|
||||
network: selectedNetwork,
|
||||
endpoint: selectedRpc,
|
||||
withChopsticks: chopsticksEnabled,
|
||||
})
|
||||
onChangeChain({ network, endpoint, withChopsticks: forked })
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="sm:max-w-106.25 sm:min-w-106.25 min-h-112.5 max-h-full flex flex-col w-auto"
|
||||
className="flex h-[min(720px,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-160 flex-col"
|
||||
onEscapeKeyDown={(evt) => {
|
||||
if (
|
||||
evt.target instanceof HTMLElement &&
|
||||
@@ -175,282 +168,396 @@ const NetworkSwitchDialogContent: FC<{
|
||||
<DialogHeader>
|
||||
<DialogTitle>Switch Network</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="flex flex-col overflow-hidden gap-2">
|
||||
<div className="h-full grow flex flex-col">
|
||||
<CommandPopover
|
||||
placeholder="Search or enter a custom URI"
|
||||
value={enteredText}
|
||||
onValueChange={setEnteredText}
|
||||
selectedValue={selectedNetwork.id}
|
||||
>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-foreground/50">No networks found.</div>
|
||||
</CommandEmpty>
|
||||
<ScrollArea className="h-65">
|
||||
{networkCategories.map((category) => {
|
||||
if (category.name === "Custom") {
|
||||
if (
|
||||
!isValidUri(enteredText) ||
|
||||
enteredText.startsWith("localhost:")
|
||||
)
|
||||
return null
|
||||
return (
|
||||
<CommandGroup key={category.name} heading={category.name}>
|
||||
<CommandItem
|
||||
value={enteredText}
|
||||
onSelect={() => {
|
||||
handleNetworkSelect({
|
||||
id: "custom",
|
||||
lightclient: false,
|
||||
endpoints: { custom: enteredText },
|
||||
display: enteredText,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === "custom"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{enteredText}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandGroup key={category.name} heading={category.name}>
|
||||
{category.networks.map((network) => (
|
||||
<CommandItem
|
||||
key={network.id}
|
||||
onSelect={() => handleNetworkSelect(network)}
|
||||
value={
|
||||
network.display.includes(category.name)
|
||||
? network.display
|
||||
: `${category.name} ${network.display}`
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === network.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{network.display}
|
||||
</CommandItem>
|
||||
))}
|
||||
{category.name === "Localhost" &&
|
||||
enteredText.startsWith("localhost:") ? (
|
||||
<CommandItem
|
||||
value={enteredText}
|
||||
onSelect={() => {
|
||||
handleNetworkSelect({
|
||||
id: "localhost",
|
||||
lightclient: false,
|
||||
endpoints: { custom: `ws://${enteredText}` },
|
||||
display: enteredText,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === "custom"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{enteredText}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandPopover>
|
||||
<div className="h-[50vh] flex flex-col gap-2">
|
||||
{selectedNetwork ? (
|
||||
<div className="grow overflow-hidden flex flex-col">
|
||||
<p className="py-2">
|
||||
Network:{" "}
|
||||
{selectedNetwork.id === "custom"
|
||||
? "Custom"
|
||||
: selectedNetwork.display}
|
||||
</p>
|
||||
<div className="overflow-auto">
|
||||
<RadioGroup
|
||||
value={selectedRpc}
|
||||
onValueChange={setSelectedRpc}
|
||||
>
|
||||
{selectedNetwork.lightclient ? (
|
||||
<ConnectionOption
|
||||
value="light-client"
|
||||
isSelected={selectedRpc === "light-client"}
|
||||
name="Smoldot"
|
||||
type="light"
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(selectedNetwork.endpoints).map(
|
||||
([rpcName, url]) => (
|
||||
<ConnectionOption
|
||||
key={rpcName}
|
||||
value={url}
|
||||
isSelected={selectedRpc === url}
|
||||
name={rpcName}
|
||||
type="rpc"
|
||||
url={url}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{selectedNetwork.id === "localhost" ? (
|
||||
<CustomPort
|
||||
selectedRpc={selectedRpc}
|
||||
setSelectedRpc={setSelectedRpc}
|
||||
/>
|
||||
) : null}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRpc && selectedRpc !== "light-client" && (
|
||||
<div className="mt-4 p-3 border rounded-md bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Chopsticks size={20} />
|
||||
<Label
|
||||
htmlFor="use-chopsticks"
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
Fork with Chopsticks
|
||||
</Label>
|
||||
</div>
|
||||
<SliderToggle
|
||||
id="use-chopsticks"
|
||||
isToggled={withChopsticks}
|
||||
toggle={() => setWithChopsticks(!withChopsticks)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
Create a local fork of this chain
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogBody className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden md:flex-row">
|
||||
<NetworkList
|
||||
query={query}
|
||||
selectedNetwork={network}
|
||||
customUrl={customUrl}
|
||||
onQueryChange={setQuery}
|
||||
onNetworkSelect={selectNetwork}
|
||||
onCustomSelect={selectCustomUrl}
|
||||
/>
|
||||
<ConnectionList
|
||||
network={network}
|
||||
endpoint={endpoint}
|
||||
onEndpointChange={setConnection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t pt-3">
|
||||
{canFork ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chopsticks size={18} />
|
||||
<Label htmlFor="use-chopsticks" className="cursor-pointer">
|
||||
Fork with Chopsticks
|
||||
</Label>
|
||||
<SliderToggle
|
||||
id="use-chopsticks"
|
||||
isToggled={forked}
|
||||
toggle={toggleChopsticks}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
className="min-w-28"
|
||||
onClick={confirm}
|
||||
disabled={
|
||||
!hasChanged ||
|
||||
(network.id === "custom" && !normalizeWsUrl(endpoint))
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
!selectedNetwork ||
|
||||
!hasChanged ||
|
||||
(selectedNetwork.id === "custom" && !selectedRpc)
|
||||
}
|
||||
>
|
||||
Confirm Selection
|
||||
</Button>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionOption: FC<{
|
||||
isSelected: boolean
|
||||
value: string
|
||||
name: string
|
||||
type: "light" | "rpc"
|
||||
url?: string
|
||||
}> = ({ isSelected, value, name, type, url }) => (
|
||||
<div
|
||||
className={`overflow-hidden p-3 border rounded-md ${isSelected ? "border-polkadot bg-polkadot/5" : "border-border"}`}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<RadioGroupItem value={value} id={`chain-${value}`} className="mt-1" />
|
||||
<div className="grid gap-0.5 grow">
|
||||
<Label htmlFor={`chain-${value}`} className="font-medium">
|
||||
{name}
|
||||
{type === "light" ? (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
Light Client
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
RPC
|
||||
</Badge>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{type === "light"
|
||||
? "Light client for a decentralized experience"
|
||||
: url?.includes("127.0.0.1")
|
||||
? "Local RPC node"
|
||||
: "Remote RPC node"}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show URL for RPC endpoints */}
|
||||
{url ? (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<code className="text-xs bg-muted p-1 rounded block overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{url}
|
||||
</code>
|
||||
</div>
|
||||
<CopyText text={url} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const CustomPort: FC<{
|
||||
selectedRpc: string
|
||||
setSelectedRpc: (rpc: string) => void
|
||||
}> = ({ selectedRpc, setSelectedRpc }) => {
|
||||
const [port, setPort] = useState(
|
||||
selectedRpc.startsWith("ws://localhost:")
|
||||
? selectedRpc.slice("ws://localhost:".length)
|
||||
: "",
|
||||
const NetworkList: FC<{
|
||||
query: string
|
||||
selectedNetwork: Network
|
||||
customUrl: string | null
|
||||
onQueryChange: (value: string) => void
|
||||
onNetworkSelect: (network: Network) => void
|
||||
onCustomSelect: (url: string) => void
|
||||
}> = ({
|
||||
query,
|
||||
selectedNetwork,
|
||||
customUrl,
|
||||
onQueryChange,
|
||||
onNetworkSelect,
|
||||
onCustomSelect,
|
||||
}) => {
|
||||
const categories = useMemo(
|
||||
() => networkCategories.filter((category) => category.name !== "Custom"),
|
||||
[],
|
||||
)
|
||||
const value = `ws://localhost:${port}`
|
||||
const isSelected = selectedRpc === value
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden p-3 border rounded-md ${isSelected ? "border-polkadot bg-polkadot/5" : "border-border"}`}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<RadioGroupItem
|
||||
value={value}
|
||||
id="chain-localhost-other"
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="grid gap-0.5 grow">
|
||||
<Label htmlFor="chain-localhost-other" className="font-medium">
|
||||
Other ports
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
RPC
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">Local RPC node</p>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Command className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border bg-transparent">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={onQueryChange}
|
||||
placeholder="Search chains, or enter a WebSocket URL"
|
||||
className="border-none focus:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<CommandEmpty>
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No matching chains or endpoints.
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{customUrl ? (
|
||||
<CommandGroup heading="Custom">
|
||||
<NetworkItem
|
||||
value={`custom websocket rpc url ${customUrl}`}
|
||||
selected={selectedNetwork.id === "custom"}
|
||||
title={customUrl}
|
||||
onSelect={() => onCustomSelect(customUrl)}
|
||||
/>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{categories.map((category) => (
|
||||
<CommandGroup key={category.name} heading={category.name}>
|
||||
{category.networks.map((network) => (
|
||||
<NetworkItem
|
||||
key={network.id}
|
||||
value={networkSearchValue(category.name, network)}
|
||||
selected={selectedNetwork.id === network.id}
|
||||
title={network.display}
|
||||
onSelect={() => onNetworkSelect(network)}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Port"
|
||||
value={port}
|
||||
onChange={(evt) => {
|
||||
setPort(evt.target.value)
|
||||
setSelectedRpc(`ws://localhost:${evt.target.value}`)
|
||||
}}
|
||||
/>
|
||||
const NetworkItem: FC<{
|
||||
value: string
|
||||
selected: boolean
|
||||
title: string
|
||||
onSelect: () => void
|
||||
}> = ({ value, selected, title, onSelect }) => (
|
||||
<CommandItem value={value} onSelect={onSelect}>
|
||||
<Check
|
||||
className={twMerge(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="truncate text-sm">{title}</div>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
const ConnectionList: FC<{
|
||||
network: Network
|
||||
endpoint: string
|
||||
onEndpointChange: (endpoint: string) => void
|
||||
}> = ({ network, endpoint, onEndpointChange }) => {
|
||||
const entries = Object.entries(network.endpoints)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border">
|
||||
<div className="shrink-0 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Connection
|
||||
</div>
|
||||
<RadioGroup
|
||||
className="min-h-0 overflow-y-auto overflow-x-hidden p-3 space-y-1"
|
||||
value={endpoint}
|
||||
onValueChange={onEndpointChange}
|
||||
>
|
||||
{network.lightclient ? (
|
||||
<ConnectionOption
|
||||
value={LIGHT_CLIENT_ENDPOINT}
|
||||
selected={endpoint === LIGHT_CLIENT_ENDPOINT}
|
||||
title="Smoldot"
|
||||
subtitle="Light client"
|
||||
badge="Default"
|
||||
/>
|
||||
) : null}
|
||||
{entries.length > 1 &&
|
||||
network.id !== "custom" &&
|
||||
network.id !== "localhost" ? (
|
||||
<ConnectionOption
|
||||
value={AUTO_RPC_ENDPOINT}
|
||||
selected={endpoint === AUTO_RPC_ENDPOINT}
|
||||
title="Any WS RPC"
|
||||
subtitle={`One of ${entries.length} endpoints`}
|
||||
/>
|
||||
) : null}
|
||||
{network.id !== "custom"
|
||||
? entries.map(([name, url]) => (
|
||||
<ConnectionOption
|
||||
key={url}
|
||||
value={url}
|
||||
selected={endpoint === url}
|
||||
title={name}
|
||||
subtitle={formatUrl(url)}
|
||||
copy={url}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{network.id === "localhost" ? (
|
||||
<CustomPortOption
|
||||
endpoint={endpoint}
|
||||
knownEndpoints={entries.map(([, url]) => url)}
|
||||
onEndpointChange={onEndpointChange}
|
||||
/>
|
||||
) : null}
|
||||
{network.id === "custom" && endpoint ? (
|
||||
<ConnectionOption
|
||||
value={endpoint}
|
||||
selected
|
||||
title="WebSocket URL"
|
||||
subtitle={formatUrl(endpoint)}
|
||||
copy={endpoint}
|
||||
/>
|
||||
) : null}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomPortOption: FC<{
|
||||
endpoint: string
|
||||
knownEndpoints: string[]
|
||||
onEndpointChange: (endpoint: string) => void
|
||||
}> = ({ endpoint, knownEndpoints, onEndpointChange }) => {
|
||||
const isKnownEndpoint = (port: string) =>
|
||||
knownEndpoints.some(
|
||||
(knownEndpoint) => getLocalhostPort(knownEndpoint) === port,
|
||||
)
|
||||
const [port, setPort] = useState(() => {
|
||||
const initialPort = getLocalhostPort(endpoint)
|
||||
return !initialPort || isKnownEndpoint(initialPort) ? "" : initialPort
|
||||
})
|
||||
const endpointValue = port ? `ws://127.0.0.1:${port}` : ""
|
||||
const value =
|
||||
port && !isKnownEndpoint(port)
|
||||
? port === getLocalhostPort(endpoint)
|
||||
? endpoint
|
||||
: endpointValue
|
||||
: `custom-localhost-port-${port || "empty"}`
|
||||
const selected =
|
||||
!!port && getLocalhostPort(endpoint) === port && !isKnownEndpoint(port)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded-md border border-transparent px-3 py-2",
|
||||
selected && !isKnownEndpoint(port) && "border-polkadot bg-polkadot/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={value}
|
||||
id="connection-localhost-custom-port"
|
||||
disabled={!port || isKnownEndpoint(port)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="connection-localhost-custom-port"
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">Other ports</span>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
RPC
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
Local RPC node
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Port"
|
||||
value={port}
|
||||
className="mt-2"
|
||||
onChange={(evt) => {
|
||||
const nextPort = evt.target.value
|
||||
setPort(nextPort)
|
||||
if (nextPort) onEndpointChange(`ws://127.0.0.1:${nextPort}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionOption: FC<{
|
||||
value: string
|
||||
selected: boolean
|
||||
title: string
|
||||
subtitle: string
|
||||
badge?: string
|
||||
copy?: string
|
||||
}> = ({ value, selected, title, subtitle, badge, copy }) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex min-w-0 items-center gap-3 rounded-md border border-transparent px-3 py-2",
|
||||
selected && "border-polkadot bg-polkadot/5",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value={value} id={`connection-${value}`} />
|
||||
<Label
|
||||
htmlFor={`connection-${value}`}
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{badge ? (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||
</Label>
|
||||
{copy ? <CopyText text={copy} /> : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const defaultEndpoint = (network: Network) => {
|
||||
if (network.lightclient) return LIGHT_CLIENT_ENDPOINT
|
||||
const endpoints = Object.values(network.endpoints)
|
||||
return network.id === "localhost" || endpoints.length === 1
|
||||
? endpoints[0]
|
||||
: AUTO_RPC_ENDPOINT
|
||||
}
|
||||
|
||||
const createCustomNetwork = (url: string): Network => ({
|
||||
id: "custom",
|
||||
display: url,
|
||||
lightclient: false,
|
||||
endpoints: { "WebSocket URL": url },
|
||||
})
|
||||
|
||||
const normalizeWsUrl = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const candidate =
|
||||
trimmed.startsWith("localhost:") || trimmed.startsWith("127.0.0.1:")
|
||||
? `ws://${trimmed}`
|
||||
: trimmed
|
||||
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
return url.protocol === "ws:" || url.protocol === "wss:"
|
||||
? url.toString()
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getLocalhostPort = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return isLocalUrl(url) ? url.port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const networkSearchValue = (category: string, network: Network) =>
|
||||
[category, network.display, network.id].join(" ")
|
||||
|
||||
const getChainLabel = ({ network, endpoint }: SelectedChain) => {
|
||||
if (network.id !== "custom") return network.display
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return isLocalUrl(url) ? "Localhost" : url.hostname
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
const getConnectionLabel = (
|
||||
selectedChain: SelectedChain,
|
||||
websocketStatus: StatusChange | null,
|
||||
) => {
|
||||
const { network, endpoint } = selectedChain
|
||||
if (endpoint === LIGHT_CLIENT_ENDPOINT) return "Smoldot"
|
||||
if (endpoint === AUTO_RPC_ENDPOINT) {
|
||||
if (!websocketStatus) return <Spinner className="inline-block" />
|
||||
|
||||
const isReady = "uri" in websocketStatus
|
||||
const activeEndpoint = isReady
|
||||
? findEndpointName(network, websocketStatus.uri)
|
||||
: null
|
||||
return (
|
||||
activeEndpoint ??
|
||||
(isReady ? (
|
||||
formatUrl(websocketStatus.uri)
|
||||
) : (
|
||||
<Spinner className="inline-block" />
|
||||
))
|
||||
)
|
||||
}
|
||||
return findEndpointName(network, endpoint) ?? formatUrl(endpoint)
|
||||
}
|
||||
|
||||
const findEndpointName = (network: Network, endpoint: string) =>
|
||||
Object.entries(network.endpoints).find(([, url]) => endpoint === url)?.[0]
|
||||
|
||||
const formatUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (isLocalUrl(url))
|
||||
return url.port ? `${url.hostname}:${url.port}` : url.hostname
|
||||
return url.hostname
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const isLocalUrl = (url: URL) =>
|
||||
url.hostname === "localhost" || url.hostname === "127.0.0.1"
|
||||
|
||||
@@ -17,10 +17,16 @@ import {
|
||||
state,
|
||||
StateObservable,
|
||||
SUSPENSE,
|
||||
withDefault,
|
||||
} from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { get, update } from "idb-keyval"
|
||||
import { ChainDefinition, createClient, TypedApi } from "polkadot-api"
|
||||
import {
|
||||
ChainDefinition,
|
||||
createClient,
|
||||
JsonRpcProvider,
|
||||
TypedApi,
|
||||
} from "polkadot-api"
|
||||
import { withLogsRecorder } from "polkadot-api/logs-provider"
|
||||
import { fromHex, toHex } from "polkadot-api/utils"
|
||||
import {
|
||||
@@ -56,9 +62,12 @@ import {
|
||||
createWebsocketSource,
|
||||
getWebsocketProvider,
|
||||
WebsocketSource,
|
||||
WsStatusJsonRpcProvider,
|
||||
} from "./websocket"
|
||||
|
||||
export type ChainSource = WebsocketSource | SmoldotSource
|
||||
export const LIGHT_CLIENT_ENDPOINT = "light-client"
|
||||
export const AUTO_RPC_ENDPOINT = "auto-rpc"
|
||||
|
||||
export type SelectedChain = {
|
||||
network: Network
|
||||
@@ -67,12 +76,21 @@ export type SelectedChain = {
|
||||
}
|
||||
export const getChainSource = ({
|
||||
endpoint,
|
||||
network: { id, relayChain },
|
||||
network,
|
||||
withChopsticks,
|
||||
}: SelectedChain) =>
|
||||
endpoint === "light-client"
|
||||
? createSmoldotSource(id, relayChain)
|
||||
: createWebsocketSource(id, endpoint, withChopsticks)
|
||||
endpoint === LIGHT_CLIENT_ENDPOINT
|
||||
? createSmoldotSource(network.id, network.relayChain)
|
||||
: createWebsocketSource(
|
||||
network.id,
|
||||
endpoint === AUTO_RPC_ENDPOINT ? shuffleEndpoints(network) : endpoint,
|
||||
withChopsticks,
|
||||
)
|
||||
const shuffleEndpoints = (network: Network) =>
|
||||
Object.values(network.endpoints)
|
||||
.map((endpoint) => ({ endpoint, luckyNumber: Math.random() }))
|
||||
.sort((a, b) => a.luckyNumber - b.luckyNumber)
|
||||
.map(({ endpoint }) => endpoint)
|
||||
|
||||
const setRpcLogsEnabled = (enabled: boolean) =>
|
||||
localStorage.setItem("rpc-logs", String(enabled))
|
||||
@@ -88,19 +106,23 @@ export const getProvider = (source: ChainSource) => {
|
||||
: getWebsocketProvider(source)
|
||||
: getSmoldotProvider(source)
|
||||
|
||||
return withLogsRecorder((msg) => {
|
||||
const recorder = withLogsRecorder((msg) => {
|
||||
if (import.meta.env.DEV || getRpcLogsEnabled()) {
|
||||
console.debug(msg)
|
||||
}
|
||||
}, provider)
|
||||
|
||||
// Bring over extra properties from the original provider.
|
||||
return Object.assign(recorder, provider)
|
||||
}
|
||||
|
||||
export const [selectedChainChanged$, onChangeChain] =
|
||||
createSignal<SelectedChain>()
|
||||
selectedChainChanged$.subscribe(({ network, endpoint }) =>
|
||||
selectedChainChanged$.subscribe(({ network, endpoint, withChopsticks }) =>
|
||||
setHashParams({
|
||||
networkId: network.id,
|
||||
endpoint,
|
||||
chopsticks: withChopsticks ? "true" : null,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -119,7 +141,7 @@ export const isValidUri = (input: string): boolean => {
|
||||
|
||||
const defaultSelectedChain: SelectedChain = {
|
||||
network: defaultNetwork,
|
||||
endpoint: "light-client",
|
||||
endpoint: LIGHT_CLIENT_ENDPOINT,
|
||||
withChopsticks: false,
|
||||
}
|
||||
const getDefaultChain = (): SelectedChain => {
|
||||
@@ -127,6 +149,9 @@ const getDefaultChain = (): SelectedChain => {
|
||||
if (hashParams.has("networkId") && hashParams.has("endpoint")) {
|
||||
const networkId = hashParams.get("networkId")!
|
||||
const endpoint = hashParams.get("endpoint")!
|
||||
const withChopsticks =
|
||||
hashParams.get("chopsticks") === "true" &&
|
||||
endpoint !== LIGHT_CLIENT_ENDPOINT
|
||||
|
||||
if (networkId === "custom") {
|
||||
if (!isValidUri(endpoint)) return defaultSelectedChain
|
||||
@@ -134,11 +159,11 @@ const getDefaultChain = (): SelectedChain => {
|
||||
return {
|
||||
network: getCustomNetwork(),
|
||||
endpoint,
|
||||
withChopsticks: false,
|
||||
withChopsticks,
|
||||
}
|
||||
}
|
||||
const network = findNetwork(networkId)
|
||||
if (network) return { network, endpoint, withChopsticks: false }
|
||||
if (network) return { network, endpoint, withChopsticks }
|
||||
}
|
||||
|
||||
return defaultSelectedChain
|
||||
@@ -221,7 +246,7 @@ export const chainClient$ = state(
|
||||
const chainHead: ChainHead$ = (client as any).___INTERNAL_DO_NOT_USE
|
||||
return concat(
|
||||
i === 0 ? EMPTY : of(SUSPENSE),
|
||||
of({ id, client, chainHead }),
|
||||
of({ id, client, chainHead, provider }),
|
||||
NEVER,
|
||||
).pipe(
|
||||
finalize(() => {
|
||||
@@ -232,7 +257,20 @@ export const chainClient$ = state(
|
||||
sinkSuspense(),
|
||||
),
|
||||
)
|
||||
export const client$ = state(chainClient$.pipe(map(({ client }) => client)))
|
||||
export const currentWsStatus$ = chainClient$.pipeState(
|
||||
switchMap(({ provider }) => {
|
||||
const isWsProvider = (
|
||||
provider: JsonRpcProvider,
|
||||
): provider is WsStatusJsonRpcProvider => "statusChange$" in provider
|
||||
|
||||
return isWsProvider(provider)
|
||||
? provider.statusChange$.pipe(startWith(provider.getStatus()))
|
||||
: of(null)
|
||||
}),
|
||||
withDefault(null),
|
||||
)
|
||||
|
||||
export const client$ = chainClient$.pipeState(map(({ client }) => client))
|
||||
export const canProduceBlocks$ = state(
|
||||
client$.pipe(
|
||||
switchMap((client) => client._request("rpc_methods", [])),
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import type { JsonRpcProvider } from "polkadot-api"
|
||||
import { getWsProvider } from "polkadot-api/ws"
|
||||
import { getWsProvider, StatusChange, WsJsonRpcProvider } from "polkadot-api/ws"
|
||||
import { Observable, Subject } from "rxjs"
|
||||
|
||||
export interface WebsocketSource {
|
||||
type: "websocket"
|
||||
id: string
|
||||
endpoint: string
|
||||
endpoint: string | string[]
|
||||
withChopsticks: boolean
|
||||
}
|
||||
|
||||
export async function createWebsocketSource(
|
||||
id: string,
|
||||
endpoint: string,
|
||||
endpoint: string | string[],
|
||||
withChopsticks: boolean,
|
||||
): Promise<WebsocketSource> {
|
||||
return { type: "websocket", id, endpoint, withChopsticks }
|
||||
}
|
||||
|
||||
export function getWebsocketProvider(source: WebsocketSource): JsonRpcProvider {
|
||||
return getWsProvider(source.endpoint)
|
||||
export type WsStatusJsonRpcProvider = WsJsonRpcProvider & {
|
||||
statusChange$: Observable<StatusChange>
|
||||
}
|
||||
export function getWebsocketProvider(
|
||||
source: WebsocketSource,
|
||||
): WsStatusJsonRpcProvider {
|
||||
const statusChange$ = new Subject<StatusChange>()
|
||||
const provider = getWsProvider(source.endpoint, {
|
||||
onStatusChanged(status) {
|
||||
statusChange$.next(status)
|
||||
},
|
||||
})
|
||||
|
||||
return Object.assign(provider, {
|
||||
statusChange$: statusChange$.asObservable(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user