Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 108 additions & 38 deletions webapp/src/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
iconLeft={<Settings2 />}
iconRight={<ChevronDown className="opacity-60" />}
size="sm"
variant="secondary"
>
{cluster?.label ?? 'Network'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Network</DropdownMenuLabel>
<DropdownMenuSeparator />
{clusters.map(c => (
<DropdownMenuItem
key={c.id}
onClick={() => {
void setCluster(c.id);
}}
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
iconLeft={<Settings2 />}
iconRight={<ChevronDown className="opacity-60" />}
size="sm"
variant="secondary"
>
{c.label}
</DropdownMenuItem>
))}
{import.meta.env.DEV && (
<>
<DropdownMenuSeparator />
{cluster?.label ?? 'Network'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Network</DropdownMenuLabel>
<DropdownMenuSeparator />
{clusters.map(c => (
<DropdownMenuItem
key={c.id}
onClick={() => {
localStorage.removeItem('setup-complete-localnet');
localStorage.removeItem('setup-complete-devnet');
localStorage.removeItem('setup-cluster');
navigate('/setup');
void setCluster(c.id);
}}
>
<RotateCcw className="mr-2 h-4 w-4" />
Rerun setup
{c.label}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openDialog}>
<Plus className="mr-2 h-4 w-4" />
{hasCustom ? 'Edit custom RPC' : 'Add custom RPC'}
</DropdownMenuItem>
{hasCustom && (
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={handleRemove}>
<Trash2 className="mr-2 h-4 w-4" />
Remove custom RPC
</DropdownMenuItem>
)}
{import.meta.env.DEV && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
localStorage.removeItem('setup-complete-localnet');
localStorage.removeItem('setup-complete-devnet');
localStorage.removeItem('setup-cluster');
navigate('/setup');
}}
>
<RotateCcw className="mr-2 h-4 w-4" />
Rerun setup
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>

<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Custom RPC endpoint</DialogTitle>
<DialogDescription>
Point the app at your own Solana RPC URL. Saving reloads the page and selects it.
</DialogDescription>
</DialogHeader>
<TextInput
value={url}
onChange={e => setUrl(e.currentTarget.value)}
placeholder="https://my-rpc.example.com"
inputClassName="font-mono"
/>
<DialogFooter>
<Button variant="secondary" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

Expand Down
30 changes: 18 additions & 12 deletions webapp/src/components/solana/solana-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/config/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';

export const STATIC_NETWORKS: Record<Network, NetworkConfig> = {
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' }],
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/lib/cluster.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
39 changes: 39 additions & 0 deletions webapp/src/lib/custom-rpc.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading