Skip to content
Open
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
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./useIsMounted";
export * from './useSubscription';
export * from './useUSDCPrice';
export * from './useUserProfile';
export * from './useWalletSync';
120 changes: 115 additions & 5 deletions hooks/useAccount.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [network, setNetwork] = useState<string | null>(null);

// Track the previous address so we can detect account switches.
const prevAddressRef = useRef<string | null>(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<string, unknown>)?.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,
};
}
200 changes: 200 additions & 0 deletions hooks/useWalletSync.ts
Original file line number Diff line number Diff line change
@@ -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<WalletSyncHandlers>(handlers);
useEffect(() => {
handlersRef.current = handlers;
});

// Tab ID is stable for the lifetime of this page session.
const tabIdRef = useRef<string>(generateTabId());

// BroadcastChannel ref – created once on mount, closed on unmount.
const channelRef = useRef<BroadcastChannel | null>(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<WalletSyncMessage>) => {
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,
};
}
Loading