diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx index c036b90..32320bc 100644 --- a/webapp/src/components/app-header.tsx +++ b/webapp/src/components/app-header.tsx @@ -1,9 +1,18 @@ import { useEffect, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; import { useCluster } from '@solana/connector/react'; -import { Button } from '@solana/design-system'; -import { ChevronDown, Menu, RotateCcw, Settings2 } from 'lucide-react'; +import { Button, TextInput } from '@solana/design-system'; +import { ChevronDown, Menu, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, @@ -14,6 +23,7 @@ import { } from '@/components/ui/dropdown-menu'; import { CURRENT_PROGRAM_VERSION } from '@solana/subscriptions'; import solanaLogo from '@/assets/solana-logo.svg'; +import { clearCustomCluster, isValidRpcUrl, readCustomCluster, saveCustomCluster } from '@/lib/custom-rpc'; import { cn } from '@/lib/utils'; import { NAV_ITEMS, type NavItem } from './nav-items'; @@ -23,50 +33,110 @@ import { TimeTravelButton } from './time-travel/time-travel-button'; function ClusterButton() { const { cluster, clusters, setCluster } = useCluster(); const navigate = useNavigate(); + const [dialogOpen, setDialogOpen] = useState(false); + const [url, setUrl] = useState(''); + + const hasCustom = readCustomCluster() !== null; + + function openDialog() { + setUrl(readCustomCluster()?.url ?? ''); + setDialogOpen(true); + } + + function handleSave() { + const trimmed = url.trim(); + if (!isValidRpcUrl(trimmed)) { + toast.error('Enter a valid http(s) RPC URL'); + return; + } + saveCustomCluster(trimmed); + window.location.reload(); + } + + function handleRemove() { + clearCustomCluster(); + window.location.reload(); + } return ( - - - - - - Network - - {clusters.map(c => ( - { - void setCluster(c.id); - }} + <> + + + + + + Network + + {clusters.map(c => ( { - localStorage.removeItem('setup-complete-localnet'); - localStorage.removeItem('setup-complete-devnet'); - localStorage.removeItem('setup-cluster'); - navigate('/setup'); + void setCluster(c.id); }} > - - Rerun setup + {c.label} - - )} - - + ))} + + + + {hasCustom ? 'Edit custom RPC' : 'Add custom RPC'} + + {hasCustom && ( + + + Remove custom RPC + + )} + {import.meta.env.DEV && ( + <> + + { + localStorage.removeItem('setup-complete-localnet'); + localStorage.removeItem('setup-complete-devnet'); + localStorage.removeItem('setup-cluster'); + navigate('/setup'); + }} + > + + Rerun setup + + + )} + + + + + + + Custom RPC endpoint + + Point the app at your own Solana RPC URL. Saving reloads the page and selects it. + + + setUrl(e.currentTarget.value)} + placeholder="https://my-rpc.example.com" + inputClassName="font-mono" + /> + + + + + + + ); } diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx index 107a8cc..33d0820 100644 --- a/webapp/src/components/solana/solana-provider.tsx +++ b/webapp/src/components/solana/solana-provider.tsx @@ -22,12 +22,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { CUSTOM_CLUSTER_ID, readCustomCluster } from '@/lib/custom-rpc'; import { ellipsify } from '@/lib/utils'; function defaultClusterId(): SolanaClusterId { const stored = localStorage.getItem('setup-cluster'); const configured = import.meta.env.VITE_DEFAULT_CLUSTER; const id = stored || configured || (import.meta.env.DEV ? 'solana:localnet' : 'solana:devnet'); + if (id === CUSTOM_CLUSTER_ID && readCustomCluster()) return CUSTOM_CLUSTER_ID; return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' || id === 'solana:mainnet' ? (id as SolanaClusterId) : 'solana:devnet'; @@ -36,20 +38,24 @@ function defaultClusterId(): SolanaClusterId { function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'mainnet' | 'testnet' { if (clusterId === 'solana:devnet') return 'devnet'; if (clusterId === 'solana:testnet') return 'testnet'; - if (clusterId === 'solana:mainnet') return 'mainnet'; + if (clusterId === 'solana:mainnet' || clusterId === CUSTOM_CLUSTER_ID) return 'mainnet'; return 'localnet'; } -const clusters = [ - ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []), - { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' }, - { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' }, - { - id: 'solana:mainnet' as const, - label: 'Mainnet', - url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com', - }, -]; +function buildClusters() { + const custom = readCustomCluster(); + return [ + ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []), + { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' }, + { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' }, + { + id: 'solana:mainnet' as const, + label: 'Mainnet', + url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com', + }, + ...(custom ? [custom] : []), + ]; +} export function WalletButton() { const { account, isConnected, isConnecting } = useWallet(); @@ -154,7 +160,7 @@ export function SolanaProvider({ children }: { children: ReactNode }) { return getDefaultConfig({ appName: 'Subscriptions', autoConnect: true, - clusters, + clusters: buildClusters(), enableMobile: true, network: networkFromClusterId(initialCluster), persistClusterSelection: false, diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts index 2031018..3654ce3 100644 --- a/webapp/src/config/networks.ts +++ b/webapp/src/config/networks.ts @@ -18,6 +18,10 @@ const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; export const STATIC_NETWORKS: Record = { + custom: { + programAddress: PROGRAM_ID, + tokens: [{ decimals: 6, mint: MAINNET_USDC, name: 'USD Coin', symbol: 'USDC' }], + }, devnet: { programAddress: PROGRAM_ID, tokens: [{ decimals: 6, mint: DEVNET_USDC, name: 'USD Coin', symbol: 'USDC' }], diff --git a/webapp/src/lib/cluster.ts b/webapp/src/lib/cluster.ts index 44c1f3f..191788d 100644 --- a/webapp/src/lib/cluster.ts +++ b/webapp/src/lib/cluster.ts @@ -1,6 +1,7 @@ -export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet'; +export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet' | 'custom'; export function clusterIdToNetwork(id: string): Network { + if (id === 'solana:custom') return 'custom'; if (id.includes('devnet')) return 'devnet'; if (id.includes('testnet')) return 'testnet'; if (id.includes('mainnet')) return 'mainnet'; diff --git a/webapp/src/lib/custom-rpc.ts b/webapp/src/lib/custom-rpc.ts new file mode 100644 index 0000000..7e3e659 --- /dev/null +++ b/webapp/src/lib/custom-rpc.ts @@ -0,0 +1,39 @@ +import type { SolanaCluster } from '@solana/connector/react'; + +export const CUSTOM_CLUSTER_ID = 'solana:custom' as const; + +const URL_KEY = 'custom-rpc-url'; +const LABEL_KEY = 'custom-rpc-label'; +const SETUP_CLUSTER_KEY = 'setup-cluster'; +const SETUP_COMPLETE_KEY = 'setup-complete-custom'; + +export function readCustomCluster(): SolanaCluster | null { + const url = localStorage.getItem(URL_KEY); + if (!url) return null; + return { id: CUSTOM_CLUSTER_ID, label: localStorage.getItem(LABEL_KEY) || 'Custom', url }; +} + +export function saveCustomCluster(url: string, label?: string): void { + localStorage.setItem(URL_KEY, url); + localStorage.setItem(LABEL_KEY, label?.trim() || 'Custom'); + localStorage.setItem(SETUP_CLUSTER_KEY, CUSTOM_CLUSTER_ID); + localStorage.setItem(SETUP_COMPLETE_KEY, 'true'); +} + +export function clearCustomCluster(): void { + localStorage.removeItem(URL_KEY); + localStorage.removeItem(LABEL_KEY); + localStorage.removeItem(SETUP_COMPLETE_KEY); + if (localStorage.getItem(SETUP_CLUSTER_KEY) === CUSTOM_CLUSTER_ID) { + localStorage.removeItem(SETUP_CLUSTER_KEY); + } +} + +export function isValidRpcUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}