feat: network picker redesign (#164)

* feat: network picker redesign

* fix default endpoint in networks with only 1 RPC
This commit is contained in:
Victor Oliva
2026-06-30 10:58:25 +02:00
committed by GitHub
parent 43fd7151a8
commit 61ff8717eb
4 changed files with 529 additions and 370 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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", [])),

View File

@@ -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(),
})
}