diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 148fa43f..eb10e1cf 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -17,6 +17,26 @@ import { useTheme } from 'theme-o-rama';
import { useLocalStorage } from 'usehooks-ts';
import { BottomNav, TopNav } from './Nav';
+function WalletTransitionWrapper({ children }: PropsWithChildren) {
+ const { isSwitching, wallet } = useWallet();
+
+ // Only show content if we have a wallet or we're not switching
+ // This prevents old wallet data from showing during transition
+ const shouldShow = wallet !== null || !isSwitching;
+
+ return (
+
+ {shouldShow ? children : null}
+
+ );
+}
+
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'sage-wallet-sidebar-collapsed';
type LayoutProps = PropsWithChildren & {
@@ -117,7 +137,9 @@ export function FullLayout(props: LayoutProps) {
onClick={() => setIsCollapsed(!isCollapsed)}
className='text-2xl hover:scale-110 transition-transform cursor-pointer'
aria-label={t`Expand sidebar - ${wallet.name}`}
- aria-expanded={!isCollapsed}
+ {...(isCollapsed
+ ? { 'aria-expanded': false }
+ : { 'aria-expanded': true })}
>
{wallet.emoji}
@@ -140,7 +162,9 @@ export function FullLayout(props: LayoutProps) {
aria-label={
isCollapsed ? t`Expand sidebar` : t`Collapse sidebar`
}
- aria-expanded={!isCollapsed}
+ {...(isCollapsed
+ ? { 'aria-expanded': false }
+ : { 'aria-expanded': true })}
>
{isCollapsed ? (
@@ -183,27 +207,29 @@ export function FullLayout(props: LayoutProps) {
-
+ >
+
+ {props.children}
+
+
);
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index 0ec64879..5e083b2f 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -1,8 +1,3 @@
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@/components/ui/tooltip';
import { usePeers } from '@/hooks/usePeers';
import { logoutAndUpdateState, useWalletState } from '@/state';
import { t } from '@lingui/core/macro';
@@ -16,15 +11,15 @@ import {
FilePenLine,
Handshake,
Images,
- LogOut,
MonitorCheck,
MonitorCog,
SquareUserRound,
WalletIcon,
} from 'lucide-react';
-import { PropsWithChildren } from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
+import { NavLink } from './NavLink';
import { Separator } from './ui/separator';
+import { WalletSwitcher } from './WalletSwitcher';
interface NavProps {
isCollapsed?: boolean;
@@ -212,86 +207,7 @@ export function BottomNav({ isCollapsed }: NavProps) {
- Logout}
- >
-
-
+
);
}
-
-interface NavLinkProps extends PropsWithChildren {
- url: string | (() => void);
- isCollapsed?: boolean;
- message: React.ReactNode;
- customTooltip?: React.ReactNode;
- ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false;
-}
-
-function NavLink({
- url,
- children,
- isCollapsed,
- message,
- customTooltip,
- ariaCurrent,
-}: NavLinkProps) {
- const location = useLocation();
- const isActive =
- typeof url === 'string' &&
- (location.pathname === url ||
- (url !== '/' && location.pathname.startsWith(url)));
-
- const baseClassName = `flex items-center gap-3 transition-all ${
- isCollapsed ? 'justify-center p-2 rounded-full' : 'px-2 rounded-lg py-1.5'
- } text-lg md:text-base`;
-
- const className = isActive
- ? `${baseClassName} text-primary border-primary`
- : `${baseClassName} text-muted-foreground hover:text-primary`;
-
- const activeStyle = isActive
- ? { backgroundColor: 'var(--nav-active-background)' }
- : {};
-
- const link =
- typeof url === 'string' ? (
-
- {children}
- {!isCollapsed && message}
-
- ) : (
-
- {children}
- {!isCollapsed && message}
-
- );
-
- if (isCollapsed || customTooltip) {
- return (
-
- {link}
-
- {customTooltip || message}
-
-
- );
- }
-
- return link;
-}
diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx
new file mode 100644
index 00000000..fab18551
--- /dev/null
+++ b/src/components/NavLink.tsx
@@ -0,0 +1,80 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { PropsWithChildren } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+
+interface NavLinkProps extends PropsWithChildren {
+ url: string | (() => void);
+ isCollapsed?: boolean;
+ message: React.ReactNode;
+ customTooltip?: React.ReactNode;
+ ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false;
+}
+
+export function NavLink({
+ url,
+ children,
+ isCollapsed,
+ message,
+ customTooltip,
+ ariaCurrent,
+}: NavLinkProps) {
+ const location = useLocation();
+ const isActive =
+ typeof url === 'string' &&
+ (location.pathname === url ||
+ (url !== '/' && location.pathname.startsWith(url)));
+
+ const baseClassName = `flex items-center gap-3 transition-all ${
+ isCollapsed ? 'justify-center p-2 rounded-full' : 'px-2 rounded-lg py-1.5'
+ } text-lg md:text-base`;
+
+ const className = isActive
+ ? `${baseClassName} text-primary border-primary`
+ : `${baseClassName} text-muted-foreground hover:text-primary`;
+
+ const activeStyle = isActive
+ ? { backgroundColor: 'var(--nav-active-background)' }
+ : {};
+
+ const link =
+ typeof url === 'string' ? (
+
+ {children}
+ {!isCollapsed && message}
+
+ ) : (
+
+ {children}
+ {!isCollapsed && message}
+
+ );
+
+ if (isCollapsed || customTooltip) {
+ return (
+
+ {link}
+
+ {customTooltip || message}
+
+
+ );
+ }
+
+ return link;
+}
diff --git a/src/components/WalletSwitcher.tsx b/src/components/WalletSwitcher.tsx
new file mode 100644
index 00000000..ba3b3893
--- /dev/null
+++ b/src/components/WalletSwitcher.tsx
@@ -0,0 +1,253 @@
+import { commands } from '@/bindings';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { CustomError } from '@/contexts/ErrorContext';
+import { useWallet } from '@/contexts/WalletContext';
+import { useErrors } from '@/hooks/useErrors';
+import { clearState, loginAndUpdateState } from '@/state';
+import { t } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
+import { platform } from '@tauri-apps/plugin-os';
+import { LogOut, WalletIcon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+interface WalletSwitcherProps {
+ isCollapsed?: boolean;
+ logout: () => void;
+}
+
+export function WalletSwitcher({ isCollapsed, logout }: WalletSwitcherProps) {
+ const navigate = useNavigate();
+ const {
+ wallet: currentWallet,
+ setWallet,
+ setIsSwitching,
+ isSwitching,
+ } = useWallet();
+ const { addError } = useErrors();
+ const [wallets, setWallets] = useState<
+ { name: string; fingerprint: number; emoji: string | null }[]
+ >([]);
+ const [loading, setLoading] = useState(true);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isHovering, setIsHovering] = useState(false);
+ const timeoutRef = useRef(null);
+ const isMobile = platform() === 'ios' || platform() === 'android';
+
+ useEffect(() => {
+ const fetchWallets = async () => {
+ try {
+ const data = await commands.getKeys({});
+ setWallets(
+ data.keys
+ .map((key) => ({
+ name: key.name,
+ fingerprint: key.fingerprint,
+ emoji: key.emoji,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ );
+ } catch (error) {
+ addError(error as CustomError);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchWallets();
+ }, [addError]);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleSwitchWallet = async (fingerprint: number) => {
+ if (isSwitching) {
+ return;
+ }
+
+ try {
+ // Start switching: clear wallet, state, and set switching state
+ setIsSwitching(true);
+ setWallet(null);
+ clearState();
+
+ // Wait for fade-out transition to complete
+ await new Promise((resolve) => setTimeout(resolve, 300));
+
+ // Load new wallet data while still blurred
+ await loginAndUpdateState(fingerprint);
+ const data = await commands.getKey({});
+
+ // Set new wallet data while still blurred
+ setWallet(data.key);
+
+ // Wait a moment for the new data to be set, then fade in
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Now fade in the new wallet
+ setIsSwitching(false);
+ navigate('/wallet');
+ } catch (error) {
+ setIsSwitching(false);
+ addError(error as CustomError);
+ navigate('/');
+ }
+ };
+
+ const handleMouseEnter = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ setIsHovering(true);
+ setIsOpen(true);
+ };
+
+ const handleMouseLeave = () => {
+ setIsHovering(false);
+ timeoutRef.current = setTimeout(() => {
+ setIsOpen(false);
+ }, 150);
+ };
+
+ const className = isCollapsed ? 'h-5 w-5' : 'h-4 w-4';
+ const baseClassName = `flex items-center gap-3 transition-all ${
+ isCollapsed ? 'justify-center p-2 rounded-full' : 'px-2 rounded-lg py-1.5'
+ } text-lg md:text-base text-muted-foreground hover:text-primary`;
+
+ // If only one wallet, show simple logout button
+ if (!loading && wallets.length <= 1) {
+ return (
+
+
+ {!isCollapsed && Logout }
+
+ );
+ }
+
+ const trigger = (
+
+
+ {!isCollapsed && Wallets }
+
+ );
+
+ const dropdownContent = (
+
+
+ Switch Wallet
+
+
+ {loading ? (
+
+ Loading...
+
+ ) : wallets.length === 0 ? (
+
+ No wallets available
+
+ ) : (
+ wallets.map((wallet) => (
+ handleSwitchWallet(wallet.fingerprint)}
+ disabled={
+ isSwitching || currentWallet?.fingerprint === wallet.fingerprint
+ }
+ className='grid grid-cols-[auto_1fr_auto] items-center gap-3'
+ >
+
+ {wallet.emoji ? (
+
+ {wallet.emoji}
+
+ ) : (
+
+ )}
+
+ {wallet.name}
+ {currentWallet?.fingerprint === wallet.fingerprint && (
+
+ (current)
+
+ )}
+
+ ))
+ )}
+
+
+
+ Logout
+
+
+ );
+
+ // If multiple wallets, show dropdown menu
+ // On mobile, use click to open/close. On desktop, use hover.
+ const dropdown = (
+
+
+
+ {trigger}
+
+ {isCollapsed && (
+
+ Wallet switcher
+
+ )}
+
+ {dropdownContent}
+
+ );
+
+ return dropdown;
+}
diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx
index bda575ab..93621d64 100644
--- a/src/contexts/WalletContext.tsx
+++ b/src/contexts/WalletContext.tsx
@@ -1,12 +1,14 @@
import { KeyInfo, commands } from '@/bindings';
+import { CustomError } from '@/contexts/ErrorContext';
import { useErrors } from '@/hooks/useErrors';
+import { fetchState, initializeWalletState } from '@/state';
import { createContext, useContext, useEffect, useState } from 'react';
-import { initializeWalletState, fetchState } from '@/state';
-import { CustomError } from '@/contexts/ErrorContext';
interface WalletContextType {
wallet: KeyInfo | null;
setWallet: (wallet: KeyInfo | null) => void;
+ isSwitching: boolean;
+ setIsSwitching: (isSwitching: boolean) => void;
}
export const WalletContext = createContext(
@@ -15,6 +17,7 @@ export const WalletContext = createContext(
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [wallet, setWallet] = useState(null);
+ const [isSwitching, setIsSwitching] = useState(false);
const { addError } = useErrors();
useEffect(() => {
@@ -33,7 +36,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
}, [addError]);
return (
-
+
{children}
);