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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 39 additions & 6 deletions components/atoms/connect-button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 {
Expand All @@ -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 (
<div className={styles.wrapper}>
<button
className={`${styles.button} ${loading ? styles.loading : ''}`}
className={`${styles.button} ${isAnyLoading ? styles.loading : ''}`}
style={{ height: isHigher ? 50 : 38, minWidth: isHigher ? 240 : undefined }}
onClick={handleClick}
disabled={loading}
aria-busy={loading}
disabled={isDisabled}
aria-busy={isAnyLoading}
aria-disabled={isDisabled}
>
{loading ? (
{isAnyLoading ? (
<span className={styles.spinner} aria-hidden="true" />
) : null}
<span>{loading ? 'Connecting…' : label}</span>
<span>{buttonLabel}</span>
</button>
{error && (
<p className={styles.error} role="alert">
Expand Down
121 changes: 104 additions & 17 deletions components/atoms/wallet-button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -27,52 +41,81 @@ export function WalletButton({
onDisconnect,
switchAccountLabel = 'Switch account',
disconnectLabel = 'Disconnect',
syncedAcrossTabs = false,
previousAddress = null,
onDismissSwitchNotice,
}: WalletButtonProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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()
}

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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
onClick={handleToggle}
aria-expanded={open}
aria-haspopup="true"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{/* Connection indicator */}
<span
className="w-2 h-2 rounded-full bg-green-500"
aria-hidden="true"
title="Connected"
/>
{/* Connection indicator — with optional sync pulse ring */}
<span className="relative flex items-center justify-center w-2 h-2" aria-hidden="true">
<span className="w-2 h-2 rounded-full bg-green-500 block" title="Connected" />
{syncedAcrossTabs && (
<span
className="absolute inset-0 rounded-full bg-green-400 animate-ping opacity-60"
title="Synced across tabs"
/>
)}
</span>

{displayName}

{/* Chevron */}
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform ${
Expand All @@ -95,16 +138,60 @@ export function WalletButton({
{open && (
<div
role="menu"
className="absolute right-0 mt-1 w-56 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg py-1 z-50"
className="absolute right-0 mt-1 w-64 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg py-1 z-50"
>
{/* Account-switch notice */}
{previousAddress && (
<div
role="status"
aria-live="polite"
className="mx-2 mb-1 px-3 py-2 rounded-md bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
>
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
Account switched
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5 font-mono">
{previousAddress.slice(0, 6)}…{previousAddress.slice(-4)}
{' '}→{' '}
{address.slice(0, 6)}…{address.slice(-4)}
</p>
</div>
)}

{/* Network badge */}
{networkLabel && (
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{networkLabel}
</span>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" aria-hidden="true" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{networkLabel}
</span>
</div>
{/* Sync indicator */}
{syncedAcrossTabs && (
<span
className="text-xs text-indigo-500 dark:text-indigo-400 flex items-center gap-1"
title="State is synced across all open tabs"
>
{/* Two-arrows sync icon */}
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Synced
</span>
)}
</div>
</div>
)}
Expand Down
10 changes: 8 additions & 2 deletions components/molecules/wallet-data/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import styles from './style.module.css'
* Displays connected wallet address and network or a connect button.
*
* Uses `useWallet` so it picks up network and connection state automatically.
* Passes `isConnecting` to ConnectButton so it reflects a connection already
* in progress from another tab, preventing duplicate Freighter prompts.
*/
export function WalletData() {
const mounted = useIsMounted()
const { account, connect } = useWallet()
const { account, connect, isBusy } = useWallet()

if (!mounted) {
return <ConnectButton label="Connect Wallet" />
Expand All @@ -23,7 +25,11 @@ export function WalletData() {
<div className={styles.card}>{account.displayName}</div>
</div>
) : (
<ConnectButton label="Connect Wallet" onConnect={connect} />
<ConnectButton
label="Connect Wallet"
onConnect={connect}
isConnecting={isBusy}
/>
)}
</>
)
Expand Down
Loading
Loading