diff --git a/src/components/onboarding/chain-address-preview.test.tsx b/src/components/onboarding/chain-address-preview.test.tsx index 8b2c301f2..372208e0f 100644 --- a/src/components/onboarding/chain-address-preview.test.tsx +++ b/src/components/onboarding/chain-address-preview.test.tsx @@ -44,7 +44,7 @@ describe('ChainAddressPreview', () => { vi.useRealTimers() }) - it('renders enabled bioforest chains after derivation', async () => { + it('renders single address after derivation (all bioforest chains share same address)', async () => { vi.useFakeTimers() renderWithProviders() @@ -52,8 +52,11 @@ describe('ChainAddressPreview', () => { await vi.advanceTimersByTimeAsync(150) }) - expect(screen.getByText('BFMeta')).toBeInTheDocument() - expect(screen.getByText('PMChain')).toBeInTheDocument() + // Should show single address, not chain names list + expect(screen.queryByTestId('address-preview-loading')).not.toBeInTheDocument() + // Address container should be present + const container = document.querySelector('.px-4.py-3') + expect(container).toBeInTheDocument() vi.useRealTimers() }) diff --git a/src/components/onboarding/chain-address-preview.tsx b/src/components/onboarding/chain-address-preview.tsx index 6bef382da..69655b2cd 100644 --- a/src/components/onboarding/chain-address-preview.tsx +++ b/src/components/onboarding/chain-address-preview.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { AddressDisplay } from '@/components/wallet/address-display' -import { ChainIcon } from '@/components/wallet/chain-icon' import { deriveBioforestAddresses } from '@/lib/crypto' import type { ChainConfig } from '@/services/chain-config' import { cn } from '@/lib/utils' @@ -29,12 +28,6 @@ export function ChainAddressPreview({ const [isLoading, setIsLoading] = useState(false) const [addresses, setAddresses] = useState([]) - const metaById = useMemo(() => { - const map = new Map() - for (const config of enabledBioforestChainConfigs ?? []) map.set(config.id, config) - return map - }, [enabledBioforestChainConfigs]) - useEffect(() => { const trimmed = secret.trim() if (!trimmed) { @@ -65,39 +58,16 @@ export function ChainAddressPreview({
{isLoading ? ( -
- {Array.from({ length: 3 }).map((_, idx) => ( -
-
-
-
-
-
-
- ))} +
+
+
+ ) : addresses.length > 0 ? ( +
+
) : ( -
- {addresses.map((item) => { - const meta = metaById.get(item.chainId) - return ( -
- -
-
{meta?.name ?? item.chainId}
-
- -
-
-
- ) - })} - - {addresses.length === 0 && ( -
- — -
- )} +
+ —
)}
diff --git a/src/components/onboarding/security-warning-dialog.tsx b/src/components/onboarding/security-warning-dialog.tsx index 826b36f30..b21612fc3 100644 --- a/src/components/onboarding/security-warning-dialog.tsx +++ b/src/components/onboarding/security-warning-dialog.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; interface SecurityWarningDialogProps { @@ -62,12 +63,10 @@ export function SecurityWarningDialog({
- diff --git a/src/components/wallet/index.ts b/src/components/wallet/index.ts index bf9491ed4..cbb5226bb 100644 --- a/src/components/wallet/index.ts +++ b/src/components/wallet/index.ts @@ -13,6 +13,10 @@ export interface WalletInfo { fiatValue?: string | undefined chainName?: string | undefined isBackedUp?: boolean | undefined + /** 主题色 hue (0-360) */ + themeHue?: number | undefined + /** 链图标 URL,用于彩虹水印 */ + chainIconUrl?: string | undefined } export { AddressDisplay } from './address-display' export { ChainIcon, ChainBadge, ChainIconProvider, type ChainType } from './chain-icon' diff --git a/src/components/wallet/refraction/index.ts b/src/components/wallet/refraction/index.ts index d1ed3a8fa..ef55b72b4 100644 --- a/src/components/wallet/refraction/index.ts +++ b/src/components/wallet/refraction/index.ts @@ -1,3 +1,5 @@ export { HologramCanvas } from './hologram-canvas' export type { HologramCanvasProps } from './hologram-canvas' export type { Priority } from './types' +export { resolveBackgroundStops } from './renderer' +export type { BackgroundStops } from './renderer' diff --git a/src/components/wallet/wallet-card-carousel.tsx b/src/components/wallet/wallet-card-carousel.tsx index 994169c3b..3da8b3e44 100644 --- a/src/components/wallet/wallet-card-carousel.tsx +++ b/src/components/wallet/wallet-card-carousel.tsx @@ -151,8 +151,6 @@ export function WalletCardCarousel({ priority={getPriority(index)} address={walletAddress} chainIconUrl={chainIconUrls[walletChain]} - watermarkLogoSize={40} - watermarkLogoActualSize={24} themeHue={getWalletTheme(wallet.id)} onCopyAddress={() => { if (walletAddress) onCopyAddress?.(walletAddress); diff --git a/src/components/wallet/wallet-card.stories.tsx b/src/components/wallet/wallet-card.stories.tsx index be4e8de5a..12f92beef 100644 --- a/src/components/wallet/wallet-card.stories.tsx +++ b/src/components/wallet/wallet-card.stories.tsx @@ -3,6 +3,15 @@ import { WalletCard } from './wallet-card' import type { Wallet } from '@/stores' import { fn } from '@storybook/test' +// 不同链的图标路径 +const CHAIN_ICONS = { + ethereum: '/icons/ethereum/chain.svg', + bitcoin: '/icons/bitcoin/chain.svg', + tron: '/icons/tron/chain.svg', + binance: '/icons/binance/chain.svg', + bfmeta: '/icons/bfmeta/chain.svg', +} + const createMockWallet = (overrides: Partial = {}): Wallet => ({ id: 'wallet-1', name: '我的钱包', @@ -64,6 +73,7 @@ export const Default: Story = { chainName: 'Ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', themeHue: 323, + chainIconUrl: CHAIN_ICONS.ethereum, onCopyAddress: fn(), onOpenChainSelector: fn(), onOpenSettings: fn(), @@ -112,6 +122,7 @@ export const TronChain: Story = { chainName: 'Tron', address: 'TAbcd1234567890abcdef1234567890abcde', themeHue: 0, + chainIconUrl: CHAIN_ICONS.tron, onCopyAddress: fn(), onOpenChainSelector: fn(), onOpenSettings: fn(), @@ -125,6 +136,7 @@ export const BitcoinChain: Story = { chainName: 'Bitcoin', address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', themeHue: 30, + chainIconUrl: CHAIN_ICONS.bitcoin, onCopyAddress: fn(), onOpenChainSelector: fn(), onOpenSettings: fn(), @@ -168,6 +180,7 @@ export const AllThemes: Story = { chainName="ETH" address="0x1234...5678" themeHue={hue} + chainIconUrl={CHAIN_ICONS.ethereum} /> ))}
diff --git a/src/components/wallet/wallet-card.tsx b/src/components/wallet/wallet-card.tsx index ec3d9776d..e7ef47891 100644 --- a/src/components/wallet/wallet-card.tsx +++ b/src/components/wallet/wallet-card.tsx @@ -79,7 +79,7 @@ export const WalletCard = forwardRef(function W priority = 'high', address, chainIconUrl, - watermarkLogoSize = 40, + watermarkLogoSize = 60, watermarkLogoActualSize = 24, onCopyAddress, onOpenChainSelector, @@ -113,6 +113,7 @@ export const WalletCard = forwardRef(function W size: Math.round(watermarkLogoActualSize * dpr), invert: false, // 白色区域不透明 contrast: 1.8, + targetBrightness: 0.85, }); const { pointerX, pointerY, isActive, bindElement } = useCardInteraction({ diff --git a/src/components/wallet/wallet-config.tsx b/src/components/wallet/wallet-config.tsx index ae323d86a..fff9f812d 100644 --- a/src/components/wallet/wallet-config.tsx +++ b/src/components/wallet/wallet-config.tsx @@ -1,42 +1,42 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme' -import { useChainIconUrls } from '@/hooks/useChainIconUrls' -import { WalletCard } from '@/components/wallet/wallet-card' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { useWallets, useSelectedChain, walletActions, type ChainType } from '@/stores' -import { useFlow } from '@/stackflow' +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme'; +import { useChainIconUrls } from '@/hooks/useChainIconUrls'; +import { WalletCard } from '@/components/wallet/wallet-card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useWallets, useSelectedChain, walletActions, type ChainType } from '@/stores'; +import { useFlow } from '@/stackflow'; import { IconCheck, IconCircleKey as KeyRound, IconTrash as Trash2, IconPencilMinus as Edit3, -} from '@tabler/icons-react' +} from '@tabler/icons-react'; /** * 计算两个色相之间的距离(0-180) */ function hueDistance(h1: number, h2: number): number { - const diff = Math.abs(h1 - h2) - return Math.min(diff, 360 - diff) + const diff = Math.abs(h1 - h2); + return Math.min(diff, 360 - diff); } /** * 计算趋避权重 */ function calculateAvoidanceWeight(hue: number, existingHues: number[], minDistance = 30): number { - if (existingHues.length === 0) return 1 - let minDist = 180 + if (existingHues.length === 0) return 1; + let minDist = 180; for (const existingHue of existingHues) { - const dist = hueDistance(hue, existingHue) - if (dist < minDist) minDist = dist + const dist = hueDistance(hue, existingHue); + if (dist < minDist) minDist = dist; } if (minDist < minDistance) { - return minDist / minDistance + return minDist / minDistance; } - return 1 + return 1; } /** @@ -46,16 +46,16 @@ function getWeightedPresetColors(existingHues: number[]) { return WALLET_THEME_COLORS.map((color) => ({ ...color, weight: calculateAvoidanceWeight(color.hue, existingHues), - })) + })); } -type WalletConfigMode = 'edit-only' | 'default' | 'edit' +type WalletConfigMode = 'edit-only' | 'default' | 'edit'; interface WalletConfigProps { - mode: WalletConfigMode - walletId: string - onEditOnlyComplete?: () => void - className?: string + mode: WalletConfigMode; + walletId: string; + onEditOnlyComplete?: () => void; + className?: string; } /** @@ -65,172 +65,166 @@ interface WalletConfigProps { * - edit-only: 创建/导入最后一步,编辑完成后触发 onEditOnlyComplete */ export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }: WalletConfigProps) { - const { t } = useTranslation(['wallet', 'onboarding', 'common']) - const { push } = useFlow() - const wallets = useWallets() - const wallet = wallets.find((w) => w.id === walletId) - const selectedChain = useSelectedChain() - const chainIconUrls = useChainIconUrls() + const { t } = useTranslation(['wallet', 'onboarding', 'common']); + const { push } = useFlow(); + const wallets = useWallets(); + const wallet = wallets.find((w) => w.id === walletId); + const selectedChain = useSelectedChain(); + const chainIconUrls = useChainIconUrls(); // 内部模式状态(default 和 edit 可以互相切换) - const [internalMode, setInternalMode] = useState<'default' | 'edit'>( - mode === 'edit-only' ? 'edit' : mode - ) + const [internalMode, setInternalMode] = useState<'default' | 'edit'>(mode === 'edit-only' ? 'edit' : mode); // 编辑状态 - const [editName, setEditName] = useState('') - const [editThemeHue, setEditThemeHue] = useState(0) + const [editName, setEditName] = useState(''); + const [editThemeHue, setEditThemeHue] = useState(0); // 色条拖拽 - const sliderRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) + const sliderRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); // 排除当前钱包的色相 const existingHues = useMemo( () => wallets.filter((w) => w.id !== walletId).map((w) => w.themeHue), - [wallets, walletId] - ) - const weightedColors = useMemo(() => getWeightedPresetColors(existingHues), [existingHues]) + [wallets, walletId], + ); + const weightedColors = useMemo(() => getWeightedPresetColors(existingHues), [existingHues]); // 初始化编辑值 useEffect(() => { if (wallet) { - setEditName(wallet.name) - setEditThemeHue(wallet.themeHue) + setEditName(wallet.name); + setEditThemeHue(wallet.themeHue); } - }, [wallet]) + }, [wallet]); // 当前显示的链(使用全局选中的链) const currentChainAddr = useMemo( () => wallet?.chainAddresses.find((ca) => ca.chain === selectedChain) ?? wallet?.chainAddresses[0], - [wallet, selectedChain] - ) + [wallet, selectedChain], + ); // 色条拖拽处理 const updateHueFromPosition = useCallback((clientX: number) => { - if (!sliderRef.current) return - const rect = sliderRef.current.getBoundingClientRect() - const x = Math.max(0, Math.min(clientX - rect.left, rect.width)) - const ratio = x / rect.width - const newHue = Math.round(ratio * 3600) / 10 - setEditThemeHue(newHue) - }, []) + if (!sliderRef.current) return; + const rect = sliderRef.current.getBoundingClientRect(); + const x = Math.max(0, Math.min(clientX - rect.left, rect.width)); + const ratio = x / rect.width; + const newHue = Math.round(ratio * 3600) / 10; + setEditThemeHue(newHue); + }, []); const handleSliderMouseDown = (e: React.MouseEvent) => { - e.stopPropagation() - setIsDragging(true) - updateHueFromPosition(e.clientX) - } + e.stopPropagation(); + setIsDragging(true); + updateHueFromPosition(e.clientX); + }; const handleSliderTouchStart = (e: React.TouchEvent) => { - e.stopPropagation() - setIsDragging(true) + e.stopPropagation(); + setIsDragging(true); if (e.touches[0]) { - updateHueFromPosition(e.touches[0].clientX) + updateHueFromPosition(e.touches[0].clientX); } - } + }; useEffect(() => { - if (!isDragging) return + if (!isDragging) return; - const handleMouseMove = (e: MouseEvent) => updateHueFromPosition(e.clientX) + const handleMouseMove = (e: MouseEvent) => updateHueFromPosition(e.clientX); const handleTouchMove = (e: TouchEvent) => { - if (e.touches[0]) updateHueFromPosition(e.touches[0].clientX) - } - const handleEnd = () => setIsDragging(false) + if (e.touches[0]) updateHueFromPosition(e.touches[0].clientX); + }; + const handleEnd = () => setIsDragging(false); - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleEnd) - document.addEventListener('touchmove', handleTouchMove) - document.addEventListener('touchend', handleEnd) + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleEnd); return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleEnd) - document.removeEventListener('touchmove', handleTouchMove) - document.removeEventListener('touchend', handleEnd) - } - }, [isDragging, updateHueFromPosition]) + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleEnd); + }; + }, [isDragging, updateHueFromPosition]); // 打开链选择器 const handleOpenChainSelector = useCallback(() => { - push('ChainSelectorJob', {}) - }, [push]) + push('ChainSelectorJob', {}); + }, [push]); // 切换到编辑模式 const handleStartEdit = useCallback(() => { if (wallet) { - setEditName(wallet.name) - setEditThemeHue(wallet.themeHue) + setEditName(wallet.name); + setEditThemeHue(wallet.themeHue); } - setInternalMode('edit') - }, [wallet]) + setInternalMode('edit'); + }, [wallet]); // 保存编辑(edit 模式) const handleSave = useCallback(async () => { - if (!wallet) return - const trimmedName = editName.trim() + if (!wallet) return; + const trimmedName = editName.trim(); if (trimmedName && trimmedName !== wallet.name) { - await walletActions.updateWalletName(wallet.id, trimmedName) + await walletActions.updateWalletName(wallet.id, trimmedName); } if (editThemeHue !== wallet.themeHue) { - await walletActions.updateWalletThemeHue(wallet.id, editThemeHue) + await walletActions.updateWalletThemeHue(wallet.id, editThemeHue); } - setInternalMode('default') - }, [wallet, editName, editThemeHue]) + setInternalMode('default'); + }, [wallet, editName, editThemeHue]); // 取消编辑(edit 模式) const handleCancel = useCallback(() => { if (wallet) { - setEditName(wallet.name) - setEditThemeHue(wallet.themeHue) + setEditName(wallet.name); + setEditThemeHue(wallet.themeHue); } - setInternalMode('default') - }, [wallet]) + setInternalMode('default'); + }, [wallet]); // 确认(edit-only 模式) const handleConfirm = useCallback(async () => { - if (!wallet) return - const trimmedName = editName.trim() + if (!wallet) return; + const trimmedName = editName.trim(); if (trimmedName && trimmedName !== wallet.name) { - await walletActions.updateWalletName(wallet.id, trimmedName) + await walletActions.updateWalletName(wallet.id, trimmedName); } if (editThemeHue !== wallet.themeHue) { - await walletActions.updateWalletThemeHue(wallet.id, editThemeHue) + await walletActions.updateWalletThemeHue(wallet.id, editThemeHue); } - onEditOnlyComplete?.() - }, [wallet, editName, editThemeHue, onEditOnlyComplete]) + onEditOnlyComplete?.(); + }, [wallet, editName, editThemeHue, onEditOnlyComplete]); // 查看助记词 const handleExportMnemonic = useCallback(async () => { - if (!wallet) return - await walletActions.setCurrentWallet(wallet.id) - push('SettingsMnemonicActivity', {}) - }, [wallet, push]) + if (!wallet) return; + await walletActions.setCurrentWallet(wallet.id); + push('SettingsMnemonicActivity', {}); + }, [wallet, push]); // 删除钱包 const handleDelete = useCallback(() => { - if (!wallet) return - push('WalletDeleteJob', { walletId: wallet.id }) - }, [wallet, push]) + if (!wallet) return; + push('WalletDeleteJob', { walletId: wallet.id }); + }, [wallet, push]); if (!wallet) { - return ( -
- {t('wallet:detail.notFound')} -
- ) + return
{t('wallet:detail.notFound')}
; } // 当前显示的主题色 - const displayThemeHue = (internalMode === 'edit' || mode === 'edit-only') ? editThemeHue : wallet.themeHue - const isEditMode = internalMode === 'edit' || mode === 'edit-only' + const displayThemeHue = internalMode === 'edit' || mode === 'edit-only' ? editThemeHue : wallet.themeHue; + const isEditMode = internalMode === 'edit' || mode === 'edit-only'; return (
{/* 卡片预览 */}
-
+
@@ -249,7 +241,7 @@ export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }: <> {/* 名称输入 */}
- + setEditName(e.target.value)} @@ -264,13 +256,14 @@ export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }:
{t('onboarding:theme.customColor')} - {editThemeHue.toFixed(1)}° + {editThemeHue.toFixed(1)}°
{weightedColors.map((color) => { - const isSelected = Math.abs(editThemeHue - color.hue) < 0.5 - const isLowWeight = color.weight < 0.5 + const isSelected = Math.abs(editThemeHue - color.hue) < 0.5; + const isLowWeight = color.weight < 0.5; return ( - ) + ); })}
@@ -361,27 +352,27 @@ export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }: ) : ( /* default 模式:功能按钮 */ -
- - - - - +
+ + +
)}
- ) + ); } diff --git a/src/components/wallet/wallet-list.stories.tsx b/src/components/wallet/wallet-list.stories.tsx new file mode 100644 index 000000000..6b66c8365 --- /dev/null +++ b/src/components/wallet/wallet-list.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { fn } from '@storybook/test' + +import { WalletList, type WalletListItem } from './wallet-list' +import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme' + +// 不同链的图标路径 +const CHAIN_ICONS = { + bfmeta: '/icons/bfmeta/chain.svg', + ethereum: '/icons/ethereum/chain.svg', + bitcoin: '/icons/bitcoin/chain.svg', + tron: '/icons/tron/chain.svg', + binance: '/icons/binance/chain.svg', +} + +const mockWallets: WalletListItem[] = [ + { + id: 'wallet-1', + name: '主钱包 (BFMeta)', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f00000', + themeHue: 323, + chainIconUrl: CHAIN_ICONS.bfmeta, + }, + { + id: 'wallet-2', + name: '以太坊账户', + address: '0xA1B2C3D4E5F6789012345678901234567890ABCD', + themeHue: 250, + chainIconUrl: CHAIN_ICONS.ethereum, + }, + { + id: 'wallet-3', + name: '比特币储蓄', + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + themeHue: 40, + chainIconUrl: CHAIN_ICONS.bitcoin, + }, +] + +const meta: Meta = { + title: 'Wallet/WalletList', + component: WalletList, + tags: ['autodocs'], + args: { + onSelect: fn(), + onAddWallet: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + wallets: mockWallets, + currentWalletId: 'wallet-1', + }, +} + +export const NoCurrentWallet: Story = { + args: { + wallets: mockWallets, + currentWalletId: null, + }, +} + +export const SingleWallet: Story = { + args: { + wallets: [mockWallets[0]!], + currentWalletId: 'wallet-1', + }, +} + +export const EmptyList: Story = { + args: { + wallets: [], + currentWalletId: null, + }, +} + +export const EmptyListWithoutAddButton: Story = { + args: { + wallets: [], + currentWalletId: null, + showAddButton: false, + }, +} + +export const WithoutAddButton: Story = { + args: { + wallets: mockWallets, + currentWalletId: 'wallet-2', + showAddButton: false, + }, +} + +export const AllThemeColors: Story = { + args: { + wallets: WALLET_THEME_COLORS.map((color, idx) => ({ + id: `wallet-${idx}`, + name: color.name, + address: `0x${idx.toString().padStart(40, '0')}`, + themeHue: color.hue, + })), + currentWalletId: 'wallet-0', + }, +} + +export const LongWalletNames: Story = { + args: { + wallets: [ + { + id: 'wallet-1', + name: '这是一个非常长的钱包名称用于测试截断显示', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f00000', + themeHue: 323, + }, + { + id: 'wallet-2', + name: 'Very Long Wallet Name For Testing Truncation', + address: '0xA1B2C3D4E5F6789012345678901234567890ABCD', + themeHue: 250, + }, + ], + currentWalletId: 'wallet-1', + }, +} + +export const ManyWallets: Story = { + args: { + wallets: Array.from({ length: 10 }, (_, idx) => ({ + id: `wallet-${idx}`, + name: `钱包 ${idx + 1}`, + address: `0x${idx.toString().padStart(40, '0')}`, + themeHue: (idx * 36) % 360, + })), + currentWalletId: 'wallet-3', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/src/components/wallet/wallet-list.test.tsx b/src/components/wallet/wallet-list.test.tsx new file mode 100644 index 000000000..621a47216 --- /dev/null +++ b/src/components/wallet/wallet-list.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TestI18nProvider } from '@/test/i18n-mock' +import { WalletList, type WalletListItem } from './wallet-list' + +const renderWithI18n = (ui: React.ReactElement) => render({ui}) + +const mockWallets: WalletListItem[] = [ + { id: 'wallet-1', name: '主钱包', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f00000', themeHue: 323 }, + { id: 'wallet-2', name: '交易账户', address: '0xA1B2C3D4E5F6789012345678901234567890ABCD', themeHue: 250 }, + { id: 'wallet-3', name: '储蓄', address: '0xDEADBEEF1234567890ABCDEF1234567890ABCDEF', themeHue: 145 }, +] + +describe('WalletList', () => { + it('renders all wallets', () => { + renderWithI18n() + + expect(screen.getByText('主钱包')).toBeInTheDocument() + expect(screen.getByText('交易账户')).toBeInTheDocument() + expect(screen.getByText('储蓄')).toBeInTheDocument() + }) + + it('shows truncated addresses', () => { + renderWithI18n() + + expect(screen.getByText('0x742d...0000')).toBeInTheDocument() + expect(screen.getByText('0xA1B2...ABCD')).toBeInTheDocument() + }) + + it('shows empty address as ---', () => { + const walletsWithEmptyAddress: WalletListItem[] = [ + { id: 'wallet-1', name: '空地址钱包', address: '', themeHue: 323 }, + ] + renderWithI18n() + + expect(screen.getByText('---')).toBeInTheDocument() + }) + + it('highlights current wallet with check icon', () => { + renderWithI18n() + + const wallet1Button = screen.getByRole('option', { name: /主钱包/ }) + expect(wallet1Button).toHaveAttribute('aria-selected', 'true') + expect(wallet1Button).toHaveClass('ring-2', 'ring-primary') + }) + + it('calls onSelect when wallet is clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + renderWithI18n() + + await user.click(screen.getByText('交易账户')) + expect(onSelect).toHaveBeenCalledWith('wallet-2') + }) + + it('renders add wallet button by default', () => { + renderWithI18n() + + expect(screen.getByRole('button', { name: /添加钱包|add/i })).toBeInTheDocument() + }) + + it('calls onAddWallet when add button is clicked', async () => { + const user = userEvent.setup() + const onAddWallet = vi.fn() + + renderWithI18n() + + await user.click(screen.getByRole('button', { name: /添加钱包|add/i })) + expect(onAddWallet).toHaveBeenCalled() + }) + + it('hides add button when showAddButton is false', () => { + renderWithI18n() + + expect(screen.queryByRole('button', { name: /添加钱包|add/i })).not.toBeInTheDocument() + }) + + it('shows empty state when no wallets and showAddButton is false', () => { + renderWithI18n() + + expect(screen.getByText(/暂无钱包|no wallet/i)).toBeInTheDocument() + }) + + it('shows only add button when no wallets but showAddButton is true', () => { + renderWithI18n() + + expect(screen.getByRole('button', { name: /添加钱包|add/i })).toBeInTheDocument() + expect(screen.queryByText(/暂无钱包|no wallet/i)).not.toBeInTheDocument() + }) + + it('applies custom className', () => { + renderWithI18n() + + const listbox = screen.getByRole('listbox') + expect(listbox).toHaveClass('custom-class') + }) + + it('renders WalletMiniCard for each wallet', () => { + renderWithI18n() + + const cards = screen.getAllByRole('img', { name: 'wallet card' }) + expect(cards).toHaveLength(3) + }) + + it('does not highlight any wallet when currentWalletId is null', () => { + renderWithI18n() + + const options = screen.getAllByRole('option') + options.forEach((option) => { + expect(option).toHaveAttribute('aria-selected', 'false') + }) + }) +}) diff --git a/src/components/wallet/wallet-list.tsx b/src/components/wallet/wallet-list.tsx new file mode 100644 index 000000000..0330960c2 --- /dev/null +++ b/src/components/wallet/wallet-list.tsx @@ -0,0 +1,101 @@ +import { IconCircleCheckFilled, IconPlus } from '@tabler/icons-react' +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' +import { WalletMiniCard } from './wallet-mini-card' + +export interface WalletListItem { + id: string + name: string + address: string + themeHue: number + /** 链图标 URL,用于彩虹水印 */ + chainIconUrl?: string +} + +export interface WalletListProps { + wallets: WalletListItem[] + currentWalletId?: string | null + onSelect?: (walletId: string) => void + onAddWallet?: () => void + showAddButton?: boolean + className?: string +} + +function truncateAddress(address: string): string { + if (!address) return '---' + if (address.length <= 12) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +export function WalletList({ + wallets, + currentWalletId, + onSelect, + onAddWallet, + showAddButton = true, + className, +}: WalletListProps) { + const { t } = useTranslation(['wallet', 'common']) + + if (wallets.length === 0 && !showAddButton) { + return ( +
+

{t('wallet:empty')}

+

{t('wallet:emptyHint')}

+
+ ) + } + + return ( +
+ {wallets.map((wallet) => { + const isActive = wallet.id === currentWalletId + const displayAddress = truncateAddress(wallet.address) + + return ( + + ) + })} + + {showAddButton && ( + + )} +
+ ) +} diff --git a/src/components/wallet/wallet-mini-card.stories.tsx b/src/components/wallet/wallet-mini-card.stories.tsx new file mode 100644 index 000000000..987006366 --- /dev/null +++ b/src/components/wallet/wallet-mini-card.stories.tsx @@ -0,0 +1,181 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { WalletMiniCard } from './wallet-mini-card' +import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme' + +// 内联 SVG 图标(B 字母,用于 demo)- base64 编码确保兼容性 +const DEMO_ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iYmxhY2siPjxwYXRoIGQ9Ik02IDRoOGE0IDQgMCAwIDEgMCA4aC0yYTQgNCAwIDAgMSAwIDhINlY0em0yIDJ2NGg2YTIgMiAwIDEgMCAwLTRIOHptMCA2djRoNmEyIDIgMCAxIDAgMC00SDh6Ii8+PC9zdmc+' + +// 也提供文件系统路径版本 +const CHAIN_ICON_URL = '/icons/bfmeta/chain.svg' + +const meta: Meta = { + title: 'Wallet/WalletMiniCard', + component: WalletMiniCard, + tags: ['autodocs'], + argTypes: { + themeHue: { + control: { type: 'range', min: 0, max: 360, step: 1 }, + }, + size: { + control: { type: 'select' }, + options: ['xs', 'sm', 'md'], + }, + showPattern: { + control: { type: 'boolean' }, + }, + watermarkIconUrl: { + control: { type: 'text' }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + themeHue: 323, + size: 'sm', + showPattern: true, + }, +} + +export const AllSizes: Story = { + render: () => ( +
+
+ + xs (20×12) +
+
+ + sm (32×20) +
+
+ + md (44×28) +
+
+ ), +} + +export const AllThemeColors: Story = { + render: () => ( +
+ {WALLET_THEME_COLORS.map(({ name, hue }) => ( +
+ + {name} +
+ ))} +
+ ), +} + +export const WithWatermark: Story = { + args: { + themeHue: 323, + size: 'md', + showPattern: true, + watermarkIconUrl: DEMO_ICON_URL, + }, +} + +export const WithChainIcon: Story = { + args: { + themeHue: 250, + size: 'md', + showPattern: true, + watermarkIconUrl: CHAIN_ICON_URL, + }, +} + +export const WithoutPattern: Story = { + args: { + themeHue: 250, + size: 'md', + showPattern: false, + }, +} + +export const RainbowWatermarkShowcase: Story = { + render: () => ( +
+
+

彩虹水印效果 (内联 SVG)

+
+
+
+ +
+ md +
+
+
+ +
+ sm +
+
+
+ +
+ xs +
+
+
+
+

对比:无水印 vs 有水印

+
+
+ + 无水印 +
+
+ + 有水印 +
+
+
+
+ ), +} + +export const AllSizesWithWatermark: Story = { + render: () => ( +
+
+ + xs (20x12) +
+
+ + sm (32x20) +
+
+ + md (44x28) +
+
+ ), +} + +export const InListContext: Story = { + render: () => ( +
+ {[ + { name: '主钱包', hue: 323 }, + { name: '交易账户', hue: 250 }, + { name: '储蓄', hue: 145 }, + ].map(({ name, hue }) => ( +
+ + {name} +
+ ))} +
+ ), +} diff --git a/src/components/wallet/wallet-mini-card.test.tsx b/src/components/wallet/wallet-mini-card.test.tsx new file mode 100644 index 000000000..062c90a25 --- /dev/null +++ b/src/components/wallet/wallet-mini-card.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { WalletMiniCard } from './wallet-mini-card' + +// Mock useMonochromeMask hook +vi.mock('@/hooks/useMonochromeMask', () => ({ + useMonochromeMask: (iconUrl: string | undefined) => + iconUrl ? 'data:image/png;base64,mockMask' : null, +})) + +describe('WalletMiniCard', () => { + it('renders with default size', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card).toBeInTheDocument() + expect(card).toHaveStyle({ width: '32px', height: '20px' }) + }) + + it('renders xs size correctly', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card).toHaveStyle({ width: '20px', height: '12px' }) + }) + + it('renders sm size correctly', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card).toHaveStyle({ width: '32px', height: '20px' }) + }) + + it('renders md size correctly', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card).toHaveStyle({ width: '44px', height: '28px' }) + }) + + it('applies gradient background based on themeHue', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card.style.background).toContain('linear-gradient') + }) + + it('applies custom className', () => { + render() + const card = screen.getByRole('img', { name: 'wallet card' }) + expect(card).toHaveClass('custom-class') + }) + + it('renders different colors for different hues', () => { + const { rerender } = render() + const card1 = screen.getByRole('img', { name: 'wallet card' }) + const bg1 = card1.style.background + + rerender() + const card2 = screen.getByRole('img', { name: 'wallet card' }) + const bg2 = card2.style.background + + expect(bg1).not.toBe(bg2) + }) + + it('renders pattern layer by default', () => { + render() + expect(screen.getByTestId('pattern-layer')).toBeInTheDocument() + }) + + it('hides pattern layer when showPattern is false', () => { + render() + expect(screen.queryByTestId('pattern-layer')).not.toBeInTheDocument() + }) + + it('renders watermark layer when watermarkIconUrl is provided', () => { + render() + expect(screen.getByTestId('watermark-layer')).toBeInTheDocument() + }) + + it('does not render watermark layer when watermarkIconUrl is not provided', () => { + render() + expect(screen.queryByTestId('watermark-layer')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/wallet-mini-card.tsx b/src/components/wallet/wallet-mini-card.tsx new file mode 100644 index 000000000..b795f1c38 --- /dev/null +++ b/src/components/wallet/wallet-mini-card.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import { useMonochromeMask } from '@/hooks/useMonochromeMask' +import { createRainbowHook } from '@/lib/canvas' +import { resolveBackgroundStops } from './refraction' + +export type WalletMiniCardSize = 'xs' | 'sm' | 'md' + +export interface WalletMiniCardProps { + themeHue: number + size?: WalletMiniCardSize + /** 是否显示三角形纹理 */ + showPattern?: boolean + /** 水印图标 URL(居中显示,彩虹渐变) */ + watermarkIconUrl?: string | undefined + className?: string +} + +// 固定纹理网格:5 列 x 3 行 +const PATTERN_COLS = 5 +const PATTERN_ROWS = 3 + +const sizeStyles: Record = { + xs: { width: 20, height: 12, radius: 'rounded-[2px]', iconSize: 10 }, + sm: { width: 32, height: 20, radius: 'rounded-[3px]', iconSize: 16 }, + md: { width: 44, height: 28, radius: 'rounded-[4px]', iconSize: 22 }, +} + +/** + * 生成固定 5x3 三角形纹理的 SVG data URL + * 使用右下角三角形,与 WalletCard 的 HologramCanvas 一致 + */ +function createTrianglePatternUrl(width: number, height: number): string { + const cellW = width / PATTERN_COLS + const cellH = height / PATTERN_ROWS + + let triangles = '' + for (let row = 0; row < PATTERN_ROWS; row++) { + for (let col = 0; col < PATTERN_COLS; col++) { + const x = col * cellW + const y = row * cellH + // 右下角三角形 + triangles += `` + } + } + + const svg = `${triangles}` + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")` +} + +// 预定义彩虹渐变 pipeline +const RAINBOW_PIPELINE = [createRainbowHook(0.9)] + +export function WalletMiniCard({ + themeHue, + size = 'sm', + showPattern = true, + watermarkIconUrl, + className, +}: WalletMiniCardProps) { + const { c0, c1, c2 } = useMemo(() => resolveBackgroundStops(themeHue), [themeHue]) + const { width, height, radius, iconSize } = sizeStyles[size] + + const patternUrl = useMemo( + () => (showPattern ? createTrianglePatternUrl(width, height) : null), + [showPattern, width, height] + ) + + // 使用 pipeline 直接在 canvas 中渲染彩虹效果 + // clip: 裁剪透明区域,让图标填满空间 + // targetBrightness: 亮度锚定,确保不同图标显示效果一致 + const dpr = typeof window !== 'undefined' ? Math.min(3, window.devicePixelRatio || 1) : 2 + const rainbowIconUrl = useMonochromeMask(watermarkIconUrl, { + size: Math.round(iconSize * dpr), + invert: false, + contrast: 1.8, + targetBrightness: 0.85, + pipeline: RAINBOW_PIPELINE, + }) + + return ( +
+ {/* 三角形纹理层 - 固定 5x3 网格 */} + {patternUrl && ( +
diff --git a/src/services/wallet-storage/service.ts b/src/services/wallet-storage/service.ts index a343c5e6b..d35585789 100644 --- a/src/services/wallet-storage/service.ts +++ b/src/services/wallet-storage/service.ts @@ -1,5 +1,5 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb' -import { encrypt, decrypt, encryptWithRawKey, decryptWithRawKey, deriveEncryptionKeyFromMnemonic } from '@/lib/crypto' +import { encrypt, decrypt, encryptWithRawKey, decryptWithRawKey, deriveEncryptionKeyFromMnemonic, deriveEncryptionKeyFromSecret } from '@/lib/crypto' import { type WalleterInfo, type WalletInfo, @@ -158,13 +158,39 @@ export class WalletStorageService { ): Promise { this.ensureInitialized() + // 验证参数 + if (!mnemonic || typeof mnemonic !== 'string') { + throw new WalletStorageError( + WalletStorageErrorCode.ENCRYPTION_FAILED, + 'Invalid mnemonic: mnemonic is required' + ) + } + if (!walletLock || typeof walletLock !== 'string') { + throw new WalletStorageError( + WalletStorageErrorCode.ENCRYPTION_FAILED, + 'Invalid walletLock: wallet lock password is required' + ) + } + + // 检查 crypto.subtle 可用性(仅在安全上下文中可用) + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new WalletStorageError( + WalletStorageErrorCode.ENCRYPTION_FAILED, + 'Web Crypto API is not available. Please use HTTPS or localhost.' + ) + } + try { // 双向加密: - // 1. 使用钱包锁加密助记词 + // 1. 使用钱包锁加密助记词/密钥 const encryptedMnemonic = await encrypt(mnemonic, walletLock) - // 2. 使用助记词派生密钥加密钱包锁 - const mnemonicKey = deriveEncryptionKeyFromMnemonic(mnemonic) - const encryptedWalletLock = await encryptWithRawKey(walletLock, mnemonicKey) + // 2. 根据密钥类型选择派生方法加密钱包锁 + // - mnemonic: 使用 BIP32 派生(兼容标准助记词) + // - arbitrary/privateKey: 使用 SHA256 派生(支持任意字符串) + const secretKey = wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic) + const encryptedWalletLock = await encryptWithRawKey(walletLock, secretKey) const walletWithEncryption: WalletInfo = { ...wallet, @@ -175,9 +201,10 @@ export class WalletStorageService { await this.db!.put('wallets', walletWithEncryption) return walletWithEncryption } catch (err) { + const message = err instanceof Error ? err.message : String(err) throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, - 'Failed to encrypt wallet data', + `Failed to encrypt wallet data: ${message}`, err instanceof Error ? err : undefined ) } @@ -277,6 +304,14 @@ export class WalletStorageService { ): Promise { this.ensureInitialized() + const wallet = await this.getWallet(walletId) + if (!wallet) { + throw new WalletStorageError( + WalletStorageErrorCode.WALLET_NOT_FOUND, + `Wallet not found: ${walletId}` + ) + } + // 用旧钱包锁解密助记词 const mnemonic = await this.getMnemonic(walletId, oldWalletLock) @@ -284,9 +319,11 @@ export class WalletStorageService { try { // 1. 使用新钱包锁加密助记词 const encryptedMnemonic = await encrypt(mnemonic, newWalletLock) - // 2. 使用助记词派生密钥加密新钱包锁 - const mnemonicKey = deriveEncryptionKeyFromMnemonic(mnemonic) - const encryptedWalletLock = await encryptWithRawKey(newWalletLock, mnemonicKey) + // 2. 根据密钥类型选择派生方法加密新钱包锁 + const secretKey = wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic) + const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey) await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }) } catch (err) { @@ -320,10 +357,12 @@ export class WalletStorageService { ) } - // 验证助记词:尝试用助记词派生密钥解密钱包锁 + // 验证助记词/密钥:尝试用派生密钥解密钱包锁 try { - const mnemonicKey = deriveEncryptionKeyFromMnemonic(mnemonic) - await decryptWithRawKey(wallet.encryptedWalletLock, mnemonicKey) + const secretKey = wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic) + await decryptWithRawKey(wallet.encryptedWalletLock, secretKey) return true } catch { return false @@ -353,24 +392,26 @@ export class WalletStorageService { ) } - // 验证助记词:尝试用助记词派生密钥解密钱包锁 + // 验证助记词/密钥:尝试用派生密钥解密钱包锁 + const secretKey = wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic) + try { - const mnemonicKey = deriveEncryptionKeyFromMnemonic(mnemonic) - await decryptWithRawKey(wallet.encryptedWalletLock, mnemonicKey) + await decryptWithRawKey(wallet.encryptedWalletLock, secretKey) } catch { throw new WalletStorageError( WalletStorageErrorCode.INVALID_PASSWORD, - 'Invalid mnemonic: failed to decrypt wallet lock' + 'Invalid mnemonic/secret: failed to decrypt wallet lock' ) } - // 助记词验证通过,重新双向加密 + // 验证通过,重新双向加密 try { - // 1. 使用新钱包锁加密助记词 + // 1. 使用新钱包锁加密助记词/密钥 const encryptedMnemonic = await encrypt(mnemonic, newWalletLock) - // 2. 使用助记词派生密钥加密新钱包锁 - const mnemonicKey = deriveEncryptionKeyFromMnemonic(mnemonic) - const encryptedWalletLock = await encryptWithRawKey(newWalletLock, mnemonicKey) + // 2. 使用派生密钥加密新钱包锁 + const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey) await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }) } catch (err) { diff --git a/src/stackflow/activities/WalletConfigActivity.tsx b/src/stackflow/activities/WalletConfigActivity.tsx index d657935d5..5c74bb1ea 100644 --- a/src/stackflow/activities/WalletConfigActivity.tsx +++ b/src/stackflow/activities/WalletConfigActivity.tsx @@ -36,7 +36,7 @@ function WalletConfigContent() { ); } - const title = mode === 'edit-only' ? t('detail.editTitle') : wallet.name; + const title = mode === 'edit-only' ? t('detail.editTitle') : t('detail.title'); return (
diff --git a/src/stackflow/activities/sheets/SecurityWarningJob.tsx b/src/stackflow/activities/sheets/SecurityWarningJob.tsx index f35790631..337aff677 100644 --- a/src/stackflow/activities/sheets/SecurityWarningJob.tsx +++ b/src/stackflow/activities/sheets/SecurityWarningJob.tsx @@ -4,6 +4,7 @@ import { IconAlertTriangle as AlertTriangle } from "@tabler/icons-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { useFlow } from "../../stackflow"; let onConfirmCallback: (() => void) | null = null; @@ -50,12 +51,10 @@ export const SecurityWarningJob: ActivityComponentType = () => {
{/* Checkbox */} - diff --git a/src/stackflow/activities/sheets/WalletListJob.tsx b/src/stackflow/activities/sheets/WalletListJob.tsx index a3662682d..e7735dd2b 100644 --- a/src/stackflow/activities/sheets/WalletListJob.tsx +++ b/src/stackflow/activities/sheets/WalletListJob.tsx @@ -1,11 +1,13 @@ import type { ActivityComponentType } from "@stackflow/react"; import { BottomSheet } from "@/components/layout/bottom-sheet"; import { useTranslation } from "react-i18next"; -import { IconPlus, IconWallet, IconCircleCheckFilled } from "@tabler/icons-react"; +import { IconPlus, IconCircleCheckFilled } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useFlow } from "../../stackflow"; import { useWallets, useCurrentWallet, walletActions } from "@/stores"; -import { WALLET_THEME_COLORS, useWalletTheme } from "@/hooks/useWalletTheme"; +import { useWalletTheme } from "@/hooks/useWalletTheme"; +import { useChainIconUrls } from "@/hooks/useChainIconUrls"; +import { WalletMiniCard } from "@/components/wallet/wallet-mini-card"; export const WalletListJob: ActivityComponentType = () => { const { t } = useTranslation(["wallet", "common"]); @@ -14,6 +16,7 @@ export const WalletListJob: ActivityComponentType = () => { const currentWallet = useCurrentWallet(); const currentWalletId = currentWallet?.id; const { getWalletTheme } = useWalletTheme(); + const chainIconUrls = useChainIconUrls(); const handleSelectWallet = (walletId: string) => { walletActions.setCurrentWallet(walletId); @@ -47,7 +50,6 @@ export const WalletListJob: ActivityComponentType = () => { ? `${address.slice(0, 6)}...${address.slice(-4)}` : "---"; const themeHue = getWalletTheme(wallet.id); - const themeColor = WALLET_THEME_COLORS.find(c => c.hue === themeHue) ?? WALLET_THEME_COLORS[0]; return ( ); })}