diff --git a/src/components/common/cardano-objects/connect-wallet.tsx b/src/components/common/cardano-objects/connect-wallet.tsx index f067d6b..c318612 100644 --- a/src/components/common/cardano-objects/connect-wallet.tsx +++ b/src/components/common/cardano-objects/connect-wallet.tsx @@ -1,4 +1,4 @@ -import { Wallet } from "lucide-react"; +import { Wallet, Loader2, CheckCircle2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,121 +8,232 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useWallet, useWalletList, useNetwork, useAssets } from "@meshsdk/react"; +import { useWalletList, useNetwork, useAssets } from "@meshsdk/react"; import { useSiteStore } from "@/lib/zustand/site"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import useUser from "@/hooks/useUser"; import { useUserStore } from "@/lib/zustand/user"; import { getProvider } from "@/utils/get-provider"; +import { useWalletContext, WalletState } from "@/hooks/useWalletContext"; +import { useToast } from "@/hooks/use-toast"; +import { cn } from "@/lib/utils"; +import { useWalletDetection } from "@/hooks/useWalletDetection"; +// Main component - uses walletDetectionKey to force remount of useWalletList export default function ConnectWallet() { + // Force re-mount key for useWalletList when wallets are detected + const [walletDetectionKey, setWalletDetectionKey] = useState(0); + const hasTriggeredRemountRef = useRef(false); + const remountTimeoutRef = useRef(null); + + // Use wallet detection hook to monitor window.cardano + useWalletDetection({ + onWalletsDetected: (count) => { + // Only trigger remount once + if (!hasTriggeredRemountRef.current && count > 0) { + hasTriggeredRemountRef.current = true; + + // Clear any existing timeout + if (remountTimeoutRef.current) { + clearTimeout(remountTimeoutRef.current); + } + + // Delay to give MeshJS time to process wallets, but only remount once + remountTimeoutRef.current = setTimeout(() => { + setWalletDetectionKey((prev) => prev + 1); + remountTimeoutRef.current = null; + }, 300); + } + }, + pollingInterval: 100, + maxPollingTime: 10000, + }); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (remountTimeoutRef.current) { + clearTimeout(remountTimeoutRef.current); + } + }; + }, []); + + return ; +} + +// Internal component that uses useWalletList - will remount when key changes +function ConnectWalletInner() { + const wallets = useWalletList(); + const networkId = useNetwork(); + const assets = useAssets(); + + + return ; +} + +// Main component content +function ConnectWalletContent({ + wallets, + networkId, + assets, +}: { + wallets: ReturnType; + networkId: ReturnType; + assets: ReturnType; +}) { const setNetwork = useSiteStore((state) => state.setNetwork); - const pastWallet = useUserStore((state) => state.pastWallet); - const setPastWallet = useUserStore((state) => state.setPastWallet); const setUserAssets = useUserStore((state) => state.setUserAssets); const setUserAssetMetadata = useUserStore( (state) => state.setUserAssetMetadata, ); const { user, isLoading: userLoading } = useUser(); + const { toast } = useToast(); - const wallets = useWalletList(); - const { connect, connected, wallet, name } = useWallet(); - const networkId = useNetwork(); - const assets = useAssets(); - const network = useSiteStore((state) => state.network); - const retryTimeoutRef = useRef(null); - const retryCountRef = useRef(0); - const walletsRef = useRef(wallets); + // Use WalletContext directly for better state access + const { + state, + connectingWallet, + connectedWalletName, + connectWallet: connectWalletContext, + disconnect, + setPersist, + error, + } = useWalletContext(); - // Keep wallets ref in sync - useEffect(() => { - walletsRef.current = wallets; - }, [wallets]); + // Track wallet detection state + const [detectingWallets, setDetectingWallets] = useState(true); + const [dropdownOpen, setDropdownOpen] = useState(false); + const walletsRef = useRef(wallets); + const hasInitializedPersist = useRef(false); + const hasAttemptedAutoConnect = useRef(false); - async function connectWallet(walletId: string) { - setPastWallet(walletId); - await connect(walletId); - } + // Check if any Sheet/Dialog is open to prevent dropdown from opening + const checkIfSheetOpen = useCallback(() => { + if (typeof window === "undefined") return false; + const sheets = document.querySelectorAll('[data-radix-dialog-content], [data-radix-sheet-content]'); + return Array.from(sheets).some( + (sheet) => sheet.getAttribute('data-state') === 'open' + ); + }, []); - // Retry wallet detection on mount to handle cached loads - // Browser extensions inject wallets asynchronously, and cached scripts may load - // before MeshProvider/wallet detection is fully initialized + // Close dropdown when a Sheet/Dialog opens to prevent aria-hidden conflicts useEffect(() => { - // If wallets are already detected, no need to retry - if (wallets.length > 0) { - retryCountRef.current = 0; - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; + if (!dropdownOpen) return; + + const checkForOpenSheets = () => { + if (checkIfSheetOpen()) { + setDropdownOpen(false); } - return; - } + }; - // Clear any existing timeout - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; + // Check periodically when dropdown is open + const interval = setInterval(checkForOpenSheets, 100); + return () => clearInterval(interval); + }, [dropdownOpen, checkIfSheetOpen]); + + // Prevent dropdown from opening if Sheet is already open + const handleDropdownOpenChange = useCallback((open: boolean) => { + if (open && checkIfSheetOpen()) { + return; // Don't open if Sheet is open } + setDropdownOpen(open); + }, [checkIfSheetOpen]); - // Reset retry count - retryCountRef.current = 0; - - // Retry detection with increasing delays: immediate, 100ms, 500ms, 1000ms - // This gives MeshProvider and browser extensions time to initialize - const delays = [0, 100, 500, 1000]; - const maxRetries = delays.length; - - const checkWallets = () => { - // Check current wallets (using ref to avoid closure issues) - if (walletsRef.current.length > 0) { - retryCountRef.current = 0; - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; - } - return; - } - // If we've reached max retries, stop - if (retryCountRef.current >= maxRetries) { - retryCountRef.current = 0; - return; - } - // Schedule next retry - const delay = delays[retryCountRef.current]; - retryCountRef.current++; - - // Use setTimeout even for 0ms delay to avoid recursion issues - retryTimeoutRef.current = setTimeout(checkWallets, delay); - }; + // Keep wallets ref in sync + useEffect(() => { + walletsRef.current = wallets; + if (wallets.length > 0) { + setDetectingWallets(false); + } + }, [wallets]); - // Start checking immediately (using setTimeout with 0ms for next tick) - retryTimeoutRef.current = setTimeout(checkWallets, 0); - retryCountRef.current = 1; + // Initialize MeshJS persistence on mount + useEffect(() => { + if (!hasInitializedPersist.current) { + setPersist(true); + hasInitializedPersist.current = true; + } + }, [setPersist]); - return () => { - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; + // Update detectingWallets state based on wallet detection + useEffect(() => { + if (wallets.length > 0) { + setDetectingWallets(false); + } else { + // Check window.cardano directly to see if wallets are available but not yet detected by useWalletList + if (typeof window !== "undefined") { + const cardano = (window as any).cardano || {}; + const walletKeys = Object.keys(cardano); + if (walletKeys.length > 0) { + setDetectingWallets(true); + } else { + setDetectingWallets(false); + } } - retryCountRef.current = 0; - }; + } }, [wallets.length]); - // Auto-connect if user had connected before + // Auto-connect using MeshJS persistence useEffect(() => { - if (pastWallet && !connected && wallets.length > 0) { - const walletExists = wallets.some((w) => w.id === pastWallet); - if (walletExists) { - connect(pastWallet).catch(() => { - setPastWallet(undefined); - }); - } else { - setPastWallet(undefined); + if ( + String(state) === String(WalletState.NOT_CONNECTED) && + wallets.length > 0 && + !connectingWallet && + !hasAttemptedAutoConnect.current + ) { + // Check MeshJS localStorage persistence + const persisted = localStorage.getItem("mesh-wallet-persist"); + + if (persisted) { + hasAttemptedAutoConnect.current = true; + try { + const { walletName } = JSON.parse(persisted); + const walletExists = wallets.some((w) => w.id === walletName); + + if (walletExists) { + connectWalletContext(walletName, true).catch(() => { + // Clear invalid persistence + localStorage.removeItem("mesh-wallet-persist"); + hasAttemptedAutoConnect.current = false; + }); + } else { + // Clear invalid persistence + localStorage.removeItem("mesh-wallet-persist"); + hasAttemptedAutoConnect.current = false; + } + } catch { + // Clear corrupted persistence + localStorage.removeItem("mesh-wallet-persist"); + hasAttemptedAutoConnect.current = false; + } } } - }, [pastWallet, connected, wallets, connect, setPastWallet]); + + // Reset auto-connect flag when disconnected + if (String(state) === String(WalletState.NOT_CONNECTED) && !connectingWallet) { + hasAttemptedAutoConnect.current = false; + } + }, [state, wallets, connectingWallet, connectWalletContext]); + + // Handle connection errors with toast notifications + useEffect(() => { + if (error) { + const errorMessage = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Failed to connect wallet"; + + toast({ + variant: "destructive", + title: "Connection Error", + description: errorMessage, + }); + } + }, [error, toast]); // Sync network from hook to store useEffect(() => { @@ -133,15 +244,22 @@ export default function ConnectWallet() { // Process assets and fetch metadata useEffect(() => { - if (!connected || !assets || assets.length === 0 || networkId === undefined || networkId === null) return; + if ( + String(state) !== String(WalletState.CONNECTED) || + !assets || + assets.length === 0 || + networkId === undefined || + networkId === null + ) + return; async function processAssets() { if (!assets || networkId === undefined || networkId === null) return; - + try { const provider = getProvider(networkId); setUserAssets(assets); - + for (const asset of assets) { if (asset.unit === "lovelace") continue; try { @@ -157,41 +275,234 @@ export default function ConnectWallet() { // Continue if asset metadata fetch fails } } - } catch (error) { - console.error("Error processing assets:", error); + } catch { + // Continue if asset processing fails } } processAssets(); - }, [assets, connected, networkId, setUserAssets, setUserAssetMetadata]); + }, [ + assets, + state, + networkId, + setUserAssets, + setUserAssetMetadata, + ]); + + async function handleConnectWallet(walletId: string) { + try { + await connectWalletContext(walletId, true); + toast({ + title: "Wallet Connected", + description: `Successfully connected to ${wallets.find((w) => w.id === walletId)?.name || walletId}`, + }); + } catch { + // Error handling is done via error state useEffect + } + } + + async function handleDisconnect() { + disconnect(); + toast({ + title: "Wallet Disconnected", + description: "You have been disconnected from your wallet", + }); + } + + // Determine button state and content + const isConnected = String(state) === String(WalletState.CONNECTED); + const isConnecting = + String(state) === String(WalletState.CONNECTING) || connectingWallet; + const isLoading = isConnecting || (isConnected && (!user || userLoading)); + + // Get button text and icon + const getButtonContent = () => { + if (isConnecting) { + return ( + <> + + Connecting... + + ); + } + if (isConnected && isLoading) { + return ( + <> + + Loading... + + ); + } + if (isConnected && user && !userLoading) { + return ( + <> + + {connectedWalletName || "Connected"} + + ); + } + return ( + <> + + Connect Wallet + + ); + }; return ( - + - - - Select Wallet - + { + // Prevent focus trap when closing + e.preventDefault(); + }} + > + + + Select Wallet + + {detectingWallets && ( +
+ + + Detecting... + +
+ )} +
+ + + {isConnected && ( + <> + + + Disconnect + + + + )} + {wallets.length === 0 ? ( - - - No wallets available. Please install a Cardano wallet extension. - - +
+
+
+
+ +
+
+

+ No wallets detected +

+

+ Please install a Cardano wallet extension to continue. +

+ {process.env.NODE_ENV === "development" && ( +
+
Debug: detectingWallets={String(detectingWallets)}
+
Debug: wallets.length={wallets.length}
+
Debug: Check console for detailed logs
+
+ )} +
+
+
+
) : ( - wallets.map((wallet, i) => { - return ( - connectWallet(wallet.id)}> - {wallet.name} - - ); - }) +
+ {wallets.map((wallet) => { + const isCurrentWallet = + isConnected && connectedWalletName === wallet.id; + return ( + handleConnectWallet(wallet.id)} + disabled={isCurrentWallet || isConnecting} + className={cn( + "px-3 py-2.5 rounded-md", + "transition-all duration-150", + "cursor-pointer", + isCurrentWallet && [ + "bg-zinc-100 dark:bg-zinc-800", + "border border-zinc-200 dark:border-zinc-700", + "font-medium cursor-default", + ], + !isCurrentWallet && [ + "hover:bg-zinc-100 dark:hover:bg-zinc-800", + "focus:bg-zinc-100 dark:focus:bg-zinc-800", + ], + isConnecting && "opacity-50 cursor-wait" + )} + > +
+
+ {isCurrentWallet ? ( + + ) : ( +
+ )} + + {wallet.name} + +
+ {isCurrentWallet && ( + + Active + + )} +
+ + ); + })} +
)} diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 69486f1..1b88e85 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -8,6 +8,7 @@ import { api } from "@/utils/api"; import useUser from "@/hooks/useUser"; import { useUserStore } from "@/lib/zustand/user"; import useAppWallet from "@/hooks/useAppWallet"; +import { useWalletContext, WalletState } from "@/hooks/useWalletContext"; import useMultisigWallet from "@/hooks/useMultisigWallet"; import SessionProvider from "@/components/SessionProvider"; @@ -78,7 +79,8 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const { connected, wallet } = useWallet(); + const { wallet } = useWallet(); + const { state: walletState, connectedWalletInstance } = useWalletContext(); const address = useAddress(); const { user, isLoading } = useUser(); const router = useRouter(); @@ -89,6 +91,13 @@ export default function RootLayout({ const userAddress = useUserStore((state) => state.userAddress); const setUserAddress = useUserStore((state) => state.setUserAddress); const ctx = api.useUtils(); + + // Use WalletState for connection check + const connected = String(walletState) === String(WalletState.CONNECTED); + // Use connectedWalletInstance if available, otherwise fall back to wallet + const activeWallet = connectedWalletInstance && Object.keys(connectedWalletInstance).length > 0 + ? connectedWalletInstance + : wallet; // Global error handler for unhandled promise rejections useEffect(() => { @@ -143,21 +152,21 @@ export default function RootLayout({ // Initialize wallet and create user when connected useEffect(() => { - if (!connected || !wallet || user || !address) return; + if (!connected || !activeWallet || user || !address) return; async function initializeWallet() { if (!address) return; try { // Get stake address - const stakeAddresses = await wallet.getRewardAddresses(); + const stakeAddresses = await activeWallet.getRewardAddresses(); const stakeAddress = stakeAddresses[0]; if (!stakeAddress) return; // Get DRep key hash (optional) let drepKeyHash = ""; try { - const dRepKey = await wallet.getDRep(); + const dRepKey = await activeWallet.getDRep(); if (dRepKey?.publicKeyHash) { drepKeyHash = dRepKey.publicKeyHash; } @@ -182,7 +191,7 @@ export default function RootLayout({ } initializeWallet(); - }, [connected, wallet, user, address, createUser, generateNsec]); + }, [connected, activeWallet, user, address, createUser, generateNsec]); const isWalletPath = router.pathname.includes("/wallets/[wallet]"); const walletPageRoute = router.pathname.split("/wallets/[wallet]/")[1]; diff --git a/src/components/ui/mobile-navigation.tsx b/src/components/ui/mobile-navigation.tsx index 957bd14..e4e24dc 100644 --- a/src/components/ui/mobile-navigation.tsx +++ b/src/components/ui/mobile-navigation.tsx @@ -32,6 +32,20 @@ export function MobileNavigation({ showWalletMenu, isLoggedIn, walletId, fallbac const router = useRouter(); const [open, setOpen] = useState(false); + // Close any open dropdowns when sheet opens to prevent aria-hidden conflicts + React.useEffect(() => { + if (open) { + // Close any open Radix dropdown menus + const dropdownTriggers = document.querySelectorAll('[data-radix-dropdown-menu-trigger]'); + dropdownTriggers.forEach((trigger) => { + const button = trigger as HTMLElement; + if (button.getAttribute('data-state') === 'open') { + button.click(); + } + }); + } + }, [open]); + return ( <> {/* Custom overlay - rendered outside of Sheet via Portal */} @@ -108,6 +122,24 @@ export function MobileNavigation({ showWalletMenu, isLoggedIn, walletId, fallbac "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left" )} style={{ top: '56px', height: 'calc(100vh - 56px)' }} + onOpenAutoFocus={(e) => { + // Blur any focused elements in the header to prevent aria-hidden conflicts + const header = document.querySelector('[data-header="main"]'); + if (header) { + const focusedElement = header.querySelector(':focus'); + if (focusedElement && focusedElement instanceof HTMLElement) { + focusedElement.blur(); + } + } + // Focus the first focusable element in the sheet content + e.preventDefault(); + if (e.currentTarget && e.currentTarget instanceof Element) { + const firstFocusable = e.currentTarget.querySelector('a, button, [tabindex]:not([tabindex="-1"])'); + if (firstFocusable && firstFocusable instanceof HTMLElement) { + firstFocusable.focus(); + } + } + }} onInteractOutside={(e) => { // Check if click was in header const header = document.querySelector('[data-header="main"]'); diff --git a/src/hooks/useWalletContext.ts b/src/hooks/useWalletContext.ts new file mode 100644 index 0000000..e751e4a --- /dev/null +++ b/src/hooks/useWalletContext.ts @@ -0,0 +1,32 @@ +import { useContext } from "react"; +import { WalletContext } from "@meshsdk/react"; + +/** + * WalletState enum matching MeshJS implementation + */ +export enum WalletState { + NOT_CONNECTED = "NOT_CONNECTED", + CONNECTING = "CONNECTING", + CONNECTED = "CONNECTED", +} + +/** + * Custom hook to access MeshJS WalletContext directly + * Provides access to internal state like WalletState enum, connectingWallet, and error + */ +export function useWalletContext() { + const context = useContext(WalletContext); + + if (!context) { + throw new Error( + "useWalletContext must be used within a MeshProvider", + ); + } + + return { + ...context, + // Expose WalletState enum for convenience + WalletState, + }; +} + diff --git a/src/hooks/useWalletDetection.ts b/src/hooks/useWalletDetection.ts new file mode 100644 index 0000000..3a294b9 --- /dev/null +++ b/src/hooks/useWalletDetection.ts @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState, useCallback } from "react"; + +/** + * Hook to detect when Cardano wallets are injected into window.cardano + * Polls window.cardano periodically and triggers callbacks when wallets appear + */ +export function useWalletDetection(options?: { + onWalletsDetected?: (walletCount: number) => void; + pollingInterval?: number; + maxPollingTime?: number; +}) { + const { + onWalletsDetected, + pollingInterval = 100, + maxPollingTime = 10000, // 10 seconds max polling + } = options || {}; + + const [detectedWalletCount, setDetectedWalletCount] = useState(0); + const [isPolling, setIsPolling] = useState(false); + const intervalRef = useRef(null); + const startTimeRef = useRef(Date.now()); + const hasDetectedWalletsRef = useRef(false); + const hasCalledCallbackRef = useRef(false); + const pollCountRef = useRef(0); + + const checkWallets = useCallback(() => { + if (typeof window === "undefined") return 0; + + // If we've already detected wallets and called the callback, stop checking + if (hasDetectedWalletsRef.current) { + return detectedWalletCount; + } + + const cardano = (window as any).cardano || {}; + const walletKeys = Object.keys(cardano); + const walletCount = walletKeys.length; + + pollCountRef.current++; + + if (walletCount > 0 && !hasDetectedWalletsRef.current) { + hasDetectedWalletsRef.current = true; + setDetectedWalletCount(walletCount); + setIsPolling(false); + + // Clear polling interval BEFORE calling callback + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + // Call callback only once + if (!hasCalledCallbackRef.current && onWalletsDetected) { + hasCalledCallbackRef.current = true; + onWalletsDetected(walletCount); + } + return walletCount; + } + + // Check if we've exceeded max polling time + const elapsed = Date.now() - startTimeRef.current; + if (elapsed >= maxPollingTime) { + setIsPolling(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return walletCount; + } + + return walletCount; + }, [onWalletsDetected, maxPollingTime, detectedWalletCount]); + + const startPolling = useCallback(() => { + if (intervalRef.current) { + return; + } + + if (typeof window === "undefined") { + return; + } + + // Check immediately + const initialCount = checkWallets(); + if (initialCount > 0) { + return; // Already detected, no need to poll + } + + setIsPolling(true); + startTimeRef.current = Date.now(); + pollCountRef.current = 0; + + // Start polling + intervalRef.current = setInterval(() => { + checkWallets(); + }, pollingInterval); + }, [checkWallets, pollingInterval]); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsPolling(false); + }, []); + + const reset = useCallback(() => { + stopPolling(); + hasDetectedWalletsRef.current = false; + setDetectedWalletCount(0); + startTimeRef.current = Date.now(); + pollCountRef.current = 0; + }, [stopPolling]); + + // Auto-start polling on mount if no wallets detected + useEffect(() => { + if (typeof window === "undefined") return; + + // Check if wallets are already available + const cardano = (window as any).cardano || {}; + const initialCount = Object.keys(cardano).length; + + if (initialCount > 0 && !hasCalledCallbackRef.current) { + hasDetectedWalletsRef.current = true; + setDetectedWalletCount(initialCount); + setIsPolling(false); + + // Call callback if wallets are already present + if (onWalletsDetected && !hasCalledCallbackRef.current) { + hasCalledCallbackRef.current = true; + onWalletsDetected(initialCount); + } + return; + } + + // Start polling if no wallets detected + if (initialCount === 0) { + startPolling(); + } + + return () => { + stopPolling(); + }; + }, [startPolling, stopPolling, onWalletsDetected]); + + return { + detectedWalletCount, + isPolling, + startPolling, + stopPolling, + reset, + checkWallets: () => { + const count = checkWallets(); + setDetectedWalletCount(count); + return count; + }, + }; +} +