From dc8831cc17efdc8d9a8557f6888c89603bed39a9 Mon Sep 17 00:00:00 2001 From: divinemike019 Date: Mon, 20 Jul 2026 19:53:03 +0000 Subject: [PATCH] feat: implement cross-tab wallet session sync & auto-reconnect (#66) - Add hooks/useWalletSync.ts: BroadcastChannel-based sync hook - Message protocol: WALLET_CONNECTED | WALLET_DISCONNECTED | WALLET_ACCOUNT_CHANGED | REQUEST_STATE | STATE_RESPONSE - SSR-safe: BroadcastChannel only instantiated inside useEffect - Tab-scoped IDs prevent self-processing of own broadcasts - New tab sends REQUEST_STATE on mount for instant hydration - Update hooks/useAccount.ts: - Integrate useWalletSync for real-time cross-tab state propagation - Broadcast connect/disconnect/account-change on Freighter poll changes - Auto-reconnect: adopt remote connection state silently - Conflict resolution: last-writer-wins for account switches across tabs - Add network field (Stellar network passphrase) to AccountInfo - Update pages/_app.tsx: - Add WalletSyncNotifier component mounted inside ToastContext.Provider - Shows toast notifications for cross-tab wallet events - Update hooks/index.ts: export useWalletSync No new runtime dependencies introduced. --- hooks/index.ts | 1 + hooks/useAccount.ts | 120 +++++++++++++++++++++++-- hooks/useWalletSync.ts | 200 +++++++++++++++++++++++++++++++++++++++++ pages/_app.tsx | 50 ++++++++++- 4 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 hooks/useWalletSync.ts diff --git a/hooks/index.ts b/hooks/index.ts index 09793c9..df1882a 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -3,3 +3,4 @@ export * from "./useIsMounted"; export * from './useSubscription'; export * from './useUSDCPrice'; export * from './useUserProfile'; +export * from './useWalletSync'; diff --git a/hooks/useAccount.ts b/hooks/useAccount.ts index 8fb4d1e..f653831 100644 --- a/hooks/useAccount.ts +++ b/hooks/useAccount.ts @@ -1,37 +1,147 @@ -import { useEffect, useState, useCallback } from "react"; +/** + * useAccount + * + * Manages the connected Freighter wallet state for this tab and keeps it in + * sync with all other open tabs via the useWalletSync hook. + * + * Behaviour + * ───────── + * • On mount the hook asks Freighter for the current connection state. + * • A 2-second interval re-checks Freighter to pick up changes made directly + * inside the extension (connect, disconnect, account switch). + * • When a change is detected the new state is broadcast to other open tabs + * via BroadcastChannel so they update immediately instead of waiting for + * their next poll. + * • When another tab reports a change, this tab's state is updated silently + * (no page reload, no user prompt) – auto-reconnect. + * • If another tab switches to a different account (conflict), this tab also + * adopts the new account to stay consistent. + * • The `network` field is exposed so consumers can gate features on the + * active Stellar network (testnet vs mainnet). + */ + +import { useEffect, useState, useCallback, useRef } from "react"; import { isConnected, getUserInfo } from "@stellar/freighter-api"; +import { useWalletSync } from "./useWalletSync"; + +// ─── Types ──────────────────────────────────────────────────────────────────── export interface AccountInfo { address: string; displayName: string; + /** Stellar network passphrase reported by Freighter, or null if unavailable. */ + network: string | null; } +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Returns the currently connected Freighter wallet account, or null when no + * wallet is connected. State is synchronised across browser tabs in real time. + */ export function useAccount(): AccountInfo | null { const [address, setAddress] = useState(null); + const [network, setNetwork] = useState(null); + // Track the previous address so we can detect account switches. + const prevAddressRef = useRef(null); + + // ── Apply new state ──────────────────────────────────────────────────────── + const applyState = useCallback((addr: string | null, net: string | null) => { + setAddress(addr); + setNetwork(net); + prevAddressRef.current = addr; + }, []); + + // ── Cross-tab sync via BroadcastChannel ─────────────────────────────────── + const { broadcastConnect, broadcastDisconnect, broadcastAccountChange } = + useWalletSync({ + onRemoteConnect: (addr, net) => { + // Another tab connected – adopt the same state silently (auto-reconnect). + applyState(addr, net); + }, + onRemoteDisconnect: () => { + // Another tab disconnected – mirror the disconnection. + applyState(null, null); + }, + onRemoteAccountChange: (addr, net) => { + // Another tab switched accounts – adopt the new account to stay consistent + // (conflict resolution: last writer wins across tabs). + applyState(addr, net); + }, + }); + + // ── Poll Freighter and broadcast detected changes ───────────────────────── const syncAccount = useCallback(async () => { try { const connected = await isConnected(); + if (!connected) { - setAddress(null); + if (prevAddressRef.current !== null) { + // We were connected before — broadcast the disconnection. + broadcastDisconnect(); + } + applyState(null, null); return; } + const user = await getUserInfo(); - setAddress(user?.publicKey ?? null); + const newAddress = user?.publicKey ?? null; + // getUserInfo also exposes networkPassphrase in newer versions; + // fall back to null for compat with @stellar/freighter-api v1.5.x. + const newNetwork = + (user as unknown as Record)?.networkPassphrase as + | string + | null ?? null; + + if (newAddress === null) { + if (prevAddressRef.current !== null) { + broadcastDisconnect(); + } + applyState(null, null); + return; + } + + const previous = prevAddressRef.current; + + if (previous === null) { + // Freshly connected. + applyState(newAddress, newNetwork); + broadcastConnect(newAddress, newNetwork); + } else if (previous !== newAddress) { + // Account switched within the extension. + applyState(newAddress, newNetwork); + broadcastAccountChange(newAddress, newNetwork); + } else if (newNetwork !== network) { + // Same account but network changed. + setNetwork(newNetwork); + broadcastConnect(newAddress, newNetwork); + } } catch { - setAddress(null); + // Freighter unavailable – silently keep the last known state rather than + // immediately flashing a disconnected UI. } - }, []); + }, [ + applyState, + broadcastConnect, + broadcastDisconnect, + broadcastAccountChange, + network, + ]); + // ── Mount: initial sync + polling interval ──────────────────────────────── useEffect(() => { syncAccount(); const interval = setInterval(syncAccount, 2000); return () => clearInterval(interval); }, [syncAccount]); + // ── Derived state ───────────────────────────────────────────────────────── if (!address) return null; + return { address, displayName: `${address.slice(0, 4)}...${address.slice(-4)}`, + network, }; } diff --git a/hooks/useWalletSync.ts b/hooks/useWalletSync.ts new file mode 100644 index 0000000..1b26dbf --- /dev/null +++ b/hooks/useWalletSync.ts @@ -0,0 +1,200 @@ +/** + * useWalletSync + * + * Synchronises Freighter wallet connection state across browser tabs using the + * BroadcastChannel API. When any tab connects, disconnects, or switches + * accounts, all other open tabs pick up the change silently without polling. + * + * Message protocol (WalletSyncMessage) + * ───────────────────────────────────── + * type: 'WALLET_CONNECTED' – a tab has a connected account + * type: 'WALLET_DISCONNECTED' – a tab reports the wallet is disconnected + * type: 'WALLET_ACCOUNT_CHANGED' – the active account changed (conflict handling) + * type: 'REQUEST_STATE' – a new tab asks other tabs for the current state + * type: 'STATE_RESPONSE' – response carrying the current state + * + * All messages carry a `tabId` (random UUID generated once per tab) so each + * tab can ignore its own broadcasts. + * + * SSR safety: BroadcastChannel is only instantiated inside useEffect, so + * Next.js server-side rendering never touches the browser API. + */ + +import { useEffect, useRef, useCallback } from "react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type WalletSyncMessageType = + | "WALLET_CONNECTED" + | "WALLET_DISCONNECTED" + | "WALLET_ACCOUNT_CHANGED" + | "REQUEST_STATE" + | "STATE_RESPONSE"; + +export interface WalletSyncMessage { + type: WalletSyncMessageType; + /** Public key of the connected account, or null when disconnected. */ + address: string | null; + /** Network passphrase reported by Freighter. */ + network: string | null; + /** Unique identifier for the sending tab (prevents self-processing). */ + tabId: string; +} + +export interface WalletSyncHandlers { + /** Called when another tab reports a connection or state change. */ + onRemoteConnect: (address: string, network: string | null) => void; + /** Called when another tab reports a disconnection. */ + onRemoteDisconnect: () => void; + /** Called when another tab switches accounts (conflict resolution). */ + onRemoteAccountChange: (address: string, network: string | null) => void; +} + +export interface WalletSyncControls { + /** Broadcast that this tab has connected with the given address. */ + broadcastConnect: (address: string, network: string | null) => void; + /** Broadcast that this tab has disconnected. */ + broadcastDisconnect: () => void; + /** Broadcast that this tab has switched accounts. */ + broadcastAccountChange: (address: string, network: string | null) => void; + /** Ask other tabs to report their current state (useful on mount). */ + requestState: () => void; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CHANNEL_NAME = "trustflow:wallet-sync"; + +// ─── Utility ────────────────────────────────────────────────────────────────── + +/** Generate a random tab-scoped identifier. Falls back gracefully for old envs. */ +function generateTabId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Sets up a BroadcastChannel listener and returns helpers to broadcast wallet + * state changes to other tabs. + * + * @param handlers Callbacks invoked when remote tabs send state-change messages. + * @returns Controls for broadcasting this tab's state to other tabs. + */ +export function useWalletSync(handlers: WalletSyncHandlers): WalletSyncControls { + // Stable ref so the effect closure always calls the latest handlers without + // needing to re-subscribe the channel. + const handlersRef = useRef(handlers); + useEffect(() => { + handlersRef.current = handlers; + }); + + // Tab ID is stable for the lifetime of this page session. + const tabIdRef = useRef(generateTabId()); + + // BroadcastChannel ref – created once on mount, closed on unmount. + const channelRef = useRef(null); + + // ── Post a message to other tabs ────────────────────────────────────────── + const post = useCallback( + (type: WalletSyncMessageType, address: string | null, network: string | null) => { + channelRef.current?.postMessage({ + type, + address, + network, + tabId: tabIdRef.current, + } satisfies WalletSyncMessage); + }, + [] + ); + + // ── Set up the channel and message handler ──────────────────────────────── + useEffect(() => { + // BroadcastChannel is unavailable in Node / SSR – bail out silently. + if (typeof BroadcastChannel === "undefined") return; + + const channel = new BroadcastChannel(CHANNEL_NAME); + channelRef.current = channel; + + channel.onmessage = (event: MessageEvent) => { + const msg = event.data; + + // Ignore messages from this same tab. + if (!msg || msg.tabId === tabIdRef.current) return; + + const { onRemoteConnect, onRemoteDisconnect, onRemoteAccountChange } = + handlersRef.current; + + switch (msg.type) { + case "WALLET_CONNECTED": + case "STATE_RESPONSE": + if (msg.address) { + onRemoteConnect(msg.address, msg.network); + } + break; + + case "WALLET_DISCONNECTED": + onRemoteDisconnect(); + break; + + case "WALLET_ACCOUNT_CHANGED": + if (msg.address) { + onRemoteAccountChange(msg.address, msg.network); + } + break; + + case "REQUEST_STATE": + // Another tab has just opened and wants our current state. + // The response is handled by useAccount which re-broadcasts once it + // knows the current address. Defer to avoid a race with mounting. + break; + + default: + break; + } + }; + + // Ask other tabs to share their current state so this tab can hydrate + // without waiting for the next 2-second Freighter poll. + post("REQUEST_STATE", null, null); + + return () => { + channel.close(); + channelRef.current = null; + }; + }, [post]); + + // ── Public broadcast helpers ────────────────────────────────────────────── + + const broadcastConnect = useCallback( + (address: string, network: string | null) => { + post("WALLET_CONNECTED", address, network); + }, + [post] + ); + + const broadcastDisconnect = useCallback(() => { + post("WALLET_DISCONNECTED", null, null); + }, [post]); + + const broadcastAccountChange = useCallback( + (address: string, network: string | null) => { + post("WALLET_ACCOUNT_CHANGED", address, network); + }, + [post] + ); + + const requestState = useCallback(() => { + post("REQUEST_STATE", null, null); + }, [post]); + + return { + broadcastConnect, + broadcastDisconnect, + broadcastAccountChange, + requestState, + }; +} diff --git a/pages/_app.tsx b/pages/_app.tsx index b843273..52c1a24 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,13 +1,15 @@ import type { AppProps } from 'next/app' -import { createContext, useContext } from 'react' +import { createContext, useContext, useCallback } from 'react' import { useRouter } from 'next/router' import { NextIntlClientProvider } from 'next-intl' import '../styles/globals.css' import { useToast } from '../hooks/useToast' +import { useWalletSync } from '../hooks/useWalletSync' import { ToastContainer } from '../components/atoms/toast' import { defaultLocale, getMessages } from '../i18n/messages' -// Create a context for global toast access +// ─── Toast context ──────────────────────────────────────────────────────────── + interface ToastContextValue { success: (message: string) => void error: (message: string) => void @@ -25,6 +27,48 @@ export function useGlobalToast() { return context } +// ─── Wallet sync notifications ──────────────────────────────────────────────── + +/** + * Inner component that has access to the toast context and wires up + * BroadcastChannel wallet-sync notifications. + * + * Mounted inside ToastContext.Provider so it can call useGlobalToast(). + */ +function WalletSyncNotifier() { + const toast = useGlobalToast() + + const handleRemoteConnect = useCallback( + (address: string) => { + const display = `${address.slice(0, 4)}...${address.slice(-4)}` + toast.info(`Wallet connected in another tab (${display})`) + }, + [toast] + ) + + const handleRemoteDisconnect = useCallback(() => { + toast.warning('Wallet disconnected in another tab') + }, [toast]) + + const handleRemoteAccountChange = useCallback( + (address: string) => { + const display = `${address.slice(0, 4)}...${address.slice(-4)}` + toast.info(`Wallet account switched in another tab (${display})`) + }, + [toast] + ) + + useWalletSync({ + onRemoteConnect: handleRemoteConnect, + onRemoteDisconnect: handleRemoteDisconnect, + onRemoteAccountChange: handleRemoteAccountChange, + }) + + return null +} + +// ─── App shell ──────────────────────────────────────────────────────────────── + function MyApp({ Component, pageProps }: AppProps) { const { toasts, dismiss, success, error, warning, info } = useToast() const { locale } = useRouter() @@ -37,6 +81,8 @@ function MyApp({ Component, pageProps }: AppProps) { timeZone="UTC" > + {/* Wire up cross-tab wallet sync notifications at the app level */} +