diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx index 419f3a8..8f694b3 100644 --- a/src/components/NetworkSwitcher.tsx +++ b/src/components/NetworkSwitcher.tsx @@ -1,10 +1,65 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isValidHttpUrl } from "../config/network"; import { NETWORK_COLORS, useNetwork, type NetworkConfig, } from "../contexts/NetworkContext"; +export { isValidHttpUrl }; + +export interface CustomNetworkFormValues { + name: string; + rpcUrl: string; + horizonUrl: string; + passphrase: string; +} + +export type CustomNetworkField = keyof CustomNetworkFormValues; + +export type CustomNetworkFormErrors = Partial< + Record +>; + +export function validateCustomNetworkForm( + values: CustomNetworkFormValues, + existingNetworks: ReadonlyArray>, +): CustomNetworkFormErrors { + const errors: CustomNetworkFormErrors = {}; + + const name = values.name.trim(); + if (!name) { + errors.name = "Network name is required."; + } else { + const normalized = name.toLowerCase(); + const isDuplicate = existingNetworks.some( + (n) => n.name.trim().toLowerCase() === normalized, + ); + if (isDuplicate) { + errors.name = "A network with this name already exists."; + } + } + + const rpcUrl = values.rpcUrl.trim(); + if (!rpcUrl) { + errors.rpcUrl = "RPC URL is required."; + } else if (!isValidHttpUrl(rpcUrl)) { + errors.rpcUrl = "Enter a valid http(s) RPC URL."; + } + + const horizonUrl = values.horizonUrl.trim(); + if (horizonUrl && !isValidHttpUrl(horizonUrl)) { + errors.horizonUrl = "Enter a valid http(s) Horizon URL."; + } + + const passphrase = values.passphrase.trim(); + if (!passphrase) { + errors.passphrase = "Network passphrase is required."; + } + + return errors; +} + type HealthState = "checking" | "connected" | "degraded" | "down"; interface Health { @@ -12,19 +67,51 @@ interface Health { latencyMs: number | null; } -async function probeNetwork(rpcUrl: string): Promise { +type RpcCheckState = "idle" | HealthState; + +interface RpcCheck { + state: RpcCheckState; + latencyMs: number | null; +} + +const PROBE_TIMEOUT_MS = 4000; +const RPC_CHECK_DEBOUNCE_MS = 300; + +const INITIAL_FORM: CustomNetworkFormValues = { + name: "", + rpcUrl: "", + horizonUrl: "", + passphrase: "", +}; + +async function probeNetwork( + rpcUrl: string, + externalSignal?: AbortSignal, +): Promise { + if (externalSignal?.aborted) { + return { state: "down", latencyMs: null }; + } + const controller = new AbortController(); + const onAbort = () => controller.abort(); + externalSignal?.addEventListener("abort", onAbort, { once: true }); + const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + const start = performance.now(); try { const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), + signal: controller.signal, }); const latencyMs = Math.round(performance.now() - start); if (!res.ok) return { state: "degraded", latencyMs }; return { state: latencyMs > 1500 ? "degraded" : "connected", latencyMs }; } catch { return { state: "down", latencyMs: null }; + } finally { + clearTimeout(timeoutId); + externalSignal?.removeEventListener("abort", onAbort); } } @@ -64,24 +151,49 @@ export default function NetworkSwitcher() { state: "checking", latencyMs: null, }); - const [form, setForm] = useState({ - name: "", - rpcUrl: "", - horizonUrl: "", - passphrase: "", + const [form, setForm] = useState(INITIAL_FORM); + const [touched, setTouched] = useState< + Partial> + >({}); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [rpcCheck, setRpcCheck] = useState({ + state: "idle", + latencyMs: null, }); - const [formError, setFormError] = useState(null); - const [validating, setValidating] = useState(false); + const rootRef = useRef(null); + const rpcCheckAbortRef = useRef(null); + const rpcCheckTimerRef = useRef | null>(null); + + const cancelPendingRpcCheck = useCallback(() => { + if (rpcCheckTimerRef.current != null) { + clearTimeout(rpcCheckTimerRef.current); + rpcCheckTimerRef.current = null; + } + if (rpcCheckAbortRef.current != null) { + rpcCheckAbortRef.current.abort(); + rpcCheckAbortRef.current = null; + } + }, []); + + const resetAddForm = useCallback(() => { + cancelPendingRpcCheck(); + setForm(INITIAL_FORM); + setTouched({}); + setSubmitAttempted(false); + setRpcCheck({ state: "idle", latencyMs: null }); + }, [cancelPendingRpcCheck]); useEffect(() => { let cancelled = false; + const controller = new AbortController(); setHealth({ state: "checking", latencyMs: null }); - probeNetwork(active.rpcUrl).then((h) => { - if (!cancelled) setHealth(h); + void probeNetwork(active.rpcUrl, controller.signal).then((h) => { + if (!cancelled && !controller.signal.aborted) setHealth(h); }); return () => { cancelled = true; + controller.abort(); }; }, [active.id, active.rpcUrl]); @@ -91,12 +203,17 @@ export default function NetworkSwitcher() { e.preventDefault(); setOpen((o) => !o); } - if (e.key === "Escape") setOpen(false); + if (e.key === "Escape") { + setOpen(false); + setShowAddForm(false); + resetAddForm(); + } } function onClickOutside(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); setShowAddForm(false); + resetAddForm(); } } window.addEventListener("keydown", onKey); @@ -105,24 +222,71 @@ export default function NetworkSwitcher() { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onClickOutside); }; - }, []); + }, [resetAddForm]); - async function handleAddNetwork(e: React.FormEvent) { - e.preventDefault(); - setFormError(null); - if (!form.name.trim() || !form.rpcUrl.trim() || !form.passphrase.trim()) { - setFormError("Name, RPC URL, and passphrase are required."); + const errors = useMemo( + () => validateCustomNetworkForm(form, networks), + [form, networks], + ); + const isFormValid = Object.keys(errors).length === 0; + const trimmedRpcUrl = form.rpcUrl.trim(); + const canProbeRpc = showAddForm && Boolean(trimmedRpcUrl) && !errors.rpcUrl; + + const runRpcCheck = useCallback( + (url: string) => { + cancelPendingRpcCheck(); + const controller = new AbortController(); + rpcCheckAbortRef.current = controller; + setRpcCheck({ state: "checking", latencyMs: null }); + void probeNetwork(url, controller.signal).then((result) => { + if (!controller.signal.aborted) { + setRpcCheck(result); + } + }); + }, + [cancelPendingRpcCheck], + ); + + useEffect(() => { + if (!canProbeRpc) { + cancelPendingRpcCheck(); + setRpcCheck({ state: "idle", latencyMs: null }); return; } - setValidating(true); - const probed = await probeNetwork(form.rpcUrl.trim()); - setValidating(false); - if (probed.state === "down") { - setFormError( - "Could not reach this RPC URL. Check the address and try again.", - ); + + rpcCheckTimerRef.current = setTimeout(() => { + rpcCheckTimerRef.current = null; + runRpcCheck(trimmedRpcUrl); + }, RPC_CHECK_DEBOUNCE_MS); + + return () => { + cancelPendingRpcCheck(); + }; + }, [canProbeRpc, trimmedRpcUrl, runRpcCheck, cancelPendingRpcCheck]); + + function updateField(field: CustomNetworkField, value: string) { + setForm((prev) => ({ ...prev, [field]: value })); + setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); + } + + function markTouched(field: CustomNetworkField) { + setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); + } + + function fieldError(field: CustomNetworkField): string | undefined { + if (!touched[field] && !submitAttempted) return undefined; + return errors[field]; + } + + function handleAddNetwork(e: React.FormEvent) { + e.preventDefault(); + setSubmitAttempted(true); + + const currentErrors = validateCustomNetworkForm(form, networks); + if (Object.keys(currentErrors).length > 0) { return; } + const network = addCustomNetwork({ name: form.name.trim(), rpcUrl: form.rpcUrl.trim(), @@ -130,10 +294,15 @@ export default function NetworkSwitcher() { passphrase: form.passphrase.trim(), }); setActiveId(network.id); - setForm({ name: "", rpcUrl: "", horizonUrl: "", passphrase: "" }); + resetAddForm(); setShowAddForm(false); } + const nameError = fieldError("name"); + const rpcUrlError = fieldError("rpcUrl"); + const horizonUrlError = fieldError("horizonUrl"); + const passphraseError = fieldError("passphrase"); + return (
+
+ {rpcUrlError && ( + + )} + {!rpcUrlError && rpcCheck.state !== "idle" && ( +

+ {rpcCheck.state === "checking" && "Checking RPC endpoint…"} + {rpcCheck.state === "connected" && + `RPC reachable${rpcCheck.latencyMs != null ? ` (${rpcCheck.latencyMs}ms)` : ""}`} + {rpcCheck.state === "degraded" && + "RPC responded with a non-OK status — you can still save."} + {rpcCheck.state === "down" && + "Could not reach RPC endpoint — you can still save."} +

+ )} + + +
+ updateField("horizonUrl", e.target.value)} + onBlur={() => markTouched("horizonUrl")} + style={{ + fontSize: 12, + width: "100%", + borderColor: horizonUrlError ? "#f85149" : undefined, + }} + /> + {horizonUrlError && ( + + )} +
+ +
+ updateField("passphrase", e.target.value)} + onBlur={() => markTouched("passphrase")} + style={{ + fontSize: 12, + width: "100%", + borderColor: passphraseError ? "#f85149" : undefined, + }} + /> + {passphraseError && ( + + )} +
+