diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index 64afa4f..e7cc474 100644 --- a/src/components/common/ConnectWalletButton.tsx +++ b/src/components/common/ConnectWalletButton.tsx @@ -1,5 +1,9 @@ import { useAccount, useConnect, useDisconnect } from 'wagmi'; import { shortenAddress } from '@/lib/web3/format'; +import { + WALLET_CONNECTION_AD_BLOCKER_MESSAGE, + useWalletConnectionStallDetection, +} from '@/hooks/useWalletConnectionStallDetection'; function ConnectWalletButton() { const { address, isConnected } = useAccount(); @@ -7,6 +11,10 @@ function ConnectWalletButton() { const { disconnect } = useDisconnect(); const primaryConnector = connectors[0]; + const showAdBlockerSuggestion = useWalletConnectionStallDetection({ + isAwaitingWalletResponse: isPending, + hasWalletResponse: isConnected || Boolean(error), + }); if (isConnected && address) { return ( @@ -35,6 +43,11 @@ function ConnectWalletButton() { {error ? (

{error.message}

) : null} + {showAdBlockerSuggestion ? ( +

+ {WALLET_CONNECTION_AD_BLOCKER_MESSAGE} +

+ ) : null} ); } diff --git a/src/components/common/WalletConnectCalloutBanner.tsx b/src/components/common/WalletConnectCalloutBanner.tsx index 1d3fd9a..64b2755 100644 --- a/src/components/common/WalletConnectCalloutBanner.tsx +++ b/src/components/common/WalletConnectCalloutBanner.tsx @@ -2,6 +2,10 @@ import { useState } from 'react'; import { Wallet } from 'lucide-react'; import { useAccount, useConnect, useReconnect } from 'wagmi'; import { cn } from '@/lib/utils'; +import { + WALLET_CONNECTION_AD_BLOCKER_MESSAGE, + useWalletConnectionStallDetection, +} from '@/hooks/useWalletConnectionStallDetection'; import showToast from '@/utils/toast.util'; interface WalletConnectCalloutBannerProps { @@ -21,6 +25,10 @@ const WalletConnectCalloutBanner: React.FC = ({ const [isReconnecting, setIsReconnecting] = useState(false); const retryConnector = reconnectConnectors[0] ?? connectConnectors[0]; + const showAdBlockerSuggestion = useWalletConnectionStallDetection({ + isAwaitingWalletResponse: isReconnecting, + hasWalletResponse: isConnected, + }); const handleReconnect = async () => { if (isReconnecting) { @@ -95,6 +103,11 @@ const WalletConnectCalloutBanner: React.FC = ({ + {showAdBlockerSuggestion ? ( +

+ {WALLET_CONNECTION_AD_BLOCKER_MESSAGE} +

+ ) : null} ); }; diff --git a/src/hooks/__tests__/useWalletConnectionStallDetection.test.ts b/src/hooks/__tests__/useWalletConnectionStallDetection.test.ts new file mode 100644 index 0000000..11b8ca0 --- /dev/null +++ b/src/hooks/__tests__/useWalletConnectionStallDetection.test.ts @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + WALLET_CONNECTION_STALL_TIMEOUT_MS, + useWalletConnectionStallDetection, +} from '@/hooks/useWalletConnectionStallDetection'; + +describe('useWalletConnectionStallDetection', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reports a stalled wallet connection after the named timeout elapses', () => { + const { result } = renderHook(() => + useWalletConnectionStallDetection({ + isAwaitingWalletResponse: true, + }) + ); + + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(WALLET_CONNECTION_STALL_TIMEOUT_MS - 1); + }); + + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(result.current).toBe(true); + }); + + it('does not report a stall when the connection succeeds before the timeout', () => { + const { result, rerender } = renderHook( + ({ hasWalletResponse, isAwaitingWalletResponse }) => + useWalletConnectionStallDetection({ + hasWalletResponse, + isAwaitingWalletResponse, + }), + { + initialProps: { + hasWalletResponse: false, + isAwaitingWalletResponse: true, + }, + } + ); + + act(() => { + vi.advanceTimersByTime(WALLET_CONNECTION_STALL_TIMEOUT_MS / 2); + }); + + rerender({ hasWalletResponse: true, isAwaitingWalletResponse: false }); + + act(() => { + vi.advanceTimersByTime(WALLET_CONNECTION_STALL_TIMEOUT_MS); + }); + + expect(result.current).toBe(false); + }); + + it('does not report a stall when the connection fails before the timeout', () => { + const { result, rerender } = renderHook( + ({ hasWalletResponse, isAwaitingWalletResponse }) => + useWalletConnectionStallDetection({ + hasWalletResponse, + isAwaitingWalletResponse, + }), + { + initialProps: { + hasWalletResponse: false, + isAwaitingWalletResponse: true, + }, + } + ); + + rerender({ hasWalletResponse: true, isAwaitingWalletResponse: true }); + + act(() => { + vi.advanceTimersByTime(WALLET_CONNECTION_STALL_TIMEOUT_MS); + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/src/hooks/useWalletConnectionStallDetection.ts b/src/hooks/useWalletConnectionStallDetection.ts new file mode 100644 index 0000000..d737d74 --- /dev/null +++ b/src/hooks/useWalletConnectionStallDetection.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +export const WALLET_CONNECTION_STALL_TIMEOUT_MS = 10_000; + +export const WALLET_CONNECTION_AD_BLOCKER_MESSAGE = + 'No wallet response yet. If your wallet prompt did not open, an ad blocker or privacy extension may be blocking the wallet script. Disable it for this site, then try connecting again.'; + +interface UseWalletConnectionStallDetectionOptions { + /** True while the app is waiting for a wallet connector to respond. */ + isAwaitingWalletResponse: boolean; + /** True once the wallet attempt has connected, failed, or otherwise returned. */ + hasWalletResponse?: boolean; + /** Override mainly for tests or specialized wallet flows. */ + timeoutMs?: number; +} + +/** + * Flags wallet connection attempts that remain pending long enough to suggest + * browser extensions may have blocked the injected wallet script. + */ +export function useWalletConnectionStallDetection({ + isAwaitingWalletResponse, + hasWalletResponse = false, + timeoutMs = WALLET_CONNECTION_STALL_TIMEOUT_MS, +}: UseWalletConnectionStallDetectionOptions): boolean { + const [hasStalled, setHasStalled] = useState(false); + + useEffect(() => { + if (!isAwaitingWalletResponse || hasWalletResponse) { + setHasStalled(false); + return undefined; + } + + const timeoutId = window.setTimeout(() => { + setHasStalled(true); + }, timeoutMs); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [hasWalletResponse, isAwaitingWalletResponse, timeoutMs]); + + return hasStalled; +}