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
4 changes: 1 addition & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/bip39": "^1.6.0",
"@tailwindcss/vite": "^4.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
270 changes: 238 additions & 32 deletions src/components/NetworkBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,98 @@ function setStorageDismissed(networkName: string, customKey?: string): void {
}
}

const MINIMIZED_KEY_PREFIX = "sorokit-banner-minimized";

function minimizedKey(networkName: string, customKey?: string): string {
return customKey
? `${customKey}-minimized`
: `${MINIMIZED_KEY_PREFIX}-${networkName}`;
}

function getStorageMinimized(networkName: string, customKey?: string): boolean {
if (typeof window === "undefined" || !window.sessionStorage) return false;
try {
const key = minimizedKey(networkName, customKey);
return window.sessionStorage.getItem(key) === "true";
} catch {
return false;
}
}

function setStorageMinimized(
networkName: string,
minimized: boolean,
customKey?: string,
): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
try {
const key = minimizedKey(networkName, customKey);
window.sessionStorage.setItem(key, String(minimized));
} catch {
// best effort in storage-restricted environments
}
}

/** Round-trip time above which the RPC is reported as degraded. */
const DEFAULT_DEGRADED_LATENCY_MS = 500;
const DEFAULT_LATENCY_INTERVAL_MS = 30_000;

interface RpcHealth {
latencyMs: number | null;
failed: boolean;
}

/**
* Periodically pings the RPC `getHealth` method and reports the round-trip
* time. Only runs when `enabled`; aborts the in-flight request on cleanup.
*/
function useRpcLatency(
rpcUrl: string | undefined,
enabled: boolean,
intervalMs: number,
): RpcHealth {
const [health, setHealth] = useState<RpcHealth>({
latencyMs: null,
failed: false,
});

useEffect(() => {
if (!enabled || !rpcUrl || typeof fetch === "undefined") return;
let controller: AbortController | null = null;

const probe = async () => {
controller?.abort();
controller = new AbortController();
const started = 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,
});
if (!res.ok) throw new Error(`RPC responded with ${res.status}`);
setHealth({
latencyMs: Math.round(performance.now() - started),
failed: false,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setHealth({ latencyMs: null, failed: true });
}
};

void probe();
const timer = setInterval(() => void probe(), intervalMs);
return () => {
clearInterval(timer);
controller?.abort();
};
}, [rpcUrl, enabled, intervalMs]);

return health;
}

export interface NetworkBannerProps {
active?: NavSection;
config?: Partial<Record<string, Partial<BannerConfig>>>;
Expand Down Expand Up @@ -119,6 +211,22 @@ export interface NetworkBannerProps {
dotColor?: string;
/** Custom class for the dismiss button */
dismissButtonClassName?: string;
/**
* Whether the banner can be collapsed into a compact status dot. The state
* is kept in sessionStorage. Defaults to true.
*/
minimizable?: boolean;
/**
* Externally measured RPC latency in milliseconds. When provided it is used
* instead of the built-in probe.
*/
latencyMs?: number | null;
/** Ping the RPC endpoint (`getHealth`) to measure latency. Defaults to false. */
monitorLatency?: boolean;
/** Latency (ms) at or above which the RPC is shown as degraded. Defaults to 500. */
degradedLatencyMs?: number;
/** How often to re-measure latency when `monitorLatency` is on. Defaults to 30s. */
latencyIntervalMs?: number;
}

export function NetworkBanner({
Expand All @@ -136,12 +244,39 @@ export function NetworkBanner({
textColor,
dotColor,
dismissButtonClassName,
minimizable = true,
latencyMs: latencyMsProp,
monitorLatency = false,
degradedLatencyMs = DEFAULT_DEGRADED_LATENCY_MS,
latencyIntervalMs = DEFAULT_LATENCY_INTERVAL_MS,
}: NetworkBannerProps) {
const { network } = useSorokit();
const networkName = network?.name;

const [dismissedMap, setDismissedMap] = useState<Record<string, boolean>>({});

const [minimizedMap, setMinimizedMap] = useState<Record<string, boolean>>({});
const isMinimized = Boolean(
minimizable &&
networkName &&
(minimizedMap[networkName] ?? getStorageMinimized(networkName, storageKey)),
);

const probed = useRpcLatency(
network?.rpcUrl,
monitorLatency && latencyMsProp === undefined,
latencyIntervalMs,
);
const latencyMs = latencyMsProp ?? probed.latencyMs;
const rpcFailed = latencyMsProp === undefined && probed.failed;
const isDegraded =
rpcFailed || (latencyMs !== null && latencyMs >= degradedLatencyMs);
const healthText = rpcFailed
? "RPC unreachable"
: latencyMs !== null
? `Latency: ${latencyMs}ms`
: null;

const isSessionDismissed = networkName
? getStorageDismissed(networkName, storageKey)
: false;
Expand Down Expand Up @@ -238,6 +373,13 @@ export function NetworkBanner({
onDismiss?.();
}, [networkName, storageKey, onDismiss]);

const handleToggleMinimized = () => {
if (!networkName) return;
const next = !isMinimized;
setMinimizedMap((prev) => ({ ...prev, [networkName]: next }));
setStorageMinimized(networkName, next, storageKey);
};

if (!isVisible || !network) return null;

const label = cfg.label ?? network.name;
Expand Down Expand Up @@ -271,53 +413,117 @@ export function NetworkBanner({
...(effectiveTextColor ? { color: effectiveTextColor } : {}),
};

const healthIndicator =
isDegraded && healthText ? (
<span
data-testid="network-banner-latency"
className="inline-flex items-center gap-1 rounded-full bg-black/10 dark:bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-orange"
>
<span className="w-1 h-1 rounded-full bg-orange" aria-hidden="true" />
{healthText}
</span>
) : null;

const dotEl = (
<span
className={cn("w-1.5 h-1.5 rounded-full shrink-0", cfg.dot)}
style={dotStyle}
data-testid="network-banner-dot"
/>
);

return (
<div
ref={bannerRef}
role="status"
aria-live="polite"
aria-label="Network status banner"
data-testid="network-banner"
data-minimized={isMinimized ? "true" : undefined}
className={cn(
"flex items-center justify-between px-4 py-2 text-[11px] font-medium transition-all duration-300",
"flex items-center text-[11px] font-medium transition-all duration-300",
isMinimized ? "justify-end px-3 py-1" : "justify-between px-4 py-2",
positionClasses[position],
cfg.bar,
className,
)}
style={containerStyle}
>
<div className="flex-1 min-w-[20px]" aria-hidden="true" />
<div className="flex items-center justify-center gap-2 text-center">
<span
className={cn("w-1.5 h-1.5 rounded-full shrink-0", cfg.dot)}
style={dotStyle}
data-testid="network-banner-dot"
/>
<span className={cfg.text} style={textStyle}>
You are on <strong>{label}</strong>
{network.name !== "mainnet" && " — transactions use test funds only"}
</span>
</div>
<div className="flex-1 min-w-[20px] flex justify-end">
{dismissible && (
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss banner"
className={cn(
"inline-flex items-center justify-center p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors opacity-70 hover:opacity-100 shrink-0 focus:outline-none focus:ring-1 focus:ring-current",
dismissButtonClassName,
{isMinimized ? (
<button
type="button"
onClick={handleToggleMinimized}
aria-label={`Expand network banner: ${label}${isDegraded && healthText ? `, ${healthText}` : ""}`}
aria-expanded={false}
data-testid="network-banner-expand"
className="inline-flex items-center gap-1.5 rounded p-1 hover:bg-black/10 dark:hover:bg-white/10 transition-colors focus:outline-none focus:ring-1 focus:ring-current"
>
{dotEl}
<span className={cn("sr-only", cfg.text)}>{label}</span>
{healthIndicator}
</button>
) : (
<>
<div className="flex-1 min-w-[20px]" aria-hidden="true" />
<div className="flex flex-wrap items-center justify-center gap-2 text-center">
{dotEl}
<span className={cfg.text} style={textStyle}>
You are on <strong>{label}</strong>
{network.name !== "mainnet" &&
" — transactions use test funds only"}
</span>
{healthIndicator}
</div>
<div className="flex-1 min-w-[20px] flex justify-end items-center gap-1">
{minimizable && (
<button
type="button"
onClick={handleToggleMinimized}
aria-label="Minimize banner"
aria-expanded={true}
data-testid="network-banner-minimize"
className={cn(
"inline-flex items-center justify-center p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors opacity-70 hover:opacity-100 shrink-0 focus:outline-none focus:ring-1 focus:ring-current",
dismissButtonClassName,
)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
aria-hidden="true"
focusable="false"
>
<path
d="M2 6h8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
)}
>
<HugeiconsIcon
icon={Cancel01Icon}
size={12}
color="currentColor"
strokeWidth={2}
/>
</button>
)}
</div>
{dismissible && (
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss banner"
className={cn(
"inline-flex items-center justify-center p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors opacity-70 hover:opacity-100 shrink-0 focus:outline-none focus:ring-1 focus:ring-current",
dismissButtonClassName,
)}
>
<HugeiconsIcon
icon={Cancel01Icon}
size={12}
color="currentColor"
strokeWidth={2}
/>
</button>
)}
</div>
</>
)}
</div>
);
}
9 changes: 9 additions & 0 deletions src/components/NetworkSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ describe("NetworkSwitcher", { timeout: 15000 }, () => {

beforeEach(() => {
vi.clearAllMocks();
// Custom RPC endpoints are health-checked before saving; stub a healthy node.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ result: { status: "healthy" } }),
}),
);
switchNetwork = vi.fn().mockResolvedValue(undefined);
addCustomNetwork = vi.fn().mockResolvedValue(undefined);
resetTransactionWatchers = vi.fn();
Expand Down
Loading