diff --git a/package-lock.json b/package-lock.json index 4eb9a40..3215413 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,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", @@ -5422,7 +5423,6 @@ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "license": "MIT", - "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -5476,7 +5476,6 @@ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" @@ -5490,7 +5489,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, diff --git a/package.json b/package.json index 153827e..1a6619f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/NetworkBanner.tsx b/src/components/NetworkBanner.tsx index 16c50f2..4eaadb4 100644 --- a/src/components/NetworkBanner.tsx +++ b/src/components/NetworkBanner.tsx @@ -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({ + 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>>; @@ -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({ @@ -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>({}); + const [minimizedMap, setMinimizedMap] = useState>({}); + 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; @@ -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; @@ -271,6 +413,25 @@ export function NetworkBanner({ ...(effectiveTextColor ? { color: effectiveTextColor } : {}), }; + const healthIndicator = + isDegraded && healthText ? ( + + + ) : null; + + const dotEl = ( + + ); + return (
- diff --git a/src/components/SeedPhraseInput.tsx b/src/components/SeedPhraseInput.tsx new file mode 100644 index 0000000..f8b0359 --- /dev/null +++ b/src/components/SeedPhraseInput.tsx @@ -0,0 +1,219 @@ +import { EyeIcon, EyeOffIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english"; +import { useRef, useState } from "react"; + +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/utils"; + +export type SeedPhraseLength = 12 | 24; + +interface SeedPhraseInputProps { + /** Number of words in the phrase. Defaults to 12. */ + wordCount?: SeedPhraseLength; + /** Called with the normalized phrase once it passes BIP-39 validation. */ + onSubmit?: (mnemonic: string) => void; + className?: string; +} + +const WORD_COUNTS: SeedPhraseLength[] = [12, 24]; + +/** Splits pasted text on any whitespace and drops empty tokens. */ +export function splitPhrase(text: string): string[] { + return text.trim().toLowerCase().split(/\s+/).filter(Boolean); +} + +/** + * Validates a list of words against the BIP-39 English wordlist and checksum. + * Returns a human-readable error, or `null` when the phrase is valid. + */ +export function validateSeedWords( + words: string[], + expectedLength: SeedPhraseLength, +): string | null { + const missing = words.filter((w) => !w).length; + if (missing > 0) { + return `Enter all ${expectedLength} words (${missing} still empty).`; + } + const unknown = words.findIndex((w) => !wordlist.includes(w)); + if (unknown !== -1) { + return `Word ${unknown + 1} ("${words[unknown]}") is not in the BIP-39 word list.`; + } + if (!validateMnemonic(words.join(" "), wordlist)) { + return "Invalid seed phrase: the BIP-39 checksum does not match. Check the word order and spelling."; + } + return null; +} + +export function SeedPhraseInput({ + wordCount = 12, + onSubmit, + className, +}: SeedPhraseInputProps) { + const [count, setCount] = useState(wordCount); + const [words, setWords] = useState(() => + Array(wordCount).fill(""), + ); + const [visible, setVisible] = useState(false); + const [error, setError] = useState(null); + const [verified, setVerified] = useState(false); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + const reset = (next: string[]) => { + setWords(next); + setError(null); + setVerified(false); + }; + + const handleCountChange = (next: SeedPhraseLength) => { + setCount(next); + reset(Array.from({ length: next }, (_, i) => words[i] ?? "")); + }; + + const handleWordChange = (index: number, value: string) => { + // A multi-word value (e.g. autofill) is treated like a paste. + const tokens = splitPhrase(value); + if (tokens.length > 1) { + distribute(index, tokens); + return; + } + const next = [...words]; + next[index] = value.trim().toLowerCase(); + reset(next); + }; + + const distribute = (start: number, tokens: string[]) => { + // Pasting a full phrase always fills from the first cell. + const from = tokens.length >= 12 ? 0 : start; + const nextCount: SeedPhraseLength = + tokens.length > 12 && tokens.length <= 24 ? 24 : count; + const next = Array.from( + { length: nextCount }, + (_, i) => words[i] ?? "", + ); + tokens.slice(0, nextCount - from).forEach((token, i) => { + next[from + i] = token; + }); + setCount(nextCount); + reset(next); + const focusIndex = Math.min(from + tokens.length, nextCount - 1); + requestAnimationFrame(() => inputRefs.current[focusIndex]?.focus()); + }; + + const handlePaste = ( + index: number, + e: React.ClipboardEvent, + ) => { + const tokens = splitPhrase(e.clipboardData.getData("text")); + if (tokens.length <= 1) return; // single word: let the browser paste normally + e.preventDefault(); + distribute(index, tokens); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const message = validateSeedWords(words, count); + setError(message); + setVerified(!message); + if (!message) onSubmit?.(words.join(" ")); + }; + + return ( +
+
+
+ {WORD_COUNTS.map((n) => ( + + ))} +
+ +
+ +
+ {words.map((word, i) => ( +
+ + { + inputRefs.current[i] = node; + }} + type={visible ? "text" : "password"} + value={word} + onChange={(e) => handleWordChange(i, e.target.value)} + onPaste={(e) => handlePaste(i, e)} + aria-label={`Seed word ${i + 1}`} + aria-invalid={!!error} + aria-describedby={error ? "seed-phrase-error" : undefined} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + className={cn( + "h-9 w-full rounded-lg border bg-surface-2 pl-7 pr-2 text-[13px] text-ink", + "outline-none transition-colors", + error + ? "border-error-dim-input focus:border-red" + : "border-line focus:border-line-2 focus:ring-1 focus:ring-brand-dim", + )} + /> +
+ ))} +
+ + + {verified && ( +

+ Seed phrase checksum is valid. +

+ )} + + +
+ ); +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 6ad5c2b..ed5fd50 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -53,12 +53,35 @@ export function Badge({ live, className, children, + onClick, + onKeyDown, ...props }: BadgeProps) { const liveProps = live ? ({ role: "status", "aria-live": "polite" } as const) : {}; + // A clickable badge is a button in all but element name: expose the role, + // make it focusable, and activate on Enter / Space like a native button. + const interactiveProps = onClick + ? { + role: "button" as const, + tabIndex: 0, + onClick, + onKeyDown: (e: React.KeyboardEvent) => { + onKeyDown?.(e); + if (e.defaultPrevented || e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") { + e.preventDefault(); // stop Space from scrolling the page + e.currentTarget.click(); + } + }, + } + : { onKeyDown }; + const interactiveClasses = onClick + ? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" + : undefined; + const hasChildren = children !== undefined && children !== null && @@ -71,8 +94,13 @@ export function Badge({ if (dot && !hasChildren) { return ( {dot && ( diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 114d1fd..3ae7138 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -53,7 +53,13 @@ export const CardHeader = forwardRef {children} diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx index 621212d..b93498d 100644 --- a/src/components/ui/Tooltip.tsx +++ b/src/components/ui/Tooltip.tsx @@ -1,5 +1,5 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import type { ReactElement, ReactNode } from "react"; +import { type ReactElement, type ReactNode, useCallback, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; @@ -41,13 +41,36 @@ export function Tooltip({ open, onOpenChange, }: TooltipProps) { + const isControlled = open !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const isOpen = isControlled ? open : uncontrolledOpen; + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!isControlled) setUncontrolledOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); + + // WCAG 2.1 SC 1.4.13 (Dismissable): Escape closes an open tooltip without + // moving focus or pointer, whether it opened from hover or focus. + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") handleOpenChange(false); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleOpenChange]); + if (content === null || content === undefined || content === "") { return children; } return ( - + {children} ( handlerRef.current(event); }; - document.addEventListener("pointerdown", listener); - document.addEventListener("touchstart", listener); - document.addEventListener("mousedown", listener); + // Capture phase + passive: iOS Safari never fires `click` for taps on + // non-interactive elements, and ancestors that stop propagation would + // otherwise swallow the event before it reaches `document`. + const options: AddEventListenerOptions = { capture: true, passive: true }; + document.addEventListener("pointerdown", listener, options); + document.addEventListener("touchstart", listener, options); + document.addEventListener("mousedown", listener, options); return () => { - document.removeEventListener("pointerdown", listener); - document.removeEventListener("touchstart", listener); - document.removeEventListener("mousedown", listener); + document.removeEventListener("pointerdown", listener, options); + document.removeEventListener("touchstart", listener, options); + document.removeEventListener("mousedown", listener, options); }; }, [enabled]); diff --git a/src/screens/RecoveryScreen.tsx b/src/screens/RecoveryScreen.tsx index 82eec0e..51836f5 100644 --- a/src/screens/RecoveryScreen.tsx +++ b/src/screens/RecoveryScreen.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import { SeedPhraseInput } from "@/components/SeedPhraseInput"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Card, CardContent, CardDescription, CardFooter,CardHeader, CardTitle } from "@/components/ui/Card"; @@ -210,6 +211,19 @@ export function RecoveryScreen() { + {/* Seed phrase entry with masking, paste distribution & BIP-39 validation */} + + + Seed Phrase Recovery + + Enter your 12 or 24-word recovery phrase. You can paste the whole phrase into any box. + + + + + + + {/* Recovery conditions threshold & timeout */}