diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6233a8e..e21e7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # Node 18 is the project minimum (.nvmrc). Node 20 covers the + # next LTS. sharp@0.33.5 and @testing-library/jest-dom@6 both + # support Node >=18. node-version: [18, 20] steps: diff --git a/README.md b/README.md index 5685d6b..90a0b71 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,38 @@ Full page sections wired to on-chain data. - The profile page fetches reputation, bio, and past gigs from `GET /api/profile`, which proxies to `PROFILE_API_BASE_URL` when configured and falls back to typed mock data for local/dev environments. - No new runtime dependencies were added for this feature. +### Cross-Tab Wallet Session Sync & Auto-Reconnect + +Wallet state (account, network, allowed status) is synchronized across all open browser tabs without requiring user interaction. + +**How it works:** + +1. When `useWallet` detects a state change during its 2-second polling cycle (account switch, network change), it broadcasts the new state to all other tabs via `BroadcastChannel`. +2. Each tab receiving the message compares the incoming version number against its own. Higher version wins; ties are broken by timestamp. Stale or equal state is silently discarded. +3. When a tab receives a newer state, it applies it immediately and fires a silent Freighter validation call to confirm the account is still accessible. +4. Disconnect is always broadcast immediately (not debounced) so all tabs clear their state at once. +5. On mount, each tab reads the last persisted state from `localStorage` and restores it before the first poll completes, eliminating the blank-wallet flash on page load. + +**Key files:** + +| File | Role | +|------|------| +| `types/wallet-sync.ts` | Shared TypeScript interfaces (`WalletSyncState`, `WalletSyncMessage`, etc.) | +| `utils/walletStorage.ts` | localStorage read/write/clear with quota-exceeded handling and version counter | +| `utils/walletSyncManager.ts` | `WalletSyncManager` class — owns the `BroadcastChannel`, debounce logic, listener registry, and localStorage fallback | +| `hooks/useWalletSync.ts` | React wrapper around `WalletSyncManager`; manages subscription lifecycle | +| `hooks/useWallet.ts` | Integrates sync into the existing polling hook via a stable `broadcastStateRef` | + +**Fallback strategy:** + +`BroadcastChannel` is used where available (all modern browsers). In environments where it is unavailable or throws, the manager falls back to `localStorage` + `storage` events, which fire across tabs when a key changes. The fallback writes to a dedicated key (`trustflow-wallet-state:broadcast`) that is separate from the persistence key (`trustflow-wallet-state`) to prevent broadcast messages from overwriting stored state. + +**Version counter:** + +Each state broadcast increments a version counter stored in `localStorage` under `trustflow-wallet-version`. This counter persists across page refreshes and browser restarts so that version numbers are always increasing across sessions — this is intentional and ensures that a freshly opened tab never mistakenly discards a broadcast from a tab that has been running longer. + +**No new runtime dependencies.** Everything uses native Web APIs (`BroadcastChannel`, `localStorage`, `crypto.randomUUID`). + ### Contract Bindings Codegen TrustFlow uses a codegen pipeline to generate fully typed TypeScript client bindings from Soroban contract specs. This ensures all frontend contract calls are compile-time checked and stay in sync with the contract interface. diff --git a/components/atoms/connect-button/index.tsx b/components/atoms/connect-button/index.tsx index 6453618..d3acff2 100644 --- a/components/atoms/connect-button/index.tsx +++ b/components/atoms/connect-button/index.tsx @@ -7,20 +7,45 @@ export interface ConnectButtonProps { isHigher?: boolean /** Called after a successful setAllowed + wallet connection is detected */ onConnect?: () => void + /** + * When true, renders the button in a disabled loading state without + * starting the Freighter flow. Use this when another tab is already + * in the middle of a connection attempt (pass `isBusy` from useWallet). + */ + isConnecting?: boolean + /** + * Disables the button entirely. Takes precedence over isConnecting. + * Useful when the parent knows a connection already exists but the + * component tree hasn't unmounted yet. + */ + disabled?: boolean } /** * Renders a "Connect Wallet" button that triggers the Freighter permission flow. * * - Shows a loading spinner while the connection is in progress + * - Accepts `isConnecting` to reflect a connection attempt started by another + * tab, preventing duplicate Freighter permission popups * - Displays inline error text if the connection fails * - Fires `onConnect` so parents can refresh state after a successful connect */ -export function ConnectButton({ label, isHigher, onConnect }: ConnectButtonProps) { +export function ConnectButton({ + label, + isHigher, + onConnect, + isConnecting = false, + disabled = false, +}: ConnectButtonProps) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // Either this instance is loading, or a cross-tab connection is in progress + const isAnyLoading = loading || isConnecting + const isDisabled = disabled || isAnyLoading + async function handleClick() { + if (isDisabled) return setLoading(true) setError(null) try { @@ -35,19 +60,27 @@ export function ConnectButton({ label, isHigher, onConnect }: ConnectButtonProps } } + // Label shown inside the button + const buttonLabel = loading + ? 'Connecting…' + : isConnecting + ? 'Connecting in another tab…' + : label + return (
{error && (

diff --git a/components/atoms/wallet-button/index.tsx b/components/atoms/wallet-button/index.tsx index e7515ae..55b3cc0 100644 --- a/components/atoms/wallet-button/index.tsx +++ b/components/atoms/wallet-button/index.tsx @@ -11,13 +11,27 @@ interface WalletButtonProps { switchAccountLabel?: string /** Label for the disconnect menu item */ disconnectLabel?: string + /** + * When true, renders a small sync badge on the connection indicator + * to signal that this account's state is shared with other tabs. + */ + syncedAcrossTabs?: boolean + /** + * When set, displays a brief account-switch notice inside the dropdown. + * Should be a short address string; the parent clears it after showing. + */ + previousAddress?: string | null + /** Callback so the parent can clear the previousAddress notice */ + onDismissSwitchNotice?: () => void } /** * Shows the connected wallet address and a dropdown with: * - Current network indicator + * - Optional account-switch notice when active account changed in another tab + * - Optional sync badge when state is shared across browser tabs * - Switch account (re-opens Freighter permission popup) - * - Disconnect (clears local connection state) + * - Disconnect (clears local connection state and broadcasts to other tabs) * * Clicking outside closes the dropdown. */ @@ -27,27 +41,41 @@ export function WalletButton({ onDisconnect, switchAccountLabel = 'Switch account', disconnectLabel = 'Disconnect', + syncedAcrossTabs = false, + previousAddress = null, + onDismissSwitchNotice, }: WalletButtonProps) { const [open, setOpen] = useState(false) const ref = useRef(null) + // Auto-open the dropdown briefly when an account switch notice arrives + // so the user sees the change without having to click. + useEffect(() => { + if (previousAddress) { + setOpen(true) + } + }, [previousAddress]) + useEffect(() => { function handleClickOutside(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false) + // Dismiss the switch notice when the user clicks away + if (previousAddress) { + onDismissSwitchNotice?.() + } } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + }, [previousAddress, onDismissSwitchNotice]) const displayName = `${address.slice(0, 4)}...${address.slice(-4)}` - - // Derive a short, human-readable network label const networkLabel = deriveNetworkLabel(network) function handleSwap() { setOpen(false) + onDismissSwitchNotice?.() // Re-invoking setAllowed opens the Freighter permission popup so the user // can approve a different profile without disconnecting first. void setAllowed() @@ -55,24 +83,39 @@ export function WalletButton({ function handleDisconnect() { setOpen(false) + onDismissSwitchNotice?.() onDisconnect() } + function handleToggle() { + setOpen((v) => !v) + // Dismiss notice when user manually opens/closes the dropdown + if (open && previousAddress) { + onDismissSwitchNotice?.() + } + } + return (