From 61ff8717eb8d33f8a13e41fac145c2b383a59468 Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Tue, 30 Jun 2026 10:58:25 +0200 Subject: [PATCH] feat: network picker redesign (#164) * feat: network picker redesign * fix default endpoint in networks with only 1 RPC --- src/chopsticks/chopsticks.ts | 2 +- src/pages/Network/Network.tsx | 809 ++++++++++++++++++-------------- src/state/chains/chain.state.ts | 62 ++- src/state/chains/websocket.ts | 26 +- 4 files changed, 529 insertions(+), 370 deletions(-) diff --git a/src/chopsticks/chopsticks.ts b/src/chopsticks/chopsticks.ts index c41195b..bf50336 100644 --- a/src/chopsticks/chopsticks.ts +++ b/src/chopsticks/chopsticks.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from "rxjs" export const chopsticksInstance$ = new BehaviorSubject(null) -export const createChopsticksProvider = (endpoint: string) => +export const createChopsticksProvider = (endpoint: string | string[]) => withChopsticksEnhancer( getSyncProvider((onReady) => { let isRunning = true diff --git a/src/pages/Network/Network.tsx b/src/pages/Network/Network.tsx index 8875fae..d9f5ee2 100644 --- a/src/pages/Network/Network.tsx +++ b/src/pages/Network/Network.tsx @@ -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 - } - - 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 ( @@ -87,21 +50,24 @@ export function NetworkSwitcher({ className }: { className?: string }) { setOpen(false)} /> @@ -113,55 +79,82 @@ const NetworkSwitchDialogContent: FC<{ selectedChain: SelectedChain onClose: () => void }> = ({ selectedChain, onClose }) => { - const [selectedNetwork, setSelectedNetwork] = useState( - selectedChain.network, - ) - const currentRpc = selectedChain.endpoint ?? "light-client" - const [selectedRpc, setSelectedRpc] = useState(currentRpc) - const [enteredText, setEnteredText] = useState("") + 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 ( { if ( evt.target instanceof HTMLElement && @@ -175,282 +168,396 @@ const NetworkSwitchDialogContent: FC<{ Switch Network - -
- - - -
No networks found.
-
- - {networkCategories.map((category) => { - if (category.name === "Custom") { - if ( - !isValidUri(enteredText) || - enteredText.startsWith("localhost:") - ) - return null - return ( - - { - handleNetworkSelect({ - id: "custom", - lightclient: false, - endpoints: { custom: enteredText }, - display: enteredText, - }) - }} - > - - {enteredText} - - - ) - } - - return ( - - {category.networks.map((network) => ( - handleNetworkSelect(network)} - value={ - network.display.includes(category.name) - ? network.display - : `${category.name} ${network.display}` - } - > - - {network.display} - - ))} - {category.name === "Localhost" && - enteredText.startsWith("localhost:") ? ( - { - handleNetworkSelect({ - id: "localhost", - lightclient: false, - endpoints: { custom: `ws://${enteredText}` }, - display: enteredText, - }) - }} - > - - {enteredText} - - ) : null} - - ) - })} - -
-
-
- {selectedNetwork ? ( -
-

- Network:{" "} - {selectedNetwork.id === "custom" - ? "Custom" - : selectedNetwork.display} -

-
- - {selectedNetwork.lightclient ? ( - - ) : null} - {Object.entries(selectedNetwork.endpoints).map( - ([rpcName, url]) => ( - - ), - )} - {selectedNetwork.id === "localhost" ? ( - - ) : null} - -
-
- ) : null} - {selectedRpc && selectedRpc !== "light-client" && ( -
-
-
- - -
- setWithChopsticks(!withChopsticks)} - /> -
-

- Create a local fork of this chain -

-
- )} -
+ +
+ + +
+ +
+ {canFork ? ( +
+ + + +
+ ) : ( +
+ )} +
- ) } -const ConnectionOption: FC<{ - isSelected: boolean - value: string - name: string - type: "light" | "rpc" - url?: string -}> = ({ isSelected, value, name, type, url }) => ( -
-
- -
- -
-
- - {/* Show URL for RPC endpoints */} - {url ? ( -
-
-
- - {url} - -
- -
-
- ) : null} -
-) - -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 ( -
-
- -
- -
-
+ + + + +
+ No matching chains or endpoints. +
+
+ {customUrl ? ( + + onCustomSelect(customUrl)} + /> + + ) : null} + {categories.map((category) => ( + + {category.networks.map((network) => ( + onNetworkSelect(network)} + /> + ))} + + ))} +
+
+ ) +} -
- { - 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 }) => ( + + +
{title}
+
+) + +const ConnectionList: FC<{ + network: Network + endpoint: string + onEndpointChange: (endpoint: string) => void +}> = ({ network, endpoint, onEndpointChange }) => { + const entries = Object.entries(network.endpoints) + + return ( +
+
+ Connection
+ + {network.lightclient ? ( + + ) : null} + {entries.length > 1 && + network.id !== "custom" && + network.id !== "localhost" ? ( + + ) : null} + {network.id !== "custom" + ? entries.map(([name, url]) => ( + + )) + : null} + {network.id === "localhost" ? ( + url)} + onEndpointChange={onEndpointChange} + /> + ) : null} + {network.id === "custom" && endpoint ? ( + + ) : null} +
) } + +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 ( +
+
+ + +
+ { + const nextPort = evt.target.value + setPort(nextPort) + if (nextPort) onEndpointChange(`ws://127.0.0.1:${nextPort}`) + }} + /> +
+ ) +} + +const ConnectionOption: FC<{ + value: string + selected: boolean + title: string + subtitle: string + badge?: string + copy?: string +}> = ({ value, selected, title, subtitle, badge, copy }) => ( +
+ + + {copy ? : null} +
+) + +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 + + const isReady = "uri" in websocketStatus + const activeEndpoint = isReady + ? findEndpointName(network, websocketStatus.uri) + : null + return ( + activeEndpoint ?? + (isReady ? ( + formatUrl(websocketStatus.uri) + ) : ( + + )) + ) + } + 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" diff --git a/src/state/chains/chain.state.ts b/src/state/chains/chain.state.ts index 4f887e0..a0d95c3 100644 --- a/src/state/chains/chain.state.ts +++ b/src/state/chains/chain.state.ts @@ -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() -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", [])), diff --git a/src/state/chains/websocket.ts b/src/state/chains/websocket.ts index 07e0d86..d3d2ae0 100644 --- a/src/state/chains/websocket.ts +++ b/src/state/chains/websocket.ts @@ -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 { return { type: "websocket", id, endpoint, withChopsticks } } -export function getWebsocketProvider(source: WebsocketSource): JsonRpcProvider { - return getWsProvider(source.endpoint) +export type WsStatusJsonRpcProvider = WsJsonRpcProvider & { + statusChange$: Observable +} +export function getWebsocketProvider( + source: WebsocketSource, +): WsStatusJsonRpcProvider { + const statusChange$ = new Subject() + const provider = getWsProvider(source.endpoint, { + onStatusChanged(status) { + statusChange$.next(status) + }, + }) + + return Object.assign(provider, { + statusChange$: statusChange$.asObservable(), + }) }