diff --git a/apps/mobile/bundle-registry/module-id-registry.json b/apps/mobile/bundle-registry/module-id-registry.json index 4e9a7bbdc53a..f3e527ef94b6 100644 --- a/apps/mobile/bundle-registry/module-id-registry.json +++ b/apps/mobile/bundle-registry/module-id-registry.json @@ -4360,6 +4360,13 @@ "node_modules/@onekeyfe/react-native-keychain-module/lib/module/index.js": 25992, "node_modules/@onekeyfe/react-native-lite-card/lib/module/NativeReactNativeLiteCard.js": 50400, "node_modules/@onekeyfe/react-native-lite-card/lib/module/index.js": 52794, + "node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js": 37385, + "node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js": 48454, + "node_modules/@onekeyfe/react-native-native-list/lib/module/index.js": 35464, + "node_modules/@onekeyfe/react-native-native-list/lib/module/scrolling.js": 27091, + "node_modules/@onekeyfe/react-native-native-list/lib/module/selection.js": 46717, + "node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js": 30351, + "node_modules/@onekeyfe/react-native-native-list/lib/nitrogen/generated/shared/json/NativeListConfig.json": 45389, "node_modules/@onekeyfe/react-native-native-logger/lib/module/index.js": 38825, "node_modules/@onekeyfe/react-native-perf-memory/lib/module/index.js": 55837, "node_modules/@onekeyfe/react-native-perf-stats/lib/module/index.js": 26192, @@ -15981,6 +15988,7 @@ "packages/components/colors/primitive/light.ts": 8053, "packages/components/colors/primitive/whiteA.ts": 9529, "packages/components/colors/semantic.ts": 14944, + "packages/components/src/actions/ActionList/imperativeShowUtils.ts": 6441, "packages/components/src/actions/ActionList/index.native.tsx": 17596, "packages/components/src/actions/ActionList/index.tsx": 14054, "packages/components/src/actions/ActionList/useAsyncItemsLifecycle.native.ts": 12994, @@ -20495,23 +20503,33 @@ "packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.ts": 8983, "packages/kit/src/views/AccountManagerStacks/components/WalletRename/index.tsx": 3967, "packages/kit/src/views/AccountManagerStacks/hooks/useAccountSelectorAvatarNetwork.tsx": 12908, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx": 4663, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountAddress.tsx": 18167, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountEditActionListV2.tsx": 6564, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorAccountListItem.tsx": 10535, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorActionV2.tsx": 3387, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorAddAccountButton.tsx": 16325, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountValue.tsx": 5698, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/EmptyView.tsx": 20181, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsHeader/AccountSearchBar.tsx": 3095, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsHeader/index.tsx": 20302, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx": 10713, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts": 11152, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.ts": 9064, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts": 6057, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/hooks/useAddAccount.ts": 8586, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/hooks/useAddHiddenWallet.tsx": 6993, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/index.tsx": 289, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/testIDs.ts": 16819, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoader.ts": 3362, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.ts": 6782, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorCreateWalletButton.tsx": 15866, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx": 8589, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBarV2.tsx": 21110, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/WalletListItem.tsx": 6670, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/index.tsx": 9583, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.ts": 5842, + "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts": 13662, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/index.tsx": 10708, "packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/useWebDappWalletSelector.ts": 8814, "packages/kit/src/views/AccountManagerStacks/pages/BatchCreateAccount/BatchCreateAccountForm.tsx": 16935, @@ -20869,6 +20887,14 @@ "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelector/PortfolioContent.tsx": 15803, "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelector/TabSwitcher.tsx": 6699, "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelector/index.tsx": 15710, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkContentV2.tsx": 536, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkSectionListV2.tsx": 23075, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx": 12115, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/PortfolioContentV2.tsx": 9770, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx": 22553, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/index.tsx": 4705, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts": 20474, + "packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkTooltipV2.tsx": 19806, "packages/kit/src/views/ChainSelector/hooks/useAndroidFlashListInitialScrollFix.ts": 8593, "packages/kit/src/views/ChainSelector/hooks/useChainSelector.ts": 20876, "packages/kit/src/views/ChainSelector/hooks/useFindNetworksWithoutAccount.ts": 20062, @@ -22626,6 +22652,7 @@ "packages/kit/src/views/Setting/pages/DevBundleSwitcher/VersionList.tsx": 11562, "packages/kit/src/views/Setting/pages/DevBundleUpdateStatus/index.tsx": 13207, "packages/kit/src/views/Setting/pages/DevDrawingOrderStress/index.tsx": 77, + "packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx": 20625, "packages/kit/src/views/Setting/pages/DevSesHardenRuntimeCheck/index.tsx": 23733, "packages/kit/src/views/Setting/pages/DevSplitBundleTest/index.tsx": 15365, "packages/kit/src/views/Setting/pages/DevUnitTests/PageDevUnitTests.tsx": 5685, diff --git a/apps/mobile/dev-vendor.config.js b/apps/mobile/dev-vendor.config.js index a2d8a3daf5de..26b7899323db 100644 --- a/apps/mobile/dev-vendor.config.js +++ b/apps/mobile/dev-vendor.config.js @@ -87,6 +87,7 @@ const nativeContractDependencies = { '@onekeyfe/react-native-image', '@onekeyfe/react-native-keychain-module', '@onekeyfe/react-native-lite-card', + '@onekeyfe/react-native-native-list', '@onekeyfe/react-native-native-logger', '@onekeyfe/react-native-network-throttle', '@onekeyfe/react-native-perf-memory', diff --git a/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj b/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj index 0486bf7ba5ea..c928b8e64e40 100644 --- a/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj @@ -549,6 +549,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/lottie-react-native/Lottie_React_Native_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/react-native-native-list/NativeListResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/react-native-view-shot/RNViewShotPrivacyInfo.bundle", ); name = "[CP] Copy Pods Resources"; @@ -586,6 +587,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/NativeListResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNViewShotPrivacyInfo.bundle", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 39e8908eca19..4bf1af92668a 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.104): + - AesCrypto (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -30,7 +30,7 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - AsyncStorage (3.0.104): + - AsyncStorage (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -51,7 +51,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.104): + - AutoSizeInput (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -74,7 +74,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.104): + - BackgroundThread (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -121,7 +121,7 @@ PODS: - ExpoModulesCore - SPAlert (~> 4.2) - SPIndicator (~> 1.6) - - ChartWebview (3.0.104): + - ChartWebview (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -145,7 +145,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.104): + - CloudFs (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -166,7 +166,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.104): + - CloudKitModule (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -193,7 +193,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.104): + - DnsLookup (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -395,7 +395,7 @@ PODS: - JPushRN (3.2.1): - React - JuiceboxSdk (0.3.2) - - KeychainModule (3.0.104): + - KeychainModule (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -458,7 +458,7 @@ PODS: - MMKVCore (~> 2.4.0) - MMKVCore (2.4.0) - MultiplatformBleAdapter (0.2.0) - - NetworkInfo (3.0.104): + - NetworkInfo (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -525,7 +525,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.104): + - OneKeyImage (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -550,12 +550,12 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.104) + - Skeleton (= 3.0.107) - Yoga - - OneKeyTextInput (3.0.104): + - OneKeyTextInput (3.0.107): - React-Core - OpenSSL-Universal (3.6.2000) - - Pbkdf2 (3.0.104): + - Pbkdf2 (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -576,7 +576,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.104): + - PerpDepthBar (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -599,7 +599,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.104): + - Ping (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2116,6 +2116,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-native-list (3.0.107): + - hermes-engine + - NitroModules + - OneKeyImage (= 3.0.107) + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-netinfo (12.0.1): - hermes-engine - RCTRequired @@ -2137,7 +2161,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.104): + - react-native-pager-view (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2311,7 +2335,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.104): + - react-native-tab-view (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2322,7 +2346,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.104) + - react-native-tab-view/common (= 3.0.107) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2333,7 +2357,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.104): + - react-native-tab-view/common (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2764,7 +2788,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.104): + - ReactNativeAppUpdate (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2788,7 +2812,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.104): + - ReactNativeBundleCrypto (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2812,7 +2836,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.104): + - ReactNativeBundleUpdate (3.0.107): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2861,7 +2885,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.104): + - ReactNativeCheckBiometricAuthChanged (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2886,7 +2910,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.104): + - ReactNativeDeviceUtils (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2931,7 +2955,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeGetRandomValues (3.0.104): + - ReactNativeGetRandomValues (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2955,7 +2979,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.104): + - ReactNativeLiteCard (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2977,7 +3001,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.104): + - ReactNativeNativeLogger (3.0.107): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -3001,7 +3025,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeNetworkThrottle (3.0.104): + - ReactNativeNetworkThrottle (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3024,7 +3048,7 @@ PODS: - Yoga - ReactNativePasskeys (0.3.3): - ExpoModulesCore - - ReactNativePerfMemory (3.0.104): + - ReactNativePerfMemory (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3048,7 +3072,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.104): + - ReactNativePerfStats (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3072,7 +3096,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.104): + - ReactNativeRangeDownloader (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3097,7 +3121,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.104): + - ReactNativeSplashScreen (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3143,7 +3167,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeZipArchive (3.0.104): + - ReactNativeZipArchive (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3626,7 +3650,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.104): + - ScrollGuard (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3657,7 +3681,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.104): + - SegmentSlider (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3683,7 +3707,7 @@ PODS: - Sentry (9.5.1): - Sentry/Core (= 9.5.1) - Sentry/Core (9.5.1) - - Skeleton (3.0.104): + - Skeleton (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3706,7 +3730,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.104): + - SniConnect (3.0.107): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -3731,7 +3755,7 @@ PODS: - Yoga - SPAlert (4.2.0) - SPIndicator (1.6.4) - - SplitBundleLoader (3.0.104): + - SplitBundleLoader (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3754,7 +3778,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.104): + - TcpSocket (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3890,6 +3914,7 @@ DEPENDENCIES: - "react-native-compat (from `../../../node_modules/@walletconnect/react-native-compat`)" - "react-native-document-picker (from `../../../node_modules/@react-native-documents/picker`)" - react-native-keyboard-controller (from `../../../node_modules/react-native-keyboard-controller`) + - "react-native-native-list (from `../../../node_modules/@onekeyfe/react-native-native-list`)" - "react-native-netinfo (from `../../../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../../../node_modules/react-native-pager-view`) - react-native-quick-base64 (from `../../../node_modules/react-native-quick-base64`) @@ -4220,6 +4245,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@react-native-documents/picker" react-native-keyboard-controller: :path: "../../../node_modules/react-native-keyboard-controller" + react-native-native-list: + :path: "../../../node_modules/@onekeyfe/react-native-native-list" react-native-netinfo: :path: "../../../node_modules/@react-native-community/netinfo" react-native-pager-view: @@ -4400,19 +4427,19 @@ CHECKOUT OPTIONS: :git: https://github.com/OneKeyHQ/app-modules.git SPEC CHECKSUMS: - AesCrypto: 5f01abb400e0472f6f7c4da01bb3a9e32936f3b0 + AesCrypto: 5252c73c9e6e7659a24c470c83ab7adc54b233f5 AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - AsyncStorage: eeae8c45c36c8a0d187dcafed1e1009613449b23 - AutoSizeInput: 52d873e1fe4f3dc0820c74d6c751c71760da5790 - BackgroundThread: d480c62e977e7f8f74eeb0e8bc8e78f6f3e2a073 + AsyncStorage: 8582cc2f35600a5cc6867481a88f3bd9b86cf625 + AutoSizeInput: b7fd89c5da5911bebd882af221bcfcf93ab722de + BackgroundThread: 901fab25beddf6be56706293162d3ebe7573531f BleUtils: 00f63c6bf8115301f8f47774b6e978f7174b4bc1 Burnt: e3a3397e26172fca31a59bb27421475a58068836 - ChartWebview: 31909ab00816555a318386cdaf74dc2c5e3ec0ac - CloudFs: 784a094064b4d2ec7c6261008dd5fd82dca3d88d - CloudKitModule: d974bdaeea42ef365d07d04e0178df2e40159813 + ChartWebview: 76d4f6ca8b9ad507613e63ccd24fc0be68f02dea + CloudFs: cb3287eaa28e96d15e7a1c7e7d14316d3d558c1b + CloudKitModule: 18821fe56070ba6cac43d4b82b39929907331d71 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 63d2dd661ac43f0772979cf2e530c6dc2e7cc9b1 + DnsLookup: b245cb29d6033486e278cad3730f9a82886b8401 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 EXConstants: e283cc77f61bddf6537e8ec1d351985e2394168a @@ -4461,22 +4488,22 @@ SPEC CHECKSUMS: JCoreRN: d985de509185b381c177fde4bb6bfc686089fdbd JPushRN: 807bc962b2f25860e5c0caa5fcb7b4910572b890 JuiceboxSdk: b2222491ce92b263694c217ea202a54b66c5ec5f - KeychainModule: 6d0801e640d695d75918c33943099056afe1a480 + KeychainModule: 1854974e50ce9494c3b55ab9ad73897f1c2a6144 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 lottie-react-native: 3234694e4dbd43853060e5eba15a35c323ed29ee MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa MultiplatformBleAdapter: b1fddd0d499b96b607e00f0faa8e60648343dc1d - NetworkInfo: 8643d019ef5bb96459329b86dc676017241db731 + NetworkInfo: a05db6c51e814671e3ce49e6f0341c2a3fba9eb9 NitroMmkv: 437a1303946283cfefe556d74e989228874aa8c0 NitroModules: d5be9f4559fc5178388ccf3ba2e152230b766d67 - OneKeyImage: a6f11358c72fb21630afb7cc1cd474badc4f271c - OneKeyTextInput: 6b4048aabb8b048b4c095c2a532cf02e4435934f + OneKeyImage: 971ddaf24649cd3dc624a36fbc06b4991f9220e1 + OneKeyTextInput: cb87871532056f8f450915855e878ab122357b5c OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e - Pbkdf2: d25c5f5eec87b5a7c5a20485e75fc813ea966ab2 - PerpDepthBar: c05bd8fde3da58d921dd2de78b9ec6b0a96fc0b6 - Ping: ada7d70a52ac4339a16014ce8950c032f38fded0 + Pbkdf2: 9005e2cd770f24f1ace79966eea9c5048cb32fa7 + PerpDepthBar: d55e289b31bb0ab800e8080e32db101c6928d9cb + Ping: 5406b36148c112cc21f8a60618d142c1b7a5cbe3 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PurchasesHybridCommon: 097af88db3cb39b415bef2f3140e6b3324899756 QuickCrypto: 459dd2f5b5c33f115238c08a9d75ace23bdf5601 @@ -4521,13 +4548,14 @@ SPEC CHECKSUMS: react-native-compat: 27466917b93b2da7c1eed1022227dba8c69d4ef5 react-native-document-picker: 598c549d1cbcc8c1f73abfbb5369d2faa10da242 react-native-keyboard-controller: 2ef1abca0f1b5ffc41c282c9e5f9c5576f83a53d + react-native-native-list: 68ce1746c104f0981554927466bdf0334537d979 react-native-netinfo: ac0848a4773ef5bd015399444409b5ea9ed75fa3 - react-native-pager-view: 107ec5569921f52775625d2a4d95089193340ea1 + react-native-pager-view: 4b12d00a15789d10cda007018cceb0dac36c64ed react-native-quick-base64: 3bc58c20e3621427e066fdcfe38b7ed452702bdb react-native-safe-area-context: 866dbfc3292621c18abe9857bda29485bf20607a react-native-skia: 65a19b18cfddadd1ba5f267216763a0b8467f2ec react-native-slider: 8a70a9fbb0253489730cc274171dc78b44600b46 - react-native-tab-view: f9e27b75acd9144a1399d49bb492187176e7c2d5 + react-native-tab-view: 2fd661e6e43a9f9e06481de3c00d4179ea295ca1 react-native-view-shot: e31564b1d0c57add676123f74ed1b6e7c7e22851 react-native-webview: d2b92fc3878f206cc5fecd618f4fb5b8d1652bd6 react-native-webview-cleaner: c2d3bbb850105553c845303044234ca82062d592 @@ -4565,25 +4593,25 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 5f06f18a986ad124808f4bdae7d4051fe157a5b1 ReactCommon: df0928b8d2064de53c42645dcf684c6e7bdea48c - ReactNativeAppUpdate: 171e4810b7076239cc14d4bec0213dc84e060ffd - ReactNativeBundleCrypto: 66389f1ce9e9d74072bf663f9f796e0d7e3df14b - ReactNativeBundleUpdate: dc8042cc0ce14dc76602c3b93a68682dd3ab21c8 + ReactNativeAppUpdate: 5dd9f61bff6be5138d1ac9742b8bf4aa88e9e8ae + ReactNativeBundleCrypto: d1f65ffbac1f35aa4f549dc3bae59a0e19cca249 + ReactNativeBundleUpdate: f7fefaec703083f0ded864b9eea2536f55fcfd00 ReactNativeCameraKit: 21f6c85397cfcb494dfa453a6fb4c9933a7c1bef - ReactNativeCheckBiometricAuthChanged: c269b6b630d29b2aa802a0035ecb491cd46da735 + ReactNativeCheckBiometricAuthChanged: da1901aceda741530084663a173deac3762363cb ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 0b534b3145bb4aade8ac5d8ad3f1014e9a547302 + ReactNativeDeviceUtils: 80695e7c13c93ff324438f230776f4833571876a ReactNativeFs: 4704ddcd290a4f26195f7236c7237611360df74b - ReactNativeGetRandomValues: 1833eb939a72bb10fb2e870d3441eb4cb1a79e3a - ReactNativeLiteCard: d4617859c001a62d1337d26f535d7cca3d3023b4 - ReactNativeNativeLogger: 5e772b28e5d3314af3aec163044ef50b2cb3d144 - ReactNativeNetworkThrottle: 6e9465bafcd37657fa1f516e01200b6b5e74e366 + ReactNativeGetRandomValues: 97bb7325810e140de7d6d12f1c6899ade7a8f07d + ReactNativeLiteCard: 972d50dd7653d537c706cda7513f026fe3f0695d + ReactNativeNativeLogger: 5d57c92f2542f5b35284ed2d86d33856ad5450a9 + ReactNativeNetworkThrottle: 0ae3db713234fc86dca774c54b75126df5ddb239 ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd - ReactNativePerfMemory: 27619522e70bdef4dbfe3f50935ccc13144b2cc2 - ReactNativePerfStats: f48b47fde27330c27ac8a72beb4478a9e9b8fa93 - ReactNativeRangeDownloader: c65289d3623901429a2bc6437f33d0e423859c39 - ReactNativeSplashScreen: 5a19f247822f5a5ef32879770abedd2cce913c9c + ReactNativePerfMemory: d9f6950a48c8946fcda5305e405e9f21462eebf3 + ReactNativePerfStats: 510936e1c8fed0d9ffa49d2ec4ee9f97e7b9b448 + ReactNativeRangeDownloader: 7e20d3503ccb77cb05590334e07778fba9b18a59 + ReactNativeSplashScreen: 91ad22849f777b705a34be231ab9f850c19aa96d ReactNativeVideo: 36938964abd84cb1355c5844d89feb501f3fbf93 - ReactNativeZipArchive: 1746dd03ef62feaad4d8cb5a414e11a2f48ad884 + ReactNativeZipArchive: 427024f750cb2913ba880fef941cb3347b4c1539 RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 RevenueCat: 72d1e14966339bf38c41d9b2841ef64c8fd79646 RNCMaskedView: a543b7a36c195a519d1bd7ef0291f1cbc6a84a0d @@ -4600,19 +4628,19 @@ SPEC CHECKSUMS: RNSentry: f9219292e372d83cccb99b7f5fa7a9d06c204adf RNSVG: 26c9fd280121dbb1639fae26d476a54c8016ed81 RNWorklets: 4405c9ce44ccd5af4e389229bbdc5e0314f2eee1 - ScrollGuard: 6648e905b1cc3fc7d499c3f53b411c8c3c0b8e09 + ScrollGuard: c369797d82fc6895ddfe4735ccfe391d80074c9c SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 718e5c7fde0e150af5695e176e99172a61e85185 + SegmentSlider: 39036f647b488528ff29064993c22ca5c11c1ee7 Sentry: 7475eb7bf6a41d7505f46341706015ad2d1766b9 - Skeleton: ca5afb6ba8c19ac02a453b462ffead84c26eabd0 - SniConnect: 0bcec9cb796f06d036d3681af14c96dffe3397b9 + Skeleton: 2cb116c0955e2eb92b5b1937aedb895a1f704b6c + SniConnect: 291f2b9fb3daddc878a8eef2bfa7a2e0b88389bb SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f - SplitBundleLoader: c35ea818165997c0f77735910a5faba6206d4215 + SplitBundleLoader: 6b34908dc35955e2b4a70de723457468da73702d SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: e549c8c3aa925666282c9b3f011beb5c73d24441 + TcpSocket: 53c9103368b3782d890da7a416f44f34d961b819 TOCropViewController: 5fa42dd0ac8c32790c06fc6057831d17b3b16857 Yoga: 0b38f02674a32b9a15de1f41f8680ffa06eaf8ef ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7dda79090621..fd7f9df5af93 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -77,38 +77,39 @@ "@formatjs/intl-locale": "^2.4.47", "@formatjs/intl-pluralrules": "^4.3.3", "@notifee/react-native": "9.1.8", - "@onekeyfe/react-native-app-update": "3.0.104", - "@onekeyfe/react-native-auto-size-input": "3.0.104", - "@onekeyfe/react-native-background-thread": "3.0.104", + "@onekeyfe/react-native-app-update": "3.0.107", + "@onekeyfe/react-native-auto-size-input": "3.0.107", + "@onekeyfe/react-native-background-thread": "3.0.107", "@onekeyfe/react-native-ble-utils": "0.1.6", - "@onekeyfe/react-native-bundle-crypto": "3.0.104", - "@onekeyfe/react-native-bundle-update": "3.0.104", - "@onekeyfe/react-native-chart-webview": "3.0.104", - "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.104", - "@onekeyfe/react-native-cloud-kit-module": "3.0.104", - "@onekeyfe/react-native-device-utils": "3.0.104", - "@onekeyfe/react-native-image": "3.0.104", - "@onekeyfe/react-native-keychain-module": "3.0.104", - "@onekeyfe/react-native-lite-card": "3.0.104", - "@onekeyfe/react-native-native-logger": "3.0.104", - "@onekeyfe/react-native-network-throttle": "3.0.104", - "@onekeyfe/react-native-perf-memory": "3.0.104", - "@onekeyfe/react-native-perf-stats": "3.0.104", - "@onekeyfe/react-native-perp-depth-bar": "3.0.104", - "@onekeyfe/react-native-range-downloader": "3.0.104", - "@onekeyfe/react-native-scroll-guard": "3.0.104", - "@onekeyfe/react-native-segment-slider": "3.0.104", - "@onekeyfe/react-native-skeleton": "3.0.104", - "@onekeyfe/react-native-sni-connect": "3.0.104", - "@onekeyfe/react-native-splash-screen": "3.0.104", - "@onekeyfe/react-native-split-bundle-loader": "3.0.104", - "@onekeyfe/react-native-tab-view": "3.0.104", - "@onekeyfe/react-native-text-input": "3.0.104", + "@onekeyfe/react-native-bundle-crypto": "3.0.107", + "@onekeyfe/react-native-bundle-update": "3.0.107", + "@onekeyfe/react-native-chart-webview": "3.0.107", + "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.107", + "@onekeyfe/react-native-cloud-kit-module": "3.0.107", + "@onekeyfe/react-native-device-utils": "3.0.107", + "@onekeyfe/react-native-image": "3.0.107", + "@onekeyfe/react-native-keychain-module": "3.0.107", + "@onekeyfe/react-native-lite-card": "3.0.107", + "@onekeyfe/react-native-native-list": "3.0.107", + "@onekeyfe/react-native-native-logger": "3.0.107", + "@onekeyfe/react-native-network-throttle": "3.0.107", + "@onekeyfe/react-native-perf-memory": "3.0.107", + "@onekeyfe/react-native-perf-stats": "3.0.107", + "@onekeyfe/react-native-perp-depth-bar": "3.0.107", + "@onekeyfe/react-native-range-downloader": "3.0.107", + "@onekeyfe/react-native-scroll-guard": "3.0.107", + "@onekeyfe/react-native-segment-slider": "3.0.107", + "@onekeyfe/react-native-skeleton": "3.0.107", + "@onekeyfe/react-native-sni-connect": "3.0.107", + "@onekeyfe/react-native-splash-screen": "3.0.107", + "@onekeyfe/react-native-split-bundle-loader": "3.0.107", + "@onekeyfe/react-native-tab-view": "3.0.107", + "@onekeyfe/react-native-text-input": "3.0.107", "@onekeyhq/components": "*", "@onekeyhq/kit": "*", "@onekeyhq/shared": "*", "@phantom/react-native-juicebox-sdk": "0.3.17", - "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.104", + "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.107", "@react-native-community/netinfo": "12.0.1", "@react-native-community/slider": "5.2.0", "@react-native-documents/picker": "^12.0.1", @@ -157,33 +158,33 @@ "path-browserify": "^1.0.1", "react": "19.2.3", "react-native": "0.86.2", - "react-native-aes-crypto": "npm:@onekeyfe/react-native-aes-crypto@3.0.104", + "react-native-aes-crypto": "npm:@onekeyfe/react-native-aes-crypto@3.0.107", "react-native-awesome-slider": "^2.9.0", "react-native-ble-plx": "3.5.1", "react-native-camera-kit": "17.0.1", "react-native-canvas": "^0.1.39", "react-native-capture-protection": "2.3.0", - "react-native-cloud-fs": "npm:@onekeyfe/react-native-cloud-fs@3.0.104", + "react-native-cloud-fs": "npm:@onekeyfe/react-native-cloud-fs@3.0.107", "react-native-collapsible-tab-view": "8.0.1", "react-native-crypto": "^2.2.0", - "react-native-dns-lookup": "npm:@onekeyfe/react-native-dns-lookup@3.0.104", - "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.104", + "react-native-dns-lookup": "npm:@onekeyfe/react-native-dns-lookup@3.0.107", + "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.107", "react-native-fs": "npm:@dr.pogodin/react-native-fs@2.34.0", "react-native-gesture-handler": "~2.32.0", - "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.104", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.107", "react-native-image-colors": "^2.5.0", "react-native-image-crop-picker": "0.51.1", "react-native-keyboard-controller": "1.21.9", "react-native-level-fs": "3.0.1", "react-native-mmkv": "4.3.2", "react-native-modal": "^13.0.1", - "react-native-network-info": "npm:@onekeyfe/react-native-network-info@3.0.104", + "react-native-network-info": "npm:@onekeyfe/react-native-network-info@3.0.107", "react-native-network-logger": "2.0.1", "react-native-nitro-modules": "0.37.0", - "react-native-pager-view": "npm:@onekeyfe/react-native-pager-view@3.0.104", + "react-native-pager-view": "npm:@onekeyfe/react-native-pager-view@3.0.107", "react-native-passkeys": "0.3.3", "react-native-permissions": "5.4.4", - "react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.104", + "react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.107", "react-native-purchases": "10.4.3", "react-native-qrcode-styled": "0.4.0", "react-native-quick-base64": "^3.0.0", @@ -193,13 +194,13 @@ "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-svg-transformer": "^1.5.3", - "react-native-tcp-socket": "npm:@onekeyfe/react-native-tcp-socket@3.0.104", + "react-native-tcp-socket": "npm:@onekeyfe/react-native-tcp-socket@3.0.107", "react-native-video": "7.0.0-beta.11", "react-native-view-shot": "5.1.0", "react-native-webview": "13.16.1", "react-native-webview-cleaner": "npm:@onekeyfe/react-native-webview-cleaner@1.0.0", "react-native-worklets": "0.10.1", - "react-native-zip-archive": "npm:@onekeyfe/react-native-zip-archive@3.0.104", + "react-native-zip-archive": "npm:@onekeyfe/react-native-zip-archive@3.0.107", "readable-stream": "^3.6.0", "realm": "20.2.0", "realm-flipper-plugin-device": "^1.1.0", diff --git a/docs/native-list-selector-performance.md b/docs/native-list-selector-performance.md new file mode 100644 index 000000000000..7ba18e80dab1 --- /dev/null +++ b/docs/native-list-selector-performance.md @@ -0,0 +1,323 @@ +# NativeList selector performance and validation + +Measured on 2026-09-07. The account and network selectors use NativeList V2 while preserving the V1 components and existing route names/parameters. The four migrated lists are the wallet sidebar, accounts, all networks, and single networks. + +The latest Desktop development-mode foreground samples sustain approximately 60 JS rAF FPS, with no unready avatar observations across the tested continuous-scroll matrix. **Native cold opening and first-scroll stalls remain above the frame budget.** Android main-thread profiling identifies repeated SWR cache serialization as a hotspot. Production Desktop avatar readiness now passes after repairing file-origin Worker startup; two account/wallet scrolling samples record approximately 120 JS rAF FPS, while opening/switching still exceeds the frame budget. Earlier URI-only pixel/persistence results are retained below with their original provenance. + +## Implementation + +- V2 account rows contain a stable `onekey-avatar://blockie/v1/` URI. They no longer synchronously generate or transport PNG/Base64 avatars in list snapshots. The original seed precedence and 128 x 128 pixels are preserved. +- Wallet animal avatars retain their existing resource URIs. External-wallet logos retain their original image source, with numeric React Native assets resolved to a URI. +- iOS resolves the URI in the native image loader, with at most two generation jobs and a dedicated SDImageCache: 8 MiB / 128 memory entries, 32 MiB / 30 days on disk. Cleanup follows cache policy rather than a strict instantaneous disk bound. +- Android resolves it on the Glide source executor and stores the original PNG with `DiskCacheStrategy.DATA` in the existing shared disk LRU. Rendering sizes and other image cache policies are unchanged. +- Desktop/Web use an external Worker for generation, PNG validation, and IndexedDB persistence (32 MiB / 2048 entries, two concurrent jobs). The list thread receives Blob URL strings. The memory cache targets 128 total URLs; active leases are protected and may exceed that target. +- Requests are coalesced, canceled when no longer needed, and guarded against stale results after row reuse. Corrupted persisted avatar images are regenerated. Web pre-mount row patches are retained against the matching snapshot, including imperative snapshot replacement. +- The iOS index-bar gesture owns touches originating in the index rail; ordinary modal header dragging still dismisses the selector. + +iOS and Android main/background JS runtimes have separate heaps. Avatar generation and caching use process-owned native image resources; PNG bytes are not copied into list snapshots or between those JS runtimes. Desktop app main/background code shares one JS thread; the avatar Worker has its own execution context. Extension UI/background runtimes remain separate and use the same Web image adapter; actual extension runtime validation is still open. + +## Reuse boundaries + +NativeList remains a reusable list implementation. Virtualization, index navigation, +row updates, image request scheduling, and reusable native cells belong to the +module. Other screens can reuse them by supplying the supported row descriptors +and stable keys/URIs; they do not need wallet services or the selector hooks. +The generated-avatar cache specifically handles the `onekey-avatar://blockie/v1/` +URI scheme. Ordinary image URLs continue through the existing image loaders. + +Balance loading, fiat/DeFi aggregation, account formatting dependencies, and +enabled/missing-address network state belong to the selector business layer. +These optimizations benefit both selectors' supported platforms, but are not +generic list behavior. Existing selector-specific row presentations also remain +optional module templates rather than a requirement for other list consumers. + +The `accSelList` cache namespace now retains at most three recently updated list +scopes and 6 Mi serialized characters in total, under the unchanged global +per-entry limit. A wallet/network/derive combination is one scope, not necessarily +one wallet. V1/V2 use the same keys and revalidate evicted entries through the +original service. Other namespaces retain their existing budgets. This bounds +cache capacity; it is not evidence that the previous memory sample was a leak. + +## Follow-up: scheduling, avatar preparation, and bounded retention + +The follow-up changes address four areas: repeated account-list work, duplicate network-selection work, avatar display waiting on disk operations, and retained account/group capacity. V1 components and existing route names/parameters remain available. Source provenance for this follow-up is `52fdb1bbee3681a5c9176d1aa9535905870edb40`; sample files retain their own capture timestamps. This source reference does not retroactively change the build provenance of older results. + +| Patch used for the following native/development samples | SHA-256 | +| --- | --- | +| `@onekeyfe+react-native-native-list+3.0.105.patch` | `0648ad8087a9e092c1390c78942cccf9d40b0d09eccbc3237b6b34c8f948f5f1` | +| `@onekeyfe+react-native-image+3.0.105.patch` | `323181a9086abfc26a33e531333eeb8b341c765600128871bbcb759e24f36f08` | + +### What changed and where it can be reused + +| Area | Implemented behavior | Reuse and correctness boundary | +| --- | --- | --- | +| Account initialization | Compute each snapshot diff once; skip unchanged references/content; invalidate formatting for the affected account or changed shared formatting context. A V2 loader merges pending results and yields through rAF plus a subsequent task when its work budget is used. | Account V2 business code: `WalletDetailsV2.tsx`, `accountSelectorAccountRowsV2.ts`, `accountSelectorValueRowsV2.ts`, `useAccountSelectorValuesLoaderV2.ts`. The original service and shared values/DeFi atoms remain. It preserves account-selector `num`, wallet/network context, Perps aggregation, refresh values, cancellation and subsequent batches after a service failure. Other NativeList consumers do not automatically receive this business scheduler. | +| Network selection | Derive enabled networks in the same render as selection; remove the extra missing-address query trigger; memoize amount/image descriptors independently of checkboxes. | Network selector V2 code in `UnifiedNetworkSelectorV2.tsx` and `NetworksSectionListV2.tsx`. It still uses the existing enabled/missing-address hooks and ordinary NativeList selection events. Deltas update affected network IDs and preserve selections outside a filtered result; Apply/custom-network business APIs are unchanged. | +| Avatar preparation | Prioritize visible and forward-direction blockie requests; reuse pending generated bytes; return Blob URLs without awaiting persistence; use readonly cache reads with an 8 ms soft waiting budget and idle, bounded writes/touches. | Generic NativeList/image behavior for the existing `onekey-avatar://blockie/v1/` URI in supported image descriptors. Ordinary HTTP image URLs are not swept into this blockie prefetch path. No new public API is required. Native preload keeps at most one uncancelable request in flight and replaces pending work on direction changes; image patches update a sparse prefetch overlay rather than disabling future prefetch. | +| Retention | Apply a dedicated account-list SWR capacity budget and trim reusable native wallet-group members after safe reuse/rebind. | `accSelList:` is a selector-business namespace shared by V1/V2, not a global cache reduction. Native group pools are reusable module behavior: retain `max(8, current required members)` and protect compact drag proxies/expansion. These are capacity controls, not proof that a measured heap increase was a leak. | + +In the controlled 1000-account test, the loader still makes 20 service requests of 50 accounts. Effective values/DeFi map publications decrease from 40 to 4; an identical refresh changes no values. These are state publications, not React commit counts. Slower service responses may legitimately cause more budgeted publications. The mounted network fixture (three network rows plus an asset header, initial render and one checkbox change) records missing-address calls 4→2, amount lookups 6→3, formatting 8→4, and leading-image construction 6→3. These call-count tests are not a device-speed benchmark. + +The saved focused reports contain 18 passed account tests and 10 passed network tests. They cover formatting invalidation, same-reference updates, structural/cleared-field fallback, deferred patches, cancellation, context changes, filtered selections and money presentation. These saved reports were reviewed rather than rerun for this documentation update. + +### Desktop: corrected foreground measurements + +Desktop main/background business code shares the Electron renderer JS thread; the avatar Worker and Electron native/main processes are separate resources. These measurements use the Debug renderer. Each of the twelve steady samples records the owned Electron process as the system foreground application immediately before and after the sample. This is stronger than `page.bringToFront()` or `document.visibilityState`; it is still a before/after observation, not continuous system-focus telemetry. + +Each list has three samples of four real CDP mouse-scroll legs, 1800 CSS px per leg at speed 1400. No CPU profiler, heap collection, or concurrent native build was used in these frame samples. + +| List | Samples | Total rAF intervals | JS rAF FPS range | Worst sample P95 (ms) | Maximum (ms) | Intervals >25 ms | JS long tasks | Mounted rows at recorded checkpoints | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Accounts | 3 | 970 | 60.000–60.011 | 17.50 | 17.70 | 0 | 0 | 18 | +| Wallet sidebar | 3 | 959 | 60.000–60.005 | 17.60 | 17.80 | 0 | 0 | 14 | +| All networks | 3 | 973 | 59.996–60.004 | 17.60 | 17.70 | 0 | 0 | 21 | +| Single network | 3 | 971 | 59.998–60.008 | 17.60 | 17.70 | 0 | 0 | 24 | + +The combined 3873 sampled intervals contain no >25 ms interval and no reported JS long task. This supports approximately 60 Hz steady renderer scheduling in these samples. It does not mean every interval was at most 16.67 ms, establish a 120 Hz budget, or directly measure compositor presentation. Mounted counts are checkpoints, not a continuous maximum. + +The earlier `desktop-acceptance-final.json` is retained as a non-passing sample whose system foreground state was not verified: 48.632–54.112 rAF FPS across twelve samples, with 592 intervals over 25 ms despite zero JS long tasks. The similarly earlier avatar matrix recorded 40 unready row-frame observations across 14 sampled frames, involving five keys and five unready reentries. These are observed failures, not silently discarded data. The corrected foreground run supplies the accepted measurement condition; the evidence does not prove every earlier deviation was caused exclusively by focus. + +Source: `evidence/perf-fixes/desktop-acceptance-front.json`; earlier boundary: `desktop-acceptance-final.json` and `desktop-avatar-matrix-final.json` in the same directory. + +### Desktop: fresh-seed avatar visibility + +Before this matrix, all 1000 avatar URI keys for each of six distinct fixture wallets were checked against the avatar store; every wallet had zero matching disk keys. Each wallet was then opened, its initial visible images were allowed to become ready, and the harness waited one second before scrolling. Therefore these are **fresh-seed/disk-cold at wallet entry** cases, not a claim that the initial viewport or every future row remained uncached at the instant scrolling began. Bounded prefetch is deliberately allowed to prepare future rows. + +All six samples record the owned Electron PID as system foreground before and after the measurement. Four real CDP scroll legs are observed at every rAF; visible rows are checked for URI/image/paint-state readiness and internal gaps. This geometry instrumentation is separate from FPS acceptance. + +| Speed (CSS px/s) | Runs | Sampled frames per run | Total frames | Unready row-frame observations | Unready reentries | Internal gaps / errors | +| --- | ---: | --- | ---: | ---: | ---: | --- | +| 1200 | 3 | 1090 / 1091 / 1093 | 3274 | 0 | 0 | 0 / 0 | +| 6500 | 3 | 681 / 681 / 682 | 2044 | 0 | 0 | 0 / 0 | + +Across 5318 sampled frames, unready sampled frames, unique unready keys, already-ready-to-unready transitions, unready reentries, internal gaps and image errors are all zero. This replaces the earlier unresolved continuous-scroll result for this tested foreground matrix. It is not a guarantee of zero latency for arbitrary cold index jumps, every scroll speed, or unrecorded transient compositor frames. DOM/image-state observations are not an independent frame-by-frame raster comparison, and the 5318 count is not an FPS measurement. + +Source: `evidence/perf-fixes/desktop-avatar-cache-front.json`, `desktop-avatar-matrix-front.json`, and the six referenced `evidence/performance/desktop-io-front-quiet-{1200,6500}-{0,1,2}.json` files. `worker-generator-unchanged.json` records unchanged generator bytes; its generator SHA-256 is `953afab26f5bd227ce8955f20f63acc32761655b6cb0f97e8490a759ceb81e02`. Existing V1 pixel-golden evidence remains separate from this scheduling test. + +### Cold page opening remains above the frame budget + +These are selector/fresh-wallet page observations, not full application process-start measurements. The native targets are Debug simulators/emulators, and the Desktop measurements use the corrected foreground condition. The Desktop DEV harness pre-focuses the intended wallet outside the timed open; a future production real-mouse first-modal sample has a different trigger and must not be directly labeled the same before/after test. + +| Target | Maximum rAF interval, runs 0 / 1 / 2 (ms) | Largest JS long task, runs 0 / 1 / 2 (ms) | Long-task counts | +| --- | --- | --- | --- | +| Desktop, corrected foreground | 99.60 / 99.70 / 83.30 | 112.00 / 105.00 / 99.00 | 2 / 1 / 1 | +| Android | 159.28 / 146.03 / 216.48 | 155.78 / 145.91 / 200.93 | 5 / 3 / 5 | +| iOS, latest completed run | 231.67 / 273.74 / 269.83 | 141.96 / 273.22 / 203.63 | 4 / 4 / 5 | + +None of these cold-opening rows establishes a full-frame pass. Long-task and rAF boundaries differ: a task before the probe's first callback can be larger than its largest recorded rAF interval. The old 7.2-second iOS long task remains the earlier URI-migration baseline; it must not be described as isolated PNG encoding time or used to claim that these latest runs controlled every other cache/state variable. + +Separate time-aligned Desktop CPU profiles place the first 104 / 109 / 104 ms long tasks mainly in React DEV mounting: `performWorkOnRoot` inclusive 74.36 / 78.10 / 73.89 ms, including Error-stack construction self 29.98 / 35.59 / 29.14 ms. The account loader and patch builder had no samples inside those first tasks. These Error samples are framework stack construction, not application error counts. An independent later 73 ms task contained 64.67 ms of debug timer configuration synchronous IPC. Those costs overlap and cannot be summed or assigned millisecond-for-millisecond to the unprofiled cold samples above. Production disables the identified DEV paths, but that source fact is not a production performance result. + +Across the separate full CPU sampling windows, excluding profiler startup, loader self samples changed from 39.19 / 38.05 / 36.72 ms to 1.09 / 1.26 / 0 ms; patch builder inclusive samples changed from 13.34 / 6.28 / 5.98 ms to 1.25 / 2.52 / 2.38 ms. These are cumulative sampled attribution, not isolated call durations or a controlled device-speed ratio. Zero means no sampling hit, not proven zero work. + +Sources: `evidence/perf-fixes/desktop-acceptance-front.json`, `android-final-acceptance.json`, `ios-final-staged-acceptance.json`, and `desktop-cold-cpu-audit.md` with its cited profiles/time-base JSON. + +### Android: retain the unresolved scroll-window spike + +Android main and background run in separate Hermes heaps; the service fixture lives in background. The rolling native sampler is process-owned. The existing steady test drives the public `scrollToOffset` API at 1200 px/s; it is not a finger-gesture arbitration test. + +| List | Three-sample rAF FPS range | Worst P95 (ms) | Maximum recorded interval (ms) | Intervals >25 ms | Long tasks | +| --- | ---: | ---: | ---: | ---: | ---: | +| Accounts | 57.701–59.996 | 21.07 | 212.82 | 2 | 1 | +| Wallet sidebar | 59.998–60.002 | 17.78 | 20.31 | 0 | 0 | +| All networks | 58.211–60.000 | 17.64 | 190.02 | 2 | 1 | +| Single network | 59.844–59.999 | 17.98 | 44.42 | 1 | 0 | + +The original probe includes reset/double-rAF preparation. Offline monotonic-interval/wall-marker alignment gives distinct boundaries: + +- Account run 0: the 212.821 ms interval begins approximately 19.179 ms after the gesture marker and ends at +232 ms. Its 68.469 ms interval is also after the marker. The approximately 213 ms stall cannot be removed as preparation and remains unassigned to a function or subsystem. +- All-network run 0: the 190.019 ms interval runs approximately −199.019 to −9 ms relative to the gesture marker, entirely in preparation. It must not be reported as a 190 ms sustained-scroll task. A separate 54.293 ms interval crosses the marker (−0.293 to +54 ms). +- Single-network run 0: a 44.418 ms interval occurs after the marker without a >50 ms long task. The other nine samples have no >25 ms interval. + +The old gesture marker predates the exact first moving command, and wall endpoints have integer-ms precision. The timeline does not attribute the account stall to avatars, React, GC, cache reads or NativeList updates. Main-runtime `Profiler.enable` was unavailable (`Unsupported method 'Profiler.enable'`); that attempt did not produce an attribution profile. A subsequent native sampling profile is described below. These original Android results must not be relabeled as having used the later preparation-separated helper. + +Sources: `evidence/perf-fixes/android-final-acceptance.json`, `android-final-steady-timeline-audit.json`, `android-final-steady-harness-audit.md`, and `android-first-scroll-profiler-availability.json`. + +### Android: main-runtime SWR attribution + +A separate diagnostic run used the existing native Sentry/Hermes sampling API without calling upload methods or changing Sentry configuration. RN `Tracing` was not used because `ReactNativeApplication.systemStateChanged.isSingleHost` was false. The native sample contains 812 samples on thread 10182 (`mqt_v_js`); 83 contain the main `index.bundle`/`runtimeTarget=main`, with no background bundle frames. This establishes the main-runtime scope for this profile. Main/background heaps remain separate; background owns persistence through the shared native MMKV resource. + +The 224.894 ms preparation long task contains 22 sampling hits, all on the SWR flush path, including optimistic mirror processing and RPC dispatch. The call chain repeatedly parses, prunes and serializes the complete store: `flush → applyNativeSWRCachePatchToSerializedStore`, followed by `mutate → applyNativeSWRCacheCanonicalEntries`. GC appears within the same chain. A 66.748 ms interval during the later scroll contains four hits, three in `acknowledgeRemoteMutation → applyCanonicalMutation → applyNativeSWRCacheCanonicalEntries → parseStore/prune`. That interval is a frame gap, not a measured 66.748 ms single task; its observer did not report a >50 ms task. + +This confirms repeated full-store work during optimistic writes and background acknowledgements as an observed hotspot. It does not retroactively assign every millisecond of the earlier unprofiled 213 ms sample, or the iOS 70 ms interval, to this cause. Both fixture runtimes were restored. The proposed shared-storage fix is under scope confirmation and has not been implemented or performance-accepted. + +Sources: `evidence/perf-fixes/android-first-scroll-attribution.json`, `android-first-scroll-attribution.hermes.json`, `android-first-scroll-attribution-audit.md`, and `android-first-scroll-profile-audit.json` (input hashes and time-aligned stacks). + +### iOS: latest steady sample separates preparation + +The latest completed iOS Debug Simulator run uses the staged helper. It records module search/load, ref lookup, reset and wait separately; the sample starts immediately before the first moving public `scrollToOffset` command. Its main/background heaps are separate, and native rolling metrics are process-owned. These are API-driven rendering samples, not new physical-finger or compositor proof. + +| List | Intervals across 3 runs | JS rAF FPS range | Worst P95 (ms) | Maximum sampled interval (ms) | Intervals >25 ms | Sample long tasks | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Accounts | 1167 | 59.537–59.999 | 16.95 | 70.15 | 1 | 1 | +| Wallet sidebar | 1170 | 59.999–60.000 | 16.81 | 26.21 | 1 | 0 | +| All networks | 1172 | 59.989–60.002 | 16.75 | 17.87 | 0 | 0 | +| Single network | 1171 | 60.000–60.004 | 16.79 | 17.56 | 0 | 0 | + +The 4680 sampled intervals include one 70.154 ms account interval with a 67.798 ms long task and one 26.208 ms wallet interval without a long task. The other ten samples have no >25 ms interval or sample long task. The account stall is inside the measured moving-command window, remains unattributed, and cannot be explained away as preparation. The maximum measured synchronous public-command call was 4.255 ms; this does not account for asynchronous native/render work. + +Preparation lasted 33.944–542.183 ms and separately contained six long tasks, largest 344.517 ms. None crossed the recorded preparation/sample boundary. Those preparation results remain visible rather than being deleted to make the steady table look clean. Main/background fixture restoration both completed according to the JSON. Latest native cold-opening and actual-scroll spikes remain open performance work; this run is not an all-scenarios pass. + +Source: `evidence/perf-fixes/ios-final-staged-acceptance.json` and its paired `.log`; helper `steady-scroll-perf-staged.cjs`. + +Separately, the final `0648…f5f1` iOS build received real native drag input on the All networks index rail. The saved after-images show the index selecting U/Unichain on the downward drag and B/Bitcoin on the upward drag while the modal remains open. The two 30 fps recordings contain 59 and 56 analyzed frames, no missing header-pill frame and a 1-point scanned pill-top variation; this small raster/scan variation does not establish a modal pan. A header-origin downward drag still dismisses the modal: the independent final navigation state is `rootIndex: 0` with `lists: []`. These recordings verify gesture ownership and dismissal in the tested Simulator; their encoding frame rate is not JS/UI FPS. The single-network index gesture was not rerun in this final-build check. + +Gesture sources: `evidence/ios-index-gesture/final-0648-portfolio-{down,up}.json`, their `-after.png` images and `.mp4` recordings, `final-0648-header-dismiss.json`, and `evidence/perf-fixes/ios-final-header-dismiss-state.json`. + +### Memory: bounded capacity, not a proven leak reduction + +The follow-up Desktop memory run opens/closes six different 1000-account fixture wallets and explicitly requests GC between stages, outside FPS sampling. It measures renderer JS heap/DOM only; Worker, image decode, GPU and native process memory are not included. + +| Stage | Renderer JS heap (MiB) | DOM nodes | Mounted lists | +| --- | --- | --- | --- | +| Initial closed state | 177.960 | 3154 | 0 | +| Six opened states | 183.001 → 186.376 | 3719 each | 2 | +| Six closed states | 180.016 → 182.707 | 3174 each | 0 | +| Fixture restored and closed | 180.326 | 3179 | 0 | + +Post-restore heap is 2.366 MiB above this run's initial state. There is no progressive closed-DOM node growth, but this short run proves neither a long-term leak nor a memory improvement. An earlier run began at 169.986 MiB and ended at 171.553 MiB (+1.566 MiB), with different document/DOM baselines; subtracting the two runs is not a controlled regression/improvement calculation. In the earlier direct persistent-SWR inspection, all 34 entries used 433531 serialized characters and `accSelList` entries were zero. The observed heap growth cannot be assigned to SWR from those results. + +The new `accSelList:` budget is at most three recently updated complete list scopes and 6 Mi serialized characters in total, under the unchanged 5 Mi per-entry global limit. A wallet/network/derive combination is a scope, not necessarily one wallet. V1/V2 share the keys. Evicted results revalidate through the original service; other namespace budgets and shared values/DeFi atoms are not cleared. Serialized-character budgets are not actual heap or RSS limits, and native main/background budgets must not simply be multiplied into alleged occupied memory. + +The current account formatting cache retains the current account set, not every prior wallet; native snapshot/row representations remain O(N). The fixture's three-wallet LRU bounds only fixture-generated records, not every application cache. Native group pools trim idle historical capacity while retaining current members and animation/drag state. In the separate iOS 32-child→2-child group check, `NativeListCell` objects reachable from window view hierarchies decreased 43→18; a subsequent real group drag emitted the parent reorder and expanded correctly. This counts reachable views, not all allocated cells, process RSS or every animation frame. + +Web avatar limits also need distinct ownership labels: 128 is the document cache's target entry count, with active leases protected and potentially exceeding it. Worker pending writes retain at most 32 items or 4 MiB, LRU touches at most 128 keys, and disk data at most 2048 entries or 32 MiB under normal metadata operation. Two display loads and two physical disk-read permits are independent bounds. The 8 ms timer only limits display waiting for disk; generation/decode/paint can take longer. Continuous scrolling may postpone persistence or evict queued old writes, and a blocked database may keep disk caching unavailable while visible generation continues. No guarantee of immediate persistence or total process-memory ceiling is claimed. + +Sources: `evidence/perf-fixes/desktop-memory.json`, `evidence/cold-init-analysis/desktop-memory.json`, `desktop-swr-counts.json`, and `evidence/perf-fixes/ios-group-before.log`, `ios-group-after.log`, `ios-group-drag.json`. + +### Production Desktop: file-origin Worker repair + +Direct Worker construction from the packaged `file:` URL succeeded synchronously but subsequently aborted script loading. Fetching the same script through Electron's existing file interceptor succeeded. The NativeList patch now fetches the packaged script asynchronously and starts a Blob Worker only for `file:` assets. HTTP and extension URLs retain the existing bundler-recognized Worker entry. Startup coalesces pending leases, honors cancellation and current priority, guards failed generations, and revokes the temporary script URL. Image generation, cache formats, native sources, public declarations, CSP and Electron security settings are unchanged. + +The resulting NativeList patch SHA-256 is `cc47d17108763b047db4619a8c64aa21a9d59386d6e2696213a322653124b4bf`. Relative to `0648…f5f1`, only the Web avatar broker's TypeScript source and published JavaScript changed. The earlier native evidence therefore retains its original build label. Eight startup behavior tests pass against each broker entry, the existing nine scheduling tests and strict TypeScript checks pass, and pristine patch replay matches 207 installed files with no build artifacts. An independent real file-origin probe verifies IndexedDB write, read from a new Blob Worker, and deletion; it does not establish that every application avatar has been persisted. + +Production renderer build `7863b1f557b28d0a` completed with zero errors and 16 existing warnings; all 26 initial-script integrity values matched. A stale dependency-cache build was rejected before acceptance, then rebuilt with persistent cache disabled in a temporary wrapper. Neither tracked build scripts nor either Electron main build changed. No Sentry upload or `ONEKEY_USER_NOTICE` was emitted by this renderer build. These runtime results use app source `105e3f8c99` plus the `cc47…b4bf` patch. The remote branch subsequently merged `x` at `2bc29eac64`; the final combined branch has not been rebuilt for these measurements. + +The actual production Electron used an isolated QA profile, the normal production renderer and normal password verification. Main/background business code shares one renderer JS thread; the avatar Worker and Electron main process are separate. Geometry was 1200 × 675 CSS pixels, DPR 2. The owned Electron process was the system foreground application before and after every sample. No CPU profiler ran during these samples. + +| Production trigger | rAF intervals | Maximum interval (ms) | JS long tasks | Readiness / steady result | +| --- | ---: | ---: | --- | --- | +| First modal mouse-open in this harness | 7 | 42.0 | One, 50 ms | Visible avatars ready at the first successful poll, 81.8 ms after input; two real QA accounts and 1000 fixture wallets | +| Fresh 1000-account wallet switch 1 | 8 | 40.9 | One, 50 ms | Visible avatars, amounts and target identity ready at 96.6 ms | +| Fresh 1000-account wallet switch 2 | 8 | 32.7 | None | Ready at 85.4 ms | +| Fresh 1000-account wallet switch 3 | 6 | 33.4 | None | Ready at 76.1 ms | +| Accounts, three scroll round trips | 954 | 9.4 | None | 120.005 rAF FPS; P95 9.3 ms; zero intervals >16.8 ms | +| Wallet sidebar, three scroll round trips | 954 | 9.4 | None | 119.989 rAF FPS; P95 9.2 ms; zero intervals >16.8 ms | + +Scrolling uses real CDP mouse gestures at 1400 CSS px/s, 1800 px per leg. Image readiness is checked at gesture checkpoints, not every raster frame. The measured rAF cadence is approximately 120 Hz; this is not panel presentation proof and must not be compared directly with the earlier development client's 60 Hz cadence or different geometry. Short opening samples are reported as intervals/tasks rather than misleading average FPS. Readiness polling is every 100 ms, and the measured first modal was already code/image-warmed by preceding diagnosis. Fresh service build counts were zero before each synthetic wallet switch, but avatar disk keys were not preflighted; none of these samples establishes process cold-start or a disk-cold avatar result. + +All 15 recorded console errors have the same digest, independently identified as the unsupported `sentry-ipc` URL-scheme error. No page error was recorded. The report remains `MEASURED_WITH_CONSOLE_ERRORS`; the trace does not attribute the remaining 50 ms tasks to Sentry or any other subsystem. Real-account screenshots and DOM checks confirm ready Blob-backed account avatars and unchanged file-backed wallet images. The fixture, original selected account/network and closed-modal state were restored, and QA globals were removed. The earlier readiness/cleanup failures remain saved and are not counted as successful samples. + +Sources under `evidence/production-measurement/`: `file-worker-fix/verification.json`, `file-worker-fix/production-renderer-result.json`, `production-file-worker-fixed-cc47-ready.json`, and `production-file-worker-repaired-ui.json` with its screenshot. Production network-list, continuous-avatar, memory and process cold-start measurements are not supplied by these two account/wallet scrolling samples. + +### Remaining acceptance boundary + +The corrected Desktop development steady/continuous-avatar matrix is complete within the stated environment and speeds. Android's approximately 213 ms scroll-window stall, iOS's approximately 70 ms sampled account stall, and cold-opening long tasks remain unresolved. Production account-avatar loading is repaired and the account/wallet samples above are complete, but 33–42 ms opening/switching intervals and two 50 ms tasks remain. Shared-storage changes await scope confirmation. The separate development-mode client remains running. First production modal opening, a fresh service wallet switch, and a cold avatar disk lookup are separate triggers/cache states; file-origin storage cannot be assumed equivalent to the copied HTTP profile. + +Actual installed extension behavior, Release physical-device results, 120 Hz/compositor presentation, arbitrary cold index jumps, hardware SDK communication and transaction/derivation work are not established by this follow-up. Do not merge a blanket “all platforms full-frame” or “no memory leak” conclusion from these samples. + +## Earlier URI-only baseline + +The following measurements belong to the earlier `8077a93a…` NativeList patch. They preserve the original before/after and pixel/persistence evidence; they are not the latest follow-up build results. + +### Measurement conditions + +| Target | Environment | Scrolling sample | +| --- | --- | --- | +| iOS | iPhone 17 Pro Simulator, iOS 26.5, Debug, 402 x 874 points, DPR 3 | Public NativeList `scrollToOffset`, about 10 s per list | +| Android | API 36 arm64 emulator, Debug, 1080 x 2400 pixels, density 420, 60 Hz | Public NativeList `scrollToOffset`, about 10 s per list | +| Desktop | Actual isolated Electron renderer, 1280 x 900 CSS pixels, DPR 2 | Six real CDP mouse-scroll gestures, about 8 s per list | + +Native programmatic scrolling isolates list rendering from the extra accessibility-tree work performed by XCUI. Separate real finger-gesture recordings validate visible behavior. These Debug simulator results do not establish Release-device, panel presentation, or 120 Hz performance. + +The account stress fixture supplies 1000 wallets and 1000 accounts per wallet at the original service boundary. Only the current wallet is materialized, with an LRU of three wallets / 3000 account records. This represents one million logical accounts, not one million persisted accounts. It does not measure database ingestion, address derivation, or transactions. Production selectors continue to use the original real wallet/account services; the temporary fixture was restored after testing. + +Network tests use the QA environment's normal server/configuration data: 157 all-network rows and 177-178 single-network rows, including structural rows. Network configuration and selected/asset groups can differ between devices. + +Steady samples below were collected without concurrent native builds. rAF FPS is calculated from recorded JS frame intervals, not the screen-recording frame count. All twelve samples contain zero reported JS long tasks. + +### Steady scrolling + +| Target | List | Sampled intervals | JS rAF FPS | P95 (ms) | Maximum (ms) | Intervals >25 ms | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| ios | Accounts | 601 | 60.06 | 16.76 | 21.08 | 0 | +| ios | Wallet sidebar | 601 | 60.02 | 16.83 | 18.62 | 0 | +| ios | All networks | 601 | 60.08 | 16.77 | 18.10 | 0 | +| ios | Single network | 602 | 60.01 | 16.76 | 21.32 | 0 | +| android | Accounts | 601 | 60.00 | 18.01 | 22.98 | 0 | +| android | Wallet sidebar | 601 | 59.99 | 18.44 | 21.81 | 0 | +| android | All networks | 602 | 60.00 | 18.25 | 22.49 | 0 | +| android | Single network | 602 | 60.00 | 18.45 | 25.08 | 1 | +| desktop | Accounts | 489 | 60.02 | 17.50 | 17.70 | 0 | +| desktop | Wallet sidebar | 485 | 60.00 | 18.50 | 18.70 | 0 | +| desktop | All networks | 486 | 60.01 | 18.50 | 18.70 | 0 | +| desktop | Single network | 487 | 59.99 | 18.40 | 18.70 | 0 | + +The native rolling UI sampler reported 59.94-60.09 for the iOS account list and 59.65-60.92 for Android. The iOS wallet-sidebar sample included one rolling reading near 58. These counters have sampling variation and are not direct panel-presentation evidence. Android single-network scrolling had one 25.08 ms JS interval; it is retained in the table. + +The Desktop account row above is a warm steady sample. Fresh-wallet scrolling had one 33.4 ms interval in a separate traced sample. Its renderer thread spent about 29.4 ms across consecutive React updates; PNG decode work was on other threads. The source of those business-state updates has not been fully identified. + +### Cold opening and payload size + +| Target | Before: maximum rAF interval (ms) | URI: maximum rAF interval (ms) | Before: maximum JS long task (ms) | URI: maximum JS long task (ms) | +| --- | ---: | ---: | ---: | ---: | +| ios | 7719.55 | 719.31 | 7206.29 | 154.24 | +| android | 6562.36 | 233.28 | 6561.96 | 233.15 | +| desktop | Not a valid cold baseline | 151.10 | Not a valid cold baseline | 110.00 | + +The old approximately 7.2 s iOS result is an entire JS long task that included eager avatar generation, not an isolated PNG-encoding measurement. An earlier approximately 1 s sample had warmed the old avatar cache and is excluded as a cold baseline. The 719.31 ms new iOS interval spans multiple tasks; its largest individual long task is 154.24 ms. Cold avatar caches were checked separately; these are page observations, not a claim that every unrelated application cache/heap was identical. + +| 1000-account payload | Before | URI | Reduction | +| --- | ---: | ---: | ---: | +| Native account snapshot, including the add-account row | 22,847,723 bytes | 736,723 bytes | 96.8% | +| Avatar visual JSON only | 22,389,001 bytes | 302,001 bytes | 98.7% | + +A same-code host V8 descriptor-construction comparison measured approximately 622 ms versus 0.45 ms. This is a host microbenchmark, not phone page time. Non-image descriptor fields were compared and remained identical. + +### Avatar pixels, persistence, and visibility + +| Check | Observed result | Boundary | +| --- | --- | --- | +| iOS generator golden images | 1014 seeds, zero RGBA pixel differences from V1 | Host generator and PNG decoding | +| Android generator golden images | 1227 seeds, zero RGBA pixel differences | Host JVM, independent PNG decoding | +| Actual Chrome Worker golden images | 1011 seeds / 16,564,224 pixels, zero differences | Worker-generated PNGs compared against V1 | +| Device PNGs | 20 iOS and 20 Android cached avatars, zero pixel differences | Same deterministic seeds | +| iOS process restart | All 84 prior avatar-cache files retained identical content, size, mtime, and inode | Proves file reuse/no rewrite; does not directly count generator calls | +| Android process restart | All 20 inspected prior avatar files retained identical content, size, mtime, and inode | Exact synthetic cache keys only | +| Desktop new document and Worker | 18 persisted PNG validations, 18 Blob URL responses, zero PNG encodes | Document/Worker restart, not full Electron process restart | +| Desktop displayed avatar raster | 18 images, 1024 x 1042 region, zero different pixels | Same frozen V2 layout with URI images versus original V1 PNGs; not a substitute for whole-page V1/V2 comparison | +| iOS real finger scrolling | 492 decoded frames / 5164 fully visible account-row observations; no whole-avatar disappearance | Variable-rate recording; unrecorded transient frames remain outside coverage | +| Android real finger scrolling | 261 decoded frames / 2876 complete row observations; no missing, misplaced, or wrong-account avatars | Clipped edge rows excluded; H264 tolerance used for color matching | + +A separate Desktop test scrolls four legs at 6500 px/s and samples visible DOM/image states every rAF. Across 682 sampled frames, already-ready-to-unready transitions, missing images on return, internal row gaps, and image errors were all zero. However, 82 newly encountered accounts had first-load waiting: 377 of 398 unready observations had no URI yet. Sixty-six visible waiting episodes ended by leaving the viewport. The approximately 101 ms stage P95 is therefore not a guaranteed time-to-ready. DOM observations cannot separate queueing, generation, disk writes, and message delivery. Geometry sampling adds overhead, so this test is not used for an FPS claim. + +### UI and correctness coverage + +- The original V1 implementations and route identifiers remain. Prior whole-page V1/V2 comparisons used matching QA data/theme/device, not user-provided screenshots. Native text, antialiasing, and rounded-edge differences are documented separately; whole-page PNG byte equality is not claimed. +- Prior migration acceptance covered account selection/search/menu/add, wallet/group interactions, network search/selection/Apply, and supported custom-network flows. This avatar round does not claim to rerun all hardware transport, address creation, or transaction paths. +- The final iOS binary passed all-network index dragging in both directions and single-network index dragging. The modal remained open and the section changed. Ordinary header dragging closed the modal (navigation root index returned to zero). +- Android fixture account values of `$0.00` were traced to Ethereum being disabled in that QA network configuration. The fixture contributes only an Ethereum value; V1/V2 retain the same enabled-network aggregation rules. +- Focused cache/lifecycle checks cover duplicate requests, cancellation, row reuse, stale callbacks, invalid/corrupted cache entries, active URL retention, Worker recovery, and Web patches arriving before mount. Strict module type checks and the Android host Kotlin/JUnit suite passed. +- Both final native apps were built locally and their installed artifacts matched the frozen patch/native contract. The final pre-publication commit profile passed eight local gates. Hosted CI/review status is tracked separately by the PR. + +Extension packaging/CSP source review found no definite external-Worker or Blob-image blocker. Published Web code was tested under a strict same-origin Worker CSP and actual Electron file loading with production security settings. **An actual installed browser-extension runtime has not been tested.** + +### Reproduction and evidence provenance + +1. Build the pinned 3.0.105 module set with the repository patches. Use an isolated QA wallet/profile. +2. Supply deterministic wallet/account metadata through the existing account-selector service interface. Keep logical size, the three-wallet LRU, and enabled networks fixed between comparisons. Do not persist the million-account fixture. +3. Check that the avatar cache lacks the tested seed keys. Open the unchanged account-selector route and collect rAF/long-task data. Record the serialized snapshot size separately. +4. Wait for the intended list and images to be ready before steady scrolling. Collect warm and first-visit samples separately; avoid concurrent builds and accessibility snapshots during performance sampling. +5. Restart the native process, or the Desktop document/Worker, without clearing persistent storage. Reopen the same seeds and compare exact cache files or Worker encode counters. +6. Capture real gestures independently. Compare V1/V2 under identical visual state, and distinguish first-load waiting from an already-rendered image disappearing. + +Measurements were captured on application parent `b5b152a1adcccde687e6fbb3b437dec0afb17da4` plus the selector changes and the two exact patches below. Raw traces, recordings, screenshots, QA credentials, and native build products remain local and are not committed. Local evidence includes the named steady/cold JSON samples, cache restart manifests, golden-pixel reports, and frame-visibility reports. The tables above are the portable result record. + +| Patch | SHA-256 | +| --- | --- | +| `@onekeyfe+react-native-native-list+3.0.105.patch` | `8077a93a271f44f8a23fa15c1315ff01b7b7b34990cbc5d3cf33b214445cb3e6` | +| `@onekeyfe+react-native-image+3.0.105.patch` | `323181a9086abfc26a33e531333eeb8b341c765600128871bbcb759e24f36f08` | + +Pristine patch replay matched 203 NativeList and 195 native-image source files with zero build artifacts. Both native builds reported a web-embed OCI HTTP 404 and succeeded after local-build fallback; these runs were not clean remote-cache hits. + +At this earlier URI-only baseline, open items were cold-page initialization, first-visit avatar waiting at extreme speeds, extension runtime and Release/high-refresh coverage. The follow-up above records the current results and remaining failures. diff --git a/package.json b/package.json index 8dd4f7c1f6ef..f0ab2108a893 100644 --- a/package.json +++ b/package.json @@ -249,7 +249,7 @@ "react-native": "0.86.2", "react-native-confirmation-code-field": "9.0.0", "react-native-draggable-flatlist": "4.0.3", - "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.104", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.107", "react-native-reanimated": "4.5.1", "react-native-screens": "~4.26.0", "react-native-web": "0.21.2", @@ -524,7 +524,7 @@ "react-native-reanimated": "4.5.1", "react-native-worklets": "0.10.1", "react-native-screens": "4.26.0", - "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.104", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.107", "@onekeyfe/react-native-ble-utils": "0.1.6", "@isaacs/brace-expansion": "5.0.1", "minimatch@^10.2.2": "10.2.6", diff --git a/packages/components/package.json b/packages/components/package.json index 80290bebc599..a599235d1262 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -7,9 +7,9 @@ "**/*.css" ], "dependencies": { - "@onekeyfe/react-native-scroll-guard": "3.0.104", - "@onekeyfe/react-native-segment-slider": "3.0.104", - "@onekeyfe/react-native-tab-view": "3.0.104", + "@onekeyfe/react-native-scroll-guard": "3.0.107", + "@onekeyfe/react-native-segment-slider": "3.0.107", + "@onekeyfe/react-native-tab-view": "3.0.107", "@react-native-masked-view/masked-view": "0.3.2", "@react-navigation/bottom-tabs": "7.10.1", "@react-navigation/elements": "2.9.5", diff --git a/packages/components/src/actions/ActionList/ActionList.stories.tsx b/packages/components/src/actions/ActionList/ActionList.stories.tsx index 051f016e963d..8721a83c697d 100644 --- a/packages/components/src/actions/ActionList/ActionList.stories.tsx +++ b/packages/components/src/actions/ActionList/ActionList.stories.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { fn } from 'storybook/test'; @@ -8,7 +8,7 @@ import { Button } from '@onekeyhq/components/src/primitives/Button'; import { XStack } from '@onekeyhq/components/src/primitives/Stack'; import type { Meta, StoryObj } from '@storybook/react-native-web-vite'; -import type { GestureResponderEvent } from 'react-native'; +import type { GestureResponderEvent, View } from 'react-native'; const ACCOUNT_ITEMS: IActionListProps['items'] = [ { label: 'Rename', icon: 'PencilOutline', onPress: fn() }, @@ -79,6 +79,24 @@ function ContextMenuTrigger({ label, ...listProps }: IContextMenuTriggerProps) { ); } +function TriggerRectMenu({ label, ...listProps }: IContextMenuTriggerProps) { + const triggerRef = useRef(null); + const handlePress = useCallback(() => { + triggerRef.current?.measureInWindow((x, y, width, height) => { + ActionList.show({ + ...listProps, + triggerRect: { x, y, width, height }, + }); + }); + }, [listProps]); + + return ( + + + + ); +} + const meta = { title: 'Actions/ActionList', component: ActionList, @@ -118,3 +136,9 @@ export const WithSections: Story = { export const ImperativeShow: Story = { render: (args) => , }; + +export const ImperativeShowFromRect: Story = { + render: (args) => ( + + ), +}; diff --git a/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts b/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts new file mode 100644 index 000000000000..f095f162e664 --- /dev/null +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts @@ -0,0 +1,161 @@ +import { + createImperativeActionListLifecycle, + getImperativeActionListPlacement, + getImperativeActionListProxyGeometry, + preventImperativeActionListCloseAutoFocus, +} from './imperativeShowUtils'; + +describe('imperative ActionList geometry', () => { + it('preserves the point-trigger proxy contract', () => { + expect( + getImperativeActionListProxyGeometry({ + triggerPosition: { x: 120, y: 80 }, + }), + ).toEqual({ + left: 120, + top: 80, + containerWidth: 0, + containerHeight: 0, + triggerWidth: 1, + triggerHeight: 1, + }); + }); + + it('preserves point-trigger edge placement', () => { + expect( + getImperativeActionListPlacement({ + triggerPosition: { x: 700, y: 400 }, + windowWidth: 800, + windowHeight: 600, + isRTL: false, + }), + ).toBe('top-end'); + }); + + it('uses the complete rectangle for the fixed trigger proxy', () => { + expect( + getImperativeActionListProxyGeometry({ + triggerRect: { x: 120, y: 80, width: 44, height: 32 }, + }), + ).toEqual({ + left: 120, + top: 80, + containerWidth: 44, + containerHeight: 32, + triggerWidth: 44, + triggerHeight: 32, + }); + }); + + it('uses the rectangle bottom edge to choose top placement', () => { + expect( + getImperativeActionListPlacement({ + triggerRect: { x: 100, y: 360, width: 48, height: 40 }, + windowWidth: 800, + windowHeight: 600, + isRTL: false, + }), + ).toBe('top-start'); + }); + + it('uses the rectangle left edge to avoid the right viewport edge in LTR', () => { + expect( + getImperativeActionListPlacement({ + triggerRect: { x: 700, y: 100, width: 40, height: 40 }, + windowWidth: 800, + windowHeight: 600, + isRTL: false, + }), + ).toBe('bottom-end'); + }); + + it('mirrors logical start placement in RTL', () => { + expect( + getImperativeActionListPlacement({ + triggerRect: { x: 700, y: 100, width: 40, height: 40 }, + windowWidth: 800, + windowHeight: 600, + isRTL: true, + }), + ).toBe('bottom-start'); + + expect( + getImperativeActionListPlacement({ + triggerRect: { x: 40, y: 100, width: 40, height: 40 }, + windowWidth: 800, + windowHeight: 600, + isRTL: true, + }), + ).toBe('bottom-end'); + }); +}); + +describe('imperative ActionList lifecycle', () => { + it.each([false, true])( + 'removes a replaced overlay immediately (already closing: %s)', + (alreadyClosing) => { + const onOpenChange = jest.fn(); + const onClose = jest.fn(); + const destroy = jest.fn(); + const scheduled: Array<() => void> = []; + const lifecycle = createImperativeActionListLifecycle({ + onOpenChange, + onClose, + destroy, + schedule: (callback) => scheduled.push(callback), + }); + + if (alreadyClosing) lifecycle.close(); + lifecycle.closeImmediately(); + + expect(destroy).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + lifecycle.closeImmediately(); + lifecycle.close(); + scheduled.forEach((callback) => callback()); + + expect(destroy).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }, + ); + + it('prevents the focus scope from restoring focus to the proxy trigger', () => { + const event = { preventDefault: jest.fn() }; + + preventImperativeActionListCloseAutoFocus(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('closes and destroys once while preserving the close animation delay', () => { + const onOpenChange = jest.fn(); + const onClose = jest.fn(); + const destroy = jest.fn(); + const scheduled: Array<{ callback: () => void; delay: number }> = []; + const lifecycle = createImperativeActionListLifecycle({ + onOpenChange, + onClose, + destroy, + schedule: (callback, delay) => scheduled.push({ callback, delay }), + }); + + lifecycle.close(); + lifecycle.close(); + lifecycle.handleOpenChange(false); + lifecycle.handleOpenChange(true); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(scheduled.map(({ delay }) => delay)).toEqual([0, 500]); + + for (const { callback } of scheduled) { + callback(); + } + + expect(onClose).toHaveBeenCalledTimes(1); + expect(destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/src/actions/ActionList/imperativeShowUtils.ts b/packages/components/src/actions/ActionList/imperativeShowUtils.ts new file mode 100644 index 000000000000..783d9d537032 --- /dev/null +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.ts @@ -0,0 +1,138 @@ +export type IActionListTriggerPosition = { + x: number; + y: number; +}; + +export type IActionListTriggerRect = IActionListTriggerPosition & { + width: number; + height: number; +}; + +export type IActionListPlacement = + | 'bottom-start' + | 'bottom-end' + | 'top-start' + | 'top-end'; + +const ESTIMATED_MENU_HEIGHT = 200; +const ESTIMATED_MENU_WIDTH = 224; // $56 = 56 * 4 = 224px +const EDGE_PADDING = 8; +const CLOSE_ANIMATION_DURATION = 500; + +export function preventImperativeActionListCloseAutoFocus(event: { + preventDefault: () => void; +}) { + event.preventDefault(); +} + +export function createImperativeActionListLifecycle({ + onOpenChange, + onClose, + destroy, + schedule = (callback, delay) => { + setTimeout(callback, delay); + }, +}: { + onOpenChange?: (isOpen: boolean) => void; + onClose?: () => void; + destroy: () => void; + schedule?: (callback: () => void, delay: number) => void; +}) { + let isClosed = false; + let isDestroyed = false; + + const destroyOnce = () => { + if (isDestroyed) return; + isDestroyed = true; + destroy(); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (isClosed) { + return; + } + if (isOpen) { + onOpenChange?.(true); + return; + } + + isClosed = true; + onOpenChange?.(false); + if (onClose) { + schedule(onClose, 0); + } + schedule(destroyOnce, CLOSE_ANIMATION_DURATION); + }; + + return { + handleOpenChange, + close: () => handleOpenChange(false), + closeImmediately: () => { + handleOpenChange(false); + destroyOnce(); + }, + }; +} + +export function getImperativeActionListPlacement({ + triggerPosition, + triggerRect, + windowWidth, + windowHeight, + isRTL, +}: { + triggerPosition?: IActionListTriggerPosition; + triggerRect?: IActionListTriggerRect; + windowWidth: number; + windowHeight: number; + isRTL: boolean; +}): IActionListPlacement { + const left = triggerRect?.x ?? triggerPosition?.x ?? 0; + const right = triggerRect ? triggerRect.x + triggerRect.width : left; + const bottom = triggerRect + ? triggerRect.y + triggerRect.height + : (triggerPosition?.y ?? 0); + + const spaceBelow = windowHeight - bottom - EDGE_PADDING; + const spaceForStartAlignment = isRTL + ? right - EDGE_PADDING + : windowWidth - left - EDGE_PADDING; + + const vertical = spaceBelow >= ESTIMATED_MENU_HEIGHT ? 'bottom' : 'top'; + const horizontal = + spaceForStartAlignment >= ESTIMATED_MENU_WIDTH ? 'start' : 'end'; + + return `${vertical}-${horizontal}` as IActionListPlacement; +} + +export function getImperativeActionListProxyGeometry({ + triggerPosition, + triggerRect, +}: { + triggerPosition?: IActionListTriggerPosition; + triggerRect?: IActionListTriggerRect; +}) { + if (triggerRect) { + return { + left: triggerRect.x, + top: triggerRect.y, + containerWidth: triggerRect.width, + containerHeight: triggerRect.height, + triggerWidth: triggerRect.width, + triggerHeight: triggerRect.height, + }; + } + + if (triggerPosition) { + return { + left: triggerPosition.x, + top: triggerPosition.y, + containerWidth: 0, + containerHeight: 0, + triggerWidth: 1, + triggerHeight: 1, + }; + } + + return undefined; +} diff --git a/packages/components/src/actions/ActionList/index.native.tsx b/packages/components/src/actions/ActionList/index.native.tsx index 7173556d3816..e04710a6eaf5 100644 --- a/packages/components/src/actions/ActionList/index.native.tsx +++ b/packages/components/src/actions/ActionList/index.native.tsx @@ -2,7 +2,11 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Dimensions, type GestureResponderEvent } from 'react-native'; +import { + Dimensions, + type GestureResponderEvent, + I18nManager, +} from 'react-native'; import { useDebouncedCallback } from 'use-debounce'; import { useMedia } from '@onekeyhq/components/src/hooks/useStyle'; @@ -36,12 +40,28 @@ import { LazyPopover } from '../LazyPopover'; import { Shortcut } from '../Shortcut'; import { Trigger } from '../Trigger'; +import { + createImperativeActionListLifecycle, + getImperativeActionListPlacement, + getImperativeActionListProxyGeometry, + preventImperativeActionListCloseAutoFocus, +} from './imperativeShowUtils'; import { useAsyncItemsLifecycle } from './useAsyncItemsLifecycle'; import type { IActionListRenderItemsAsync } from './asyncItemsLifecycleTypes'; +import type { + IActionListPlacement, + IActionListTriggerPosition, + IActionListTriggerRect, +} from './imperativeShowUtils'; import type { IIconProps, IKeyOfIcons } from '../../primitives'; import type { IPopoverProps } from '../LazyPopover'; +export type { + IActionListTriggerPosition, + IActionListTriggerRect, +} from './imperativeShowUtils'; + export interface IActionListItemProps { icon?: IKeyOfIcons; iconProps?: IIconProps; @@ -463,12 +483,17 @@ function BasicActionList({ ); } -type IShowActionListParams = Omit< +export type IActionListShowHandle = { + close: () => void; +}; + +export type IShowActionListParams = Omit< IActionListProps, 'renderTrigger' | 'defaultOpen' > & { onClose?: () => void; - triggerPosition?: { x: number; y: number }; + triggerPosition?: IActionListTriggerPosition; + triggerRect?: IActionListTriggerRect; }; const showActionList = ( props: IShowActionListParams, @@ -478,71 +503,70 @@ const showActionList = ( pageContextValue?: ReturnType; } | undefined, -) => { +): IActionListShowHandle & { closeImmediately: () => void } => { const { modalNavigatorContext, pageContextValue } = contexts || {}; - const { triggerPosition, ...restProps } = props; + const { onClose, triggerPosition, triggerRect, ...restProps } = props; dismissKeyboard(); + const proxyGeometry = + !platformEnv.isNative && (triggerRect || triggerPosition) + ? getImperativeActionListProxyGeometry({ + triggerPosition, + triggerRect, + }) + : undefined; + // eslint-disable-next-line react-perf/jsx-no-jsx-as-prop - const triggerElement = - triggerPosition && !platformEnv.isNative ? ( - - ) : null; + const triggerElement = proxyGeometry ? ( + + ) : null; // Use let so the destroy callback can reference it after assignment // eslint-disable-next-line prefer-const - let ref: { destroy: () => void }; - let isClosed = false; - - // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop - const handleOpenChange = (isOpen: boolean) => { - if (isOpen) { - restProps.onOpenChange?.(true); - return; - } - if (isClosed) { - return; - } - isClosed = true; - restProps.onOpenChange?.(false); - setTimeout(() => { - restProps.onClose?.(); - }); - // delay the destruction of the reference to allow for the completion of the animation transition. - setTimeout(() => { - ref.destroy(); - }, 500); - }; + let ref: { destroy: () => void } | undefined; + const lifecycle = createImperativeActionListLifecycle({ + onOpenChange: restProps.onOpenChange, + onClose, + // Delay destruction so the close animation can finish. + destroy: () => ref?.destroy(), + }); // For context menu positioning: compute the optimal placement direction // based on viewport boundaries, like native OS context menus that flip // at screen edges. Keep allowFlip disabled to prevent Floating UI from // recalculating placement during close animation. - let contextMenuPlacement: - | 'bottom-start' - | 'bottom-end' - | 'top-start' - | 'top-end' = 'bottom-start'; - if (triggerPosition && !platformEnv.isNative) { + let contextMenuPlacement: IActionListPlacement = 'bottom-start'; + if (proxyGeometry) { const { height: windowHeight, width: windowWidth } = Dimensions.get('window'); - const ESTIMATED_MENU_HEIGHT = 200; - const ESTIMATED_MENU_WIDTH = 224; // $56 = 56 * 4 = 224px - const EDGE_PADDING = 8; - - const spaceBelow = windowHeight - triggerPosition.y - EDGE_PADDING; - const spaceRight = windowWidth - triggerPosition.x - EDGE_PADDING; - - const vertical = spaceBelow >= ESTIMATED_MENU_HEIGHT ? 'bottom' : 'top'; - const horizontal = spaceRight >= ESTIMATED_MENU_WIDTH ? 'start' : 'end'; - contextMenuPlacement = - `${vertical}-${horizontal}` as typeof contextMenuPlacement; + contextMenuPlacement = getImperativeActionListPlacement({ + triggerPosition, + triggerRect, + windowWidth, + windowHeight, + isRTL: I18nManager.isRTL, + }); } - const contextMenuProps = - triggerPosition && !platformEnv.isNative - ? { placement: contextMenuPlacement, allowFlip: false } - : {}; + const contextMenuProps = proxyGeometry + ? { + placement: contextMenuPlacement, + allowFlip: false, + ...(triggerRect + ? { + floatingPanelProps: { + ...ACTION_LIST_FLOATING_PANEL_PROPS, + ...restProps.floatingPanelProps, + onCloseAutoFocus: preventImperativeActionListCloseAutoFocus, + }, + } + : undefined), + } + : {}; const actionList = ( ); - // Wrap in a fixed-position container at cursor coordinates for context menu positioning - const content = - triggerPosition && !platformEnv.isNative ? ( - - {actionList} - - ) : ( - actionList - ); + // Wrap in a fixed-position container at the point or rectangle coordinates. + const content = proxyGeometry ? ( + + {actionList} + + ) : ( + actionList + ); const modalCtxValue = modalNavigatorContext || FALLBACK_MODAL_NAVIGATOR_CONTEXT; @@ -587,10 +611,8 @@ const showActionList = ( , ); return { - close: () => { - handleOpenChange(false); - ref.destroy(); - }, + close: lifecycle.close, + closeImmediately: lifecycle.closeImmediately, }; }; function ActionListFrame(props: IActionListProps) { @@ -632,9 +654,10 @@ function ActionListFrame(props: IActionListProps) { // Imperative action lists share one overlay slot; newer calls replace the active one. let imperativeActionList: ReturnType | undefined; -const show = (props: IShowActionListParams) => { - imperativeActionList?.close(); +const show = (props: IShowActionListParams): IActionListShowHandle => { + imperativeActionList?.closeImmediately(); imperativeActionList = showActionList(props, undefined); + return imperativeActionList; }; export const ActionList = withStaticProperties(ActionListFrame, { diff --git a/packages/components/src/actions/ActionList/index.tsx b/packages/components/src/actions/ActionList/index.tsx index b941ed1aba9d..ed2760121168 100644 --- a/packages/components/src/actions/ActionList/index.tsx +++ b/packages/components/src/actions/ActionList/index.tsx @@ -2,7 +2,11 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Dimensions, type GestureResponderEvent } from 'react-native'; +import { + Dimensions, + type GestureResponderEvent, + I18nManager, +} from 'react-native'; import { useDebouncedCallback } from 'use-debounce'; import { useMedia } from '@onekeyhq/components/src/hooks/useStyle'; @@ -36,9 +40,26 @@ import { LazyPopover } from '../LazyPopover'; import { Shortcut } from '../Shortcut'; import { Trigger } from '../Trigger'; +import { + createImperativeActionListLifecycle, + getImperativeActionListPlacement, + getImperativeActionListProxyGeometry, + preventImperativeActionListCloseAutoFocus, +} from './imperativeShowUtils'; + +import type { + IActionListPlacement, + IActionListTriggerPosition, + IActionListTriggerRect, +} from './imperativeShowUtils'; import type { IIconProps, IKeyOfIcons } from '../../primitives'; import type { IPopoverProps } from '../LazyPopover'; +export type { + IActionListTriggerPosition, + IActionListTriggerRect, +} from './imperativeShowUtils'; + export interface IActionListItemProps { icon?: IKeyOfIcons; iconProps?: IIconProps; @@ -452,12 +473,17 @@ function BasicActionList({ ); } -type IShowActionListParams = Omit< +export type IActionListShowHandle = { + close: () => void; +}; + +export type IShowActionListParams = Omit< IActionListProps, 'renderTrigger' | 'defaultOpen' > & { onClose?: () => void; - triggerPosition?: { x: number; y: number }; + triggerPosition?: IActionListTriggerPosition; + triggerRect?: IActionListTriggerRect; }; const showActionList = ( props: IShowActionListParams, @@ -467,71 +493,70 @@ const showActionList = ( pageContextValue?: ReturnType; } | undefined, -) => { +): IActionListShowHandle & { closeImmediately: () => void } => { const { modalNavigatorContext, pageContextValue } = contexts || {}; - const { triggerPosition, ...restProps } = props; + const { onClose, triggerPosition, triggerRect, ...restProps } = props; dismissKeyboard(); + const proxyGeometry = + !platformEnv.isNative && (triggerRect || triggerPosition) + ? getImperativeActionListProxyGeometry({ + triggerPosition, + triggerRect, + }) + : undefined; + // eslint-disable-next-line react-perf/jsx-no-jsx-as-prop - const triggerElement = - triggerPosition && !platformEnv.isNative ? ( - - ) : null; + const triggerElement = proxyGeometry ? ( + + ) : null; // Use let so the destroy callback can reference it after assignment // eslint-disable-next-line prefer-const - let ref: { destroy: () => void }; - let isClosed = false; - - // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop - const handleOpenChange = (isOpen: boolean) => { - if (isOpen) { - restProps.onOpenChange?.(true); - return; - } - if (isClosed) { - return; - } - isClosed = true; - restProps.onOpenChange?.(false); - setTimeout(() => { - restProps.onClose?.(); - }); - // delay the destruction of the reference to allow for the completion of the animation transition. - setTimeout(() => { - ref.destroy(); - }, 500); - }; + let ref: { destroy: () => void } | undefined; + const lifecycle = createImperativeActionListLifecycle({ + onOpenChange: restProps.onOpenChange, + onClose, + // Delay destruction so the close animation can finish. + destroy: () => ref?.destroy(), + }); // For context menu positioning: compute the optimal placement direction // based on viewport boundaries, like native OS context menus that flip // at screen edges. Keep allowFlip disabled to prevent Floating UI from // recalculating placement during close animation. - let contextMenuPlacement: - | 'bottom-start' - | 'bottom-end' - | 'top-start' - | 'top-end' = 'bottom-start'; - if (triggerPosition && !platformEnv.isNative) { + let contextMenuPlacement: IActionListPlacement = 'bottom-start'; + if (proxyGeometry) { const { height: windowHeight, width: windowWidth } = Dimensions.get('window'); - const ESTIMATED_MENU_HEIGHT = 200; - const ESTIMATED_MENU_WIDTH = 224; // $56 = 56 * 4 = 224px - const EDGE_PADDING = 8; - - const spaceBelow = windowHeight - triggerPosition.y - EDGE_PADDING; - const spaceRight = windowWidth - triggerPosition.x - EDGE_PADDING; - - const vertical = spaceBelow >= ESTIMATED_MENU_HEIGHT ? 'bottom' : 'top'; - const horizontal = spaceRight >= ESTIMATED_MENU_WIDTH ? 'start' : 'end'; - contextMenuPlacement = - `${vertical}-${horizontal}` as typeof contextMenuPlacement; + contextMenuPlacement = getImperativeActionListPlacement({ + triggerPosition, + triggerRect, + windowWidth, + windowHeight, + isRTL: I18nManager.isRTL, + }); } - const contextMenuProps = - triggerPosition && !platformEnv.isNative - ? { placement: contextMenuPlacement, allowFlip: false } - : {}; + const contextMenuProps = proxyGeometry + ? { + placement: contextMenuPlacement, + allowFlip: false, + ...(triggerRect + ? { + floatingPanelProps: { + ...ACTION_LIST_FLOATING_PANEL_PROPS, + ...restProps.floatingPanelProps, + onCloseAutoFocus: preventImperativeActionListCloseAutoFocus, + }, + } + : undefined), + } + : {}; const actionList = ( ); - // Wrap in a fixed-position container at cursor coordinates for context menu positioning - const content = - triggerPosition && !platformEnv.isNative ? ( - - {actionList} - - ) : ( - actionList - ); + // Wrap in a fixed-position container at the point or rectangle coordinates. + const content = proxyGeometry ? ( + + {actionList} + + ) : ( + actionList + ); const modalCtxValue = modalNavigatorContext || FALLBACK_MODAL_NAVIGATOR_CONTEXT; @@ -576,10 +601,8 @@ const showActionList = ( , ); return { - close: () => { - handleOpenChange(false); - ref.destroy(); - }, + close: lifecycle.close, + closeImmediately: lifecycle.closeImmediately, }; }; function ActionListFrame(props: IActionListProps) { @@ -621,9 +644,10 @@ function ActionListFrame(props: IActionListProps) { // Imperative action lists share one overlay slot; newer calls replace the active one. let imperativeActionList: ReturnType | undefined; -const show = (props: IShowActionListParams) => { - imperativeActionList?.close(); +const show = (props: IShowActionListParams): IActionListShowHandle => { + imperativeActionList?.closeImmediately(); imperativeActionList = showActionList(props, undefined); + return imperativeActionList; }; export const ActionList = withStaticProperties(ActionListFrame, { diff --git a/packages/components/src/primitives/Image/ImageV2.native.test.tsx b/packages/components/src/primitives/Image/ImageV2.native.test.tsx new file mode 100644 index 000000000000..ebee2cbc1d4c --- /dev/null +++ b/packages/components/src/primitives/Image/ImageV2.native.test.tsx @@ -0,0 +1,75 @@ +/** + * @jest-environment jsdom + */ +import { render } from '@testing-library/react'; + +import { ImageV2 } from './ImageV2.native'; + +import type { OneKeyImageProps } from '@onekeyfe/react-native-image'; + +type INativeImageProps = Pick< + OneKeyImageProps, + 'source' | 'style' | 'optimizeTos' +>; + +const mockNativeImage = jest.fn(() => null); +const privateSource = { + uri: 'https://uni.onekey-asset.com/private.png', + headers: { Authorization: 'test' }, +}; + +jest.mock('react-native', () => ({ + Image: { resolveAssetSource: jest.fn() }, + PixelRatio: { get: () => 3 }, + Platform: { OS: 'ios' }, + StyleSheet: { + create: (styles: object) => styles, + flatten: (styles: object[]): object => + styles.reduce((result, style) => ({ ...result, ...style }), {}), + }, + View: 'div', +})); + +jest.mock('@onekeyfe/react-native-image', () => ({ + OneKeyImage: (props: INativeImageProps) => mockNativeImage(props), + OneKeyImageCachePolicy: { MEMORY_DISK: 'memory-disk' }, + OneKeyImageContentFit: { COVER: 'cover' }, + OneKeyImageLoadingStrategy: { SKELETON: 'skeleton' }, +})); + +jest.mock('@onekeyhq/components/src/shared/tamagui', () => ({ + usePropsAndStyle: (props: object) => [props, { width: 40, height: 40 }], +})); + +describe('native ImageV2 rendition ownership', () => { + beforeEach(() => mockNativeImage.mockClear()); + + it('keeps the original URL and layout while forwarding resize hints', () => { + const uri = 'https://uni.onekey-asset.com/token.png'; + const { rerender } = render(); + expect(mockNativeImage).toHaveBeenLastCalledWith( + expect.objectContaining({ + source: { uri }, + style: { width: 40, height: 40 }, + resizeWidth: 32, + optimizeTos: true, + }), + ); + rerender(); + expect(mockNativeImage).toHaveBeenLastCalledWith( + expect.objectContaining({ + source: { uri }, + style: { width: 40, height: 40 }, + resizeWidth: 64, + optimizeTos: true, + }), + ); + }); + + it('preserves custom request identity without enabling URL rewriting', () => { + render(); + expect(mockNativeImage).toHaveBeenLastCalledWith( + expect.objectContaining({ source: privateSource, optimizeTos: false }), + ); + }); +}); diff --git a/packages/components/src/primitives/Image/ImageV2.native.tsx b/packages/components/src/primitives/Image/ImageV2.native.tsx index 136071f65880..1adbab85f477 100644 --- a/packages/components/src/primitives/Image/ImageV2.native.tsx +++ b/packages/components/src/primitives/Image/ImageV2.native.tsx @@ -22,10 +22,7 @@ import { import { usePropsAndStyle } from '@onekeyhq/components/src/shared/tamagui'; import { ANDROID_PACKAGE_NAME } from '@onekeyhq/shared/src/config/appConfig'; -import { - buildOptimizedImageSource, - hasCustomSourceIdentity, -} from './optimization'; +import { hasCustomSourceIdentity } from './optimization'; import type { IImageCachePolicy, @@ -217,7 +214,6 @@ export function ImageV2({ style: defaultStyle, ...props }: IImageV2Props) { contentFit, cachePolicy, recyclingKey, - resizeWidth, retryTimes = 1, canRetry = true, blurRadius: _blurRadius, @@ -240,39 +236,8 @@ export function ImageV2({ style: defaultStyle, ...props }: IImageV2Props) { () => normalizeSource(rawSource, style.width, style.height), [rawSource, style.height, style.width], ); - const optimizedSourceResult = useMemo( - () => - buildOptimizedImageSource({ - source: rawSource, - resolvedSource: normalizedSource, - resizeWidth, - width: [style.width, sizeProps?.width, props.width, props.w], - height: [style.height, sizeProps?.height, props.height, props.h], - }), - [ - normalizedSource, - props.h, - props.height, - props.w, - props.width, - rawSource, - resizeWidth, - sizeProps?.height, - sizeProps?.width, - style.height, - style.width, - ], - ); - const [rawSourceFallbackUri, setRawSourceFallbackUri] = useState< - string | undefined - >(); - const shouldUseRawSourceFallback = - optimizedSourceResult.optimized && - Boolean(optimizedSourceResult.rawUri) && - rawSourceFallbackUri === optimizedSourceResult.rawUri; - const activeSource = shouldUseRawSourceFallback - ? optimizedSourceResult.rawSource - : optimizedSourceResult.source; + // Native owns rendition selection and the optimized-to-original fallback. + const activeSource = normalizedSource; const [retryNonce, setRetryNonce] = useState(0); const retryCountRef = useRef(0); const retryTimerRef = useRef | null>(null); @@ -338,26 +303,12 @@ export function ImageV2({ style: defaultStyle, ...props }: IImageV2Props) { ); const handleError = useCallback( (event: OneKeyImageErrorEvent) => { - if ( - optimizedSourceResult.optimized && - optimizedSourceResult.rawUri && - !shouldUseRawSourceFallback - ) { - setRawSourceFallbackUri(optimizedSourceResult.rawUri); - return; - } if (scheduleRetry()) { return; } onError?.(event); }, - [ - onError, - optimizedSourceResult.optimized, - optimizedSourceResult.rawUri, - scheduleRetry, - shouldUseRawSourceFallback, - ], + [onError, scheduleRetry], ); return ( @@ -371,11 +322,7 @@ export function ImageV2({ style: defaultStyle, ...props }: IImageV2Props) { cachePolicy={cachePolicy ? CACHE_POLICIES[cachePolicy] : undefined} recyclingKey={effectiveRecyclingKey} autoplay={autoplay} - optimizeTos={ - !hasCustomSourceIdentity(rawSource) && - !optimizedSourceResult.optimized && - !shouldUseRawSourceFallback - } + optimizeTos={!hasCustomSourceIdentity(rawSource)} loadingStrategy={OneKeyImageLoadingStrategy.SKELETON} onError={handleError} onLoad={onLoad ? handleLoad : undefined} diff --git a/packages/components/src/primitives/Image/optimization.test.ts b/packages/components/src/primitives/Image/optimization.test.ts index 7f802c47e286..2a9cf97d7e48 100644 --- a/packages/components/src/primitives/Image/optimization.test.ts +++ b/packages/components/src/primitives/Image/optimization.test.ts @@ -19,7 +19,7 @@ describe('Image optimization', () => { expect(result.optimized).toBe(true); expect(result.rawSource).toBe(resolvedSource); expect(result.source?.uri).toBe( - 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_48', + 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_96', ); }); @@ -58,7 +58,7 @@ describe('Image optimization', () => { expect(result.optimized).toBe(true); expect(result.source?.uri).toBe( - 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_256', + 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_384', ); }); @@ -113,7 +113,7 @@ describe('Image optimization', () => { expect(result.optimized).toBe(true); expect(result.source?.uri).toBe( - 'https://uni.onekey-asset.com/icons/poster.png?x-tos-process=image%2Fresize%2Cw_320', + 'https://uni.onekey-asset.com/icons/poster.png?x-tos-process=image%2Fresize%2Cw_384', ); }); diff --git a/packages/components/src/primitives/Image/preload.native.ts b/packages/components/src/primitives/Image/preload.native.ts index 4765c57b64f1..4c1f13900fc1 100644 --- a/packages/components/src/primitives/Image/preload.native.ts +++ b/packages/components/src/primitives/Image/preload.native.ts @@ -2,9 +2,6 @@ import { OneKeyImageCache, OneKeyImageCachePolicy, } from '@onekeyfe/react-native-image'; -import { PixelRatio } from 'react-native'; - -import { buildTosImageResizeUrl } from '@onekeyhq/shared/src/utils/tosImageResizeUtils'; import { hasCustomSourceIdentity } from './optimization'; @@ -19,33 +16,10 @@ const CACHE_POLICIES = { type IPreloadRequest = Parameters[0][number]; -type IPreloadRequestEntry = { - optimized: boolean; - rawUri: string; - request: IPreloadRequest; -}; - const MAX_CONCURRENT_PRELOADS = 4; -async function preloadRequestWithRawFallback({ - optimized, - rawUri, - request, -}: IPreloadRequestEntry): Promise { - const success = await OneKeyImageCache.preload([request]).catch(() => false); - if (success || !optimized) { - return success; - } - return OneKeyImageCache.preload([ - { - ...request, - uri: rawUri, - }, - ]).catch(() => false); -} - async function preloadWithConcurrency( - preloadRequests: IPreloadRequestEntry[], + preloadRequests: IPreloadRequest[], ): Promise { let nextIndex = 0; let success = true; @@ -53,7 +27,7 @@ async function preloadWithConcurrency( while (nextIndex < preloadRequests.length) { const request = preloadRequests[nextIndex]; nextIndex += 1; - if (!(await preloadRequestWithRawFallback(request))) { + if (!(await OneKeyImageCache.preload([request]).catch(() => false))) { success = false; } } @@ -75,36 +49,20 @@ export const preloadImages: IPreloadImagesFunc = async (sources, options) => { .filter((source): source is typeof source & { uri: string } => Boolean(source.uri?.trim()), ) - .map((source) => { - const pixelRatio = - source.pixelRatio ?? options?.pixelRatio ?? PixelRatio.get(); - const optimizedSource = buildTosImageResizeUrl({ - uri: source.uri, - resizeWidth: source.resizeWidth, - displayWidth: source.width, - displayHeight: source.height, - pixelRatio, - enabled: source.optimize !== false && !hasCustomSourceIdentity(source), - overscanRatio: source.overscan, - }); - - return { - request: { - uri: optimizedSource.uri ?? source.uri, - headers: source.headers, - cachePolicy: source.cachePolicy - ? CACHE_POLICIES[source.cachePolicy] - : OneKeyImageCachePolicy.MEMORY_DISK, - resizeWidth: source.resizeWidth ?? source.width, - resizeHeight: source.height, - pixelRatio, - overscan: source.overscan, - optimizeTos: false, - }, - rawUri: source.uri, - optimized: optimizedSource.optimized, - }; - }); + .map((source) => ({ + // Match rendering: native chooses the rendition and handles raw fallback. + uri: source.uri.trim(), + headers: source.headers, + cachePolicy: source.cachePolicy + ? CACHE_POLICIES[source.cachePolicy] + : OneKeyImageCachePolicy.MEMORY_DISK, + resizeWidth: source.resizeWidth ?? source.width, + resizeHeight: source.height, + pixelRatio: source.pixelRatio ?? options?.pixelRatio, + overscan: source.overscan, + optimizeTos: + source.optimize !== false && !hasCustomSourceIdentity(source), + })); const success = await preloadWithConcurrency(preloadRequests); return success && !hasInvalidSource; }; diff --git a/packages/components/src/primitives/Image/preload.test.ts b/packages/components/src/primitives/Image/preload.test.ts index 55e8b2841864..2905f845ad90 100644 --- a/packages/components/src/primitives/Image/preload.test.ts +++ b/packages/components/src/primitives/Image/preload.test.ts @@ -107,81 +107,71 @@ describe('native preloadImages', () => { mockNativePreload.mockResolvedValue(true); }); - test('uses the same encoded TOS URL as the render path', async () => { + test('passes the raw URL and layout size to native rendition selection', async () => { await preloadNativeImages([ - { - uri: 'https://uni.onekey-asset.com/token.png', - resizeWidth: 32, - pixelRatio: 1, - }, + { uri: 'https://uni.onekey-asset.com/token.png', resizeWidth: 32 }, ]); - expect(mockNativePreload).toHaveBeenCalledWith([ - expect.objectContaining({ - uri: 'https://uni.onekey-asset.com/token.png?x-tos-process=image%2Fresize%2Cw_40', - optimizeTos: false, - }), - ]); - }); - - test('retries the original URLs when an optimized preload fails', async () => { - mockNativePreload.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - await expect( - preloadNativeImages([ - { - uri: 'https://uni.onekey-asset.com/token.png', - resizeWidth: 32, - pixelRatio: 1, - }, - ]), - ).resolves.toBe(true); - - expect(mockNativePreload).toHaveBeenNthCalledWith(2, [ expect.objectContaining({ uri: 'https://uni.onekey-asset.com/token.png', - optimizeTos: false, + resizeWidth: 32, + pixelRatio: undefined, + optimizeTos: true, }), ]); }); - test('only retries failed optimized entries with their original URLs', async () => { - mockNativePreload - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - - await expect( - preloadNativeImages([ + test('forwards explicit density, dimensions, and optimization opt-outs', async () => { + await preloadNativeImages( + [ { uri: 'https://uni.onekey-asset.com/a.png', - resizeWidth: 32, - pixelRatio: 1, + width: 32, + height: 64, + pixelRatio: 2.625, }, { uri: 'https://uni.onekey-asset.com/b.png', - resizeWidth: 32, - pixelRatio: 1, + resizeWidth: 40, + optimize: false, }, { - uri: 'https://example.com/c.png', - optimize: false, + uri: 'https://uni.onekey-asset.com/private.png', + resizeWidth: 32, + headers: { Authorization: 'test' }, }, - ]), - ).resolves.toBe(true); - - expect(mockNativePreload).toHaveBeenCalledTimes(4); - expect(mockNativePreload).toHaveBeenNthCalledWith(3, [ - expect.objectContaining({ uri: 'https://example.com/c.png' }), + ], + { pixelRatio: 3 }, + ); + expect(mockNativePreload).toHaveBeenNthCalledWith(1, [ + expect.objectContaining({ + resizeWidth: 32, + resizeHeight: 64, + pixelRatio: 2.625, + optimizeTos: true, + }), ]); - expect(mockNativePreload).toHaveBeenNthCalledWith(4, [ + expect(mockNativePreload).toHaveBeenNthCalledWith(2, [ + expect.objectContaining({ pixelRatio: 3, optimizeTos: false }), + ]); + expect(mockNativePreload).toHaveBeenNthCalledWith(3, [ expect.objectContaining({ - uri: 'https://uni.onekey-asset.com/b.png', + headers: { Authorization: 'test' }, + optimizeTos: false, }), ]); }); + test('reports native failure without repeating its optimized-to-raw fallback in JS', async () => { + mockNativePreload.mockResolvedValueOnce(false); + await expect( + preloadNativeImages([ + { uri: 'https://uni.onekey-asset.com/token.png', resizeWidth: 32 }, + ]), + ).resolves.toBe(false); + expect(mockNativePreload).toHaveBeenCalledTimes(1); + }); + test('returns false for blank sources while preloading valid entries', async () => { await expect( preloadNativeImages([ diff --git a/packages/kit-bg/src/services/ServiceDemo.ts b/packages/kit-bg/src/services/ServiceDemo.ts index 7ca1ca14b914..e48f2737462f 100644 --- a/packages/kit-bg/src/services/ServiceDemo.ts +++ b/packages/kit-bg/src/services/ServiceDemo.ts @@ -2,6 +2,11 @@ import { verifyMessage } from '@ethersproject/wallet'; import { random, range } from 'lodash'; import type { IEncodedTxEvm } from '@onekeyhq/core/src/chains/evm/types'; +import type { IBip39RevealableSeed } from '@onekeyhq/core/src/secret'; +import { + generateMnemonic, + mnemonicToRevealableSeed, +} from '@onekeyhq/core/src/secret'; import { backgroundClass, backgroundMethod, @@ -20,6 +25,10 @@ import { NeedOneKeyBridge, } from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; import { convertDeviceResponse } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; @@ -36,7 +45,11 @@ import { import localDb from '../dbs/local/localDb'; import { ELocalDBStoreNames } from '../dbs/local/localDBStoreNames'; -import { settingsPersistAtom } from '../states/jotai/atoms'; +import { EIndexedDBBucketNames } from '../dbs/local/types'; +import { + devSettingsPersistAtom, + settingsPersistAtom, +} from '../states/jotai/atoms'; import { vaultFactory } from '../vaults/factory'; import ServiceBase from './ServiceBase'; @@ -45,8 +58,13 @@ import type { IDBExternalAccount } from '../dbs/local/types'; import type { ITransferInfo } from '../vaults/types'; import type { AllNetworkAddressParams } from '@onekeyfe/hd-core'; +const LARGE_WALLET_DATA_ACCOUNT_COUNT = 100; +const LARGE_WALLET_DATA_ACCOUNT_BATCH_SIZE = 100; + @backgroundClass() class ServiceDemo extends ServiceBase { + private isCreatingLargeWalletData = false; + constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); } @@ -125,6 +143,153 @@ class ServiceDemo extends ServiceBase { return c; } + @backgroundMethod() + async createLargeWalletsAndAccounts({ + walletCount, + }: { + walletCount: 10 | 100; + }) { + const devSettings = await devSettingsPersistAtom.get(); + if (!devSettings.enabled) { + throw new OneKeyLocalError('Developer mode is required'); + } + if (walletCount !== 10 && walletCount !== 100) { + throw new OneKeyLocalError('Choose either 10 or 100 wallets'); + } + if (this.isCreatingLargeWalletData) { + throw new OneKeyLocalError( + 'Large wallet data creation is already running', + ); + } + + this.isCreatingLargeWalletData = true; + const startedAt = Date.now(); + let walletsCreated = 0; + let accountsCreated = 0; + let currentWalletIndex = 0; + let accountsCreatedInCurrentWallet = 0; + let password = ''; + const accountsTotal = walletCount * LARGE_WALLET_DATA_ACCOUNT_COUNT; + const emitProgress = () => { + appEventBus.emit(EAppEventBusNames.DevLargeWalletDataCreationProgress, { + isRunning: this.isCreatingLargeWalletData, + walletIndex: currentWalletIndex, + walletsCreated, + walletsTotal: walletCount, + accountsCreatedInWallet: accountsCreatedInCurrentWallet, + accountsPerWallet: LARGE_WALLET_DATA_ACCOUNT_COUNT, + accountsCreated, + accountsTotal, + }); + }; + + try { + emitProgress(); + + for (let walletIndex = 0; walletIndex < walletCount; walletIndex += 1) { + currentWalletIndex = walletIndex + 1; + accountsCreatedInCurrentWallet = 0; + let createdWalletId = ''; + let mnemonic = ''; + let revealableSeed: IBip39RevealableSeed | undefined; + try { + password = + (await this.backgroundApi.servicePassword.getCachedPassword()) || + ''; + if (!password) { + throw new OneKeyLocalError( + 'Unlock the app before creating large wallet data', + ); + } + + emitProgress(); + mnemonic = generateMnemonic(); + revealableSeed = mnemonicToRevealableSeed(mnemonic); + mnemonic = ''; + + const { wallet } = + await this.backgroundApi.serviceAccount.createHDWalletWithRevealableSeed( + { + revealableSeed, + password, + name: `Large Data HD Wallet #${walletIndex + 1}`, + isWalletBackedUp: false, + skipAddHDNextIndexedAccount: true, + }, + ); + walletsCreated += 1; + createdWalletId = wallet.id; + emitProgress(); + + for ( + let accountStartIndex = 0; + accountStartIndex < LARGE_WALLET_DATA_ACCOUNT_COUNT; + accountStartIndex += LARGE_WALLET_DATA_ACCOUNT_BATCH_SIZE + ) { + const accountIndexes = range( + accountStartIndex, + Math.min( + accountStartIndex + LARGE_WALLET_DATA_ACCOUNT_BATCH_SIZE, + LARGE_WALLET_DATA_ACCOUNT_COUNT, + ), + ); + const indexedAccounts = + await this.backgroundApi.serviceAccount.addIndexedAccount({ + walletId: wallet.id, + indexes: accountIndexes, + skipIfExists: false, + }); + accountsCreatedInCurrentWallet += indexedAccounts.length; + accountsCreated += indexedAccounts.length; + emitProgress(); + } + } finally { + password = ''; + mnemonic = ''; + if (revealableSeed) { + revealableSeed.entropyWithLangPrefixed = ''; + revealableSeed.seed = ''; + revealableSeed = undefined; + } + if (createdWalletId) { + const walletId = createdWalletId; + const nextAccountHdIndex = accountsCreatedInCurrentWallet; + await localDb.withTransaction( + EIndexedDBBucketNames.account, + async (tx) => { + await localDb.txUpdateWallet({ + tx, + walletId, + updater: (walletRecord) => { + if (!walletRecord.nextIds) { + walletRecord.nextIds = {}; + } + walletRecord.nextIds.accountHdIndex = nextAccountHdIndex; + return walletRecord; + }, + }); + }, + ); + } + } + } + } finally { + this.isCreatingLargeWalletData = false; + emitProgress(); + password = ''; + appEventBus.emit(EAppEventBusNames.WalletUpdate, undefined); + appEventBus.emit(EAppEventBusNames.AccountUpdate, undefined); + } + + return { + accountsCreated, + accountsRequested: accountsTotal, + durationMs: Date.now() - startedAt, + walletsCreated, + walletsRequested: walletCount, + }; + } + @backgroundMethodForDev() async demoDbUpdateUUID() { const c = await localDb.demoDbUpdateUUID(); diff --git a/packages/kit/package.json b/packages/kit/package.json index 38d322d8af53..4f3744f1f957 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -4,6 +4,7 @@ "private": true, "main": "src/index.tsx", "dependencies": { + "@onekeyfe/react-native-native-list": "3.0.107", "@onekeyhq/components": "*", "@types/url-parse": "^1.4.8", "date-fns": "2.30.0", diff --git a/packages/kit/src/views/AccountManagerStacks/components/AccountEdit/AccountEditButton.tsx b/packages/kit/src/views/AccountManagerStacks/components/AccountEdit/AccountEditButton.tsx index 393aec91152d..dba36175c3bb 100644 --- a/packages/kit/src/views/AccountManagerStacks/components/AccountEdit/AccountEditButton.tsx +++ b/packages/kit/src/views/AccountManagerStacks/components/AccountEdit/AccountEditButton.tsx @@ -2,7 +2,11 @@ import { memo, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { ActionList, Divider } from '@onekeyhq/components'; +import { + ActionList, + Divider, + type IActionListProps, +} from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { AccountSelectorProviderMirror } from '@onekeyhq/kit/src/components/AccountSelector'; import { ListItem } from '@onekeyhq/kit/src/components/ListItem'; @@ -27,16 +31,7 @@ import { AccountMoveToTopButton } from './AccountMoveToTopButton'; import { AccountRemoveButton } from './AccountRemoveButton'; import { AccountRenameButton } from './AccountRenameButton'; -function AccountEditButtonView({ - avatarNetworkId, - accountsCount, - indexedAccount, - firstIndexedAccount, - account, - firstAccount, - wallet, - networkId, -}: { +export interface IAccountEditButtonProps { avatarNetworkId?: string; accountsCount: number; indexedAccount?: IDBIndexedAccount; @@ -45,7 +40,23 @@ function AccountEditButtonView({ firstAccount?: IDBAccount; wallet?: IDBWallet; networkId?: string; -}) { +} + +export interface IAccountEditActionListOptions { + title: string; + renderItemsAsync: NonNullable; +} + +export function useAccountEditActionListOptions({ + avatarNetworkId, + accountsCount, + indexedAccount, + firstIndexedAccount, + account, + firstAccount, + wallet, + networkId, +}: IAccountEditButtonProps): IAccountEditActionListOptions { const intl = useIntl(); const { config } = useAccountSelectorContextData(); const name = indexedAccount?.name || account?.name || '--'; @@ -326,16 +337,25 @@ function AccountEditButtonView({ ], ); + return { + title: name, + renderItemsAsync: renderItems, + }; +} + +function AccountEditButtonView(props: IAccountEditButtonProps) { + const { title, renderItemsAsync } = useAccountEditActionListOptions(props); + return ( } - renderItemsAsync={renderItems} + renderItemsAsync={renderItemsAsync} /> ); } diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx new file mode 100644 index 000000000000..bfe10c78c64e --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx @@ -0,0 +1,85 @@ +import type { IPageScreenProps } from '@onekeyhq/components'; +import { Page, XStack } from '@onekeyhq/components'; +import { AccountSelectorProviderMirror } from '@onekeyhq/kit/src/components/AccountSelector'; +import { useSelectedAccount } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import type { + EAccountManagerStacksRoutes, + IAccountManagerStacksParamList, +} from '@onekeyhq/shared/src/routes'; + +import { useWebDappWalletSelector } from './useWebDappWalletSelector'; +import { WalletDetailsV2 } from './WalletDetails/WalletDetailsV2'; +import { AccountSelectorWalletListSideBarV2 } from './WalletList/AccountSelectorWalletListSideBarV2'; + +export function AccountSelectorStackV2({ + num, + hideNonBackedUpWallet, +}: { + num: number; + hideNonBackedUpWallet?: boolean; +}) { + const { selectedAccount } = useSelectedAccount({ num }); + const { shouldHideWalletList } = useWebDappWalletSelector({ + num, + focusedWallet: selectedAccount.focusedWallet, + }); + + return ( + + + + {shouldHideWalletList ? null : ( + + )} + + + + + + ); +} + +export default function AccountSelectorStackPageV2({ + route, +}: IPageScreenProps< + IAccountManagerStacksParamList, + EAccountManagerStacksRoutes.AccountSelectorStack +>) { + const { + num, + sceneName, + sceneUrl, + hideNonBackedUpWallet, + linkNetworkId, + linkNetworkDeriveType, + linkNetwork, + } = route.params; + + defaultLogger.accountSelector.perf.renderAccountSelectorModal({ + num, + sceneName, + sceneUrl, + linkNetworkId, + linkNetworkDeriveType, + linkNetwork, + }); + + return ( + + + + ); +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountEditActionListV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountEditActionListV2.tsx new file mode 100644 index 000000000000..81d20e4df58b --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountEditActionListV2.tsx @@ -0,0 +1,342 @@ +import { useCallback, useMemo } from 'react'; + +import { useIntl } from 'react-intl'; + +import { Divider, type IActionListProps } from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { AccountSelectorProviderMirror } from '@onekeyhq/kit/src/components/AccountSelector'; +import { useAccountData } from '@onekeyhq/kit/src/hooks/useAccountData'; +import { useAccountSelectorContextData } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import type { + IDBAccount, + IDBIndexedAccount, + IDBUtxoAccount, + IDBWallet, +} from '@onekeyhq/kit-bg/src/dbs/local/types'; +import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; + +import { AccountCopyButton } from '../../../components/AccountEdit/AccountCopyButton'; +import { AccountExportPrivateKeyButton } from '../../../components/AccountEdit/AccountExportPrivateKeyButton'; +import { AccountMoveToTopButton } from '../../../components/AccountEdit/AccountMoveToTopButton'; +import { AccountRemoveButton } from '../../../components/AccountEdit/AccountRemoveButton'; +import { AccountRenameButton } from '../../../components/AccountEdit/AccountRenameButton'; +import { AccountManagerTestIDs } from '../../../testIDs'; + +export interface IAccountEditActionListV2Props { + avatarNetworkId?: string; + accountsCount: number; + indexedAccount?: IDBIndexedAccount; + firstIndexedAccount?: IDBIndexedAccount; + account?: IDBAccount; + firstAccount?: IDBAccount; + wallet?: IDBWallet; + networkId?: string; +} + +export interface IAccountEditActionListOptionsV2 { + title: string; + ready: boolean; + renderItemsAsync: NonNullable; +} + +export function useAccountEditActionListOptionsV2({ + avatarNetworkId, + accountsCount, + indexedAccount, + firstIndexedAccount, + account, + firstAccount, + wallet, + networkId, +}: IAccountEditActionListV2Props): IAccountEditActionListOptionsV2 { + const intl = useIntl(); + const { config } = useAccountSelectorContextData(); + const name = indexedAccount?.name || account?.name || '--'; + const { network, vaultSettings, isLoading } = useAccountData({ + networkId: account?.createAtNetwork ?? networkId, + options: { watchLoading: true }, + }); + // const { config } = useAccountSelectorContextData(); + // if (!config) { + // return null; + // } + + const showRemoveButton = useMemo(() => { + if (accountUtils.isQrWallet({ walletId: wallet?.id })) { + return false; + } + if (indexedAccount && accountsCount <= 1) { + return false; + } + return true; + }, [accountsCount, indexedAccount, wallet?.id]); + + const showCopyButton = useMemo(() => { + if (network?.isAllNetworks) { + return true; + } + + if (vaultSettings?.copyAddressDisabled) { + return false; + } + + if (!account && !indexedAccount?.associateAccount) { + return false; + } + + return true; + }, [account, indexedAccount, network, vaultSettings?.copyAddressDisabled]); + + const isImportedAccount = useMemo( + () => + Boolean( + account && + !indexedAccount && + account?.id && + accountUtils.isImportedAccount({ accountId: account?.id }), + ), + [account, indexedAccount], + ); + + const isWatchingAccount = useMemo( + () => + Boolean( + account && + !indexedAccount && + account?.id && + accountUtils.isWatchingAccount({ accountId: account?.id }), + ), + [account, indexedAccount], + ); + + const isHdAccount = useMemo( + () => + indexedAccount && + !account && + wallet?.id && + accountUtils.isHdWallet({ walletId: wallet?.id }), + [account, indexedAccount, wallet?.id], + ); + + const isHwOrQrAccount = useMemo( + () => + indexedAccount && + !account && + wallet?.id && + accountUtils.isHwOrQrWallet({ walletId: wallet?.id }), + [account, indexedAccount, wallet?.id], + ); + + const getExportKeysVisible = useCallback(async () => { + if ( + (isImportedAccount && account?.createAtNetwork) || + (isWatchingAccount && + account?.createAtNetwork && + (account?.pub || (account as IDBUtxoAccount)?.xpub)) + ) { + const privateKeyTypes = + await backgroundApiProxy.serviceAccount.getNetworkSupportedExportKeyTypes( + { + networkId: account?.createAtNetwork, + exportType: 'privateKey', + }, + ); + const publicKeyTypes = + await backgroundApiProxy.serviceAccount.getNetworkSupportedExportKeyTypes( + { + networkId: account?.createAtNetwork, + exportType: 'publicKey', + }, + ); + + const mnemonicTypes = + await backgroundApiProxy.serviceAccount.getNetworkSupportedExportKeyTypes( + { + accountId: account?.id, + networkId: account?.createAtNetwork, + exportType: 'mnemonic', + }, + ); + + return { + showExportPrivateKey: isWatchingAccount + ? false + : Boolean(privateKeyTypes?.length), + showExportPublicKey: Boolean(publicKeyTypes?.length), + showExportMnemonic: Boolean(mnemonicTypes?.length), + }; + } + + if (isHdAccount) { + return { + showExportPrivateKey: true, + showExportPublicKey: true, + }; + } + + if (isHwOrQrAccount) { + let showExportPublicKey = true; + + // qr wallet firmware does not support verify and confirm public key currently + if (accountUtils.isQrWallet({ walletId: wallet?.id })) { + showExportPublicKey = false; + } + // Third-party HW (Ledger) — no unified verify-and-confirm public key flow + if ( + wallet?.associatedDeviceInfo?.vendor && + getVendorProfile(wallet.associatedDeviceInfo.vendor).isThirdParty + ) { + showExportPublicKey = false; + } + return { + showExportPrivateKey: false, + showExportPublicKey, + }; + } + + return { + showExportPrivateKey: false, + showExportPublicKey: false, + }; + }, [ + account, + isHdAccount, + isHwOrQrAccount, + isImportedAccount, + isWatchingAccount, + wallet?.id, + wallet?.associatedDeviceInfo?.vendor, + ]); + + const renderItems = useCallback( + async ({ + handleActionListClose, + }: { + handleActionListClose: () => void; + }) => { + if (!config) { + return null; + } + const exportKeysVisible = await getExportKeysVisible(); + return ( + // fix missing context in popover + + {(() => { + defaultLogger.accountSelector.perf.renderAccountEditOptions({ + wallet, + indexedAccount, + account, + }); + return null; + })()} + {showCopyButton ? ( + + ) : null} + + + {exportKeysVisible?.showExportPrivateKey ? ( + + ) : null} + {exportKeysVisible?.showExportPublicKey ? ( + + ) : null} + {exportKeysVisible?.showExportMnemonic ? ( + + ) : null} + + {showRemoveButton ? ( + <> + + + + ) : null} + + ); + }, + [ + config, + getExportKeysVisible, + showCopyButton, + avatarNetworkId, + wallet, + indexedAccount, + account, + name, + intl, + firstIndexedAccount, + firstAccount, + showRemoveButton, + accountsCount, + ], + ); + + return { + title: name, + // Keep account management available when network metadata fails to load. + ready: !(account?.createAtNetwork ?? networkId) || isLoading === false, + renderItemsAsync: renderItems, + }; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorActionV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorActionV2.tsx new file mode 100644 index 000000000000..f9fbaf08f52d --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorActionV2.tsx @@ -0,0 +1,186 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; + +import { + ActionList, + type IButtonProps, + ModalNavigatorContext, + Portal, + Stack, + useMedia, + useModalNavigatorContext, +} from '@onekeyhq/components'; +import { + createImperativeActionListLifecycle, + preventImperativeActionListCloseAutoFocus, +} from '@onekeyhq/components/src/actions/ActionList/imperativeShowUtils'; +import { + PageContext, + usePageContext, +} from '@onekeyhq/components/src/layouts/Page/PageContext'; +import { AccountSelectorCreateAddressButton } from '@onekeyhq/kit/src/components/AccountSelector/AccountSelectorCreateAddressButton'; +import type { IAccountEditButtonProps } from '@onekeyhq/kit/src/views/AccountManagerStacks/components/AccountEdit/AccountEditButton'; +import type { IAccountDeriveTypes } from '@onekeyhq/kit-bg/src/vaults/types'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import { useAccountEditActionListOptionsV2 } from './AccountEditActionListV2'; + +import type { + NativeListActionAnchor, + NativeListRef, +} from '@onekeyfe/react-native-native-list'; + +const desktopMenuPanelPropsV2 = { + width: '$56', + onCloseAutoFocus: preventImperativeActionListCloseAutoFocus, +} as const; + +export function AccountSelectorMenuActionV2({ + account, + anchor, + listRef, + onClose, +}: { + account: IAccountEditButtonProps; + anchor: NativeListActionAnchor; + listRef: RefObject; + onClose: (token: string) => void; +}) { + const { title, renderItemsAsync, ready } = + useAccountEditActionListOptionsV2(account); + const { gtMd } = useMedia(); + const modalNavigatorContext = useModalNavigatorContext(); + const pageContext = usePageContext(); + const contextsRef = useRef({ modalNavigatorContext, pageContext }); + contextsRef.current = { modalNavigatorContext, pageContext }; + const optionsRef = useRef({ title, renderItemsAsync }); + optionsRef.current = { title, renderItemsAsync }; + useEffect(() => { + if (!ready) return; + listRef.current?.setActionAnchorState({ token: anchor.token, open: true }); + const handleClose = () => { + listRef.current?.setActionAnchorState({ + token: anchor.token, + open: false, + restoreFocus: true, + }); + onClose(anchor.token); + }; + if (platformEnv.isNative || !gtMd) { + const handle = ActionList.show({ + title: optionsRef.current.title, + triggerRect: anchor.windowRect, + renderItemsAsync: (options) => + optionsRef.current.renderItemsAsync(options), + onClose: handleClose, + }); + return () => handle.close(); + } + + const portalLifecycle: { destroy?: () => void } = {}; + let closeActionList: (() => void) | undefined; + let closeRequested = false; + const lifecycle = createImperativeActionListLifecycle({ + onClose: handleClose, + destroy: () => portalLifecycle.destroy?.(), + }); + const { windowRect } = anchor; + // The anchor is the original 24px layout slot, inside the 38px hit target. + const triggerStyle = { + position: 'fixed' as const, + left: windowRect.x, + top: windowRect.y, + width: windowRect.width, + height: windowRect.height, + }; + const portal = Portal.Render( + Portal.Constant.FULL_WINDOW_OVERLAY_PORTAL, + + + + + } + renderItemsAsync={(options) => { + closeActionList = options.handleActionListClose; + if (closeRequested) { + closeActionList(); + return Promise.resolve(null); + } + return optionsRef.current.renderItemsAsync(options); + }} + /> + + + , + ); + portalLifecycle.destroy = () => portal.destroy(); + return () => { + closeRequested = true; + closeActionList?.(); + lifecycle.close(); + }; + }, [anchor, gtMd, listRef, onClose, ready]); + return null; +} + +function InvokeCreateAddressV2({ + onPress, + onDone, +}: { + onPress: NonNullable; + onDone: () => void; +}) { + const invoked = useRef(false); + useEffect(() => { + if (invoked.current) return; + invoked.current = true; + // AccountSelectorCreateAddressButton supplies a zero-argument async handler. + const create = onPress as () => Promise; + void create().finally(onDone); + }, [onDone, onPress]); + return null; +} + +export function AccountSelectorCreateAddressActionV2({ + num, + walletId, + networkId, + indexedAccountId, + deriveType, + onDone, +}: { + num: number; + walletId: string; + networkId?: string; + indexedAccountId?: string; + deriveType?: IAccountDeriveTypes; + onDone: () => void; +}) { + const renderButton = useCallback( + (props: IButtonProps) => + props.onPress ? ( + + ) : null, + [onDone], + ); + return ( + + ); +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx new file mode 100644 index 000000000000..4dd9e49d7d1e --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx @@ -0,0 +1,794 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + NativeList, + type NativeListActionAnchor, + type NativeListProps, + type NativeListRef, + type NativeListSnapshot, + type RowModel, +} from '@onekeyfe/react-native-native-list'; +import { useIntl } from 'react-intl'; + +import { + Alert, + Button, + SizableText, + Stack, + Toast, + resetAccountManagerStacksModal, + useSafeAreaInsets, + useTheme, +} from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { useCreateQrWallet } from '@onekeyhq/kit/src/components/AccountSelector/hooks/useCreateQrWallet'; +import { useEnabledNetworksCompatibleWithWalletIdInAllNetworks } from '@onekeyhq/kit/src/hooks/useAllNetwork'; +import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { + useAccountSelectorStorageReadyAtom, + useSelectedAccount, +} from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import { useAccountSelectorActions } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector/actions'; +import qrHiddenCreateGuideDialog from '@onekeyhq/kit/src/views/Onboarding/pages/ConnectHardwareWallet/qrHiddenCreateGuideDialog'; +import type { + IDBAccount, + IDBDevice, + IDBIndexedAccount, + IDBWallet, +} from '@onekeyhq/kit-bg/src/dbs/local/types'; +import type { + IAccountSelectorAccountsListSectionData, + IAccountSelectorSelectedAccount, +} from '@onekeyhq/kit-bg/src/dbs/simple/entity/SimpleDbEntityAccountSelector'; +import { accountSelectorAccountsListIsLoadingAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { IAccountDeriveTypes } from '@onekeyhq/kit-bg/src/vaults/types'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; +import { swrKeys } from '@onekeyhq/shared/src/utils/swrCacheUtils'; + +import { HiddenWalletRememberSwitch } from '../../../components/WalletEdit/HiddenWalletRememberSwitch'; +import { useAccountSelectorRoute } from '../../../router/useAccountSelectorRoute'; +import { AccountManagerTestIDs } from '../../../testIDs'; +import { useAccountSelectorNativeListThemeV2 } from '../accountSelectorNativeListV2'; + +import { + type IAccountSelectorRowRecordV2, + useAccountSelectorAccountRowsV2, + useAccountSelectorNativeSnapshotV2, +} from './accountSelectorAccountRowsV2'; +import { + AccountSelectorCreateAddressActionV2, + AccountSelectorMenuActionV2, +} from './AccountSelectorActionV2'; +import { EmptyView } from './EmptyView'; +import { useAddAccount } from './hooks/useAddAccount'; +import { useAccountSelectorValuesLoaderV2 } from './useAccountSelectorValuesLoaderV2'; +import { WalletDetailsHeader } from './WalletDetailsHeader'; +import { AccountSearchBar } from './WalletDetailsHeader/AccountSearchBar'; + +import type { IAccountEditActionListV2Props } from './AccountEditActionListV2'; + +export interface IWalletDetailsProps { + num: number; + wallet?: IDBWallet; + device?: IDBDevice | undefined; +} + +function BotWalletDeactivatedBanner({ walletId }: { walletId: string }) { + const { result: isBotWalletDeactivated } = usePromiseResult( + async () => + backgroundApiProxy.serviceAccount.isBotWalletDeactivated({ walletId }), + [walletId], + { + checkIsFocused: false, + }, + ); + + if (!isBotWalletDeactivated) { + return null; + } + + return ( + + ); +} + +function WalletDetailsViewV2({ num }: IWalletDetailsProps) { + const intl = useIntl(); + const { serviceAccountSelector } = backgroundApiProxy; + const { selectedAccount } = useSelectedAccount({ num }); + const actions = useAccountSelectorActions(); + const listRef = useRef(null); + const route = useAccountSelectorRoute(); + const selectedAccountRef = + useRef(selectedAccount); + selectedAccountRef.current = selectedAccount; + + const linkNetwork: boolean | undefined = route.params?.linkNetwork; + const linkNetworkId: string | undefined = route.params?.linkNetworkId; + const linkNetworkDeriveType: IAccountDeriveTypes | undefined = + route.params?.linkNetworkDeriveType; + + const isEditableRouteParams = route.params?.editable; + const keepAllOtherAccounts = route.params?.keepAllOtherAccounts; + const allowSelectEmptyAccount = route.params?.allowSelectEmptyAccount; + const hideAddress = route.params?.hideAddress; + const linkedNetworkId = useMemo(() => { + if (linkNetworkId) { + return linkNetworkId; + } + return linkNetwork ? selectedAccount?.networkId : undefined; + }, [linkNetworkId, linkNetwork, selectedAccount?.networkId]); + const usedDeriveType = useMemo(() => { + if (linkNetworkId && linkNetworkDeriveType) { + return linkNetworkDeriveType; + } + return selectedAccount?.deriveType; + }, [linkNetworkId, linkNetworkDeriveType, selectedAccount?.deriveType]); + const selectedNetworkId = selectedAccount?.networkId; + const [searchText, setSearchText] = useState(''); + const { createQrWallet } = useCreateQrWallet(); + const [storageReady] = useAccountSelectorStorageReadyAtom(); + + const accountsListSwrKey = useMemo(() => { + if (!selectedAccount?.focusedWallet || !usedDeriveType) return undefined; + return swrKeys.accountSelectorList({ + focusedWallet: selectedAccount.focusedWallet, + deriveType: usedDeriveType, + linkedNetworkId, + selectedNetworkId, + keepAllOtherAccounts, + }); + }, [ + selectedAccount?.focusedWallet, + usedDeriveType, + linkedNetworkId, + selectedNetworkId, + keepAllOtherAccounts, + ]); + + defaultLogger.accountSelector.perf.renderAccountsList({ + selectedAccount, + }); + + // TODO move to hooks + const isOthers = selectedAccount?.focusedWallet === '$$others'; + const isOthersWallet = Boolean( + selectedAccount?.focusedWallet && + accountUtils.isOthersWallet({ + walletId: selectedAccount?.focusedWallet, + }), + ); + const isOthersUniversal = isOthers || isOthersWallet; + // const isOthersUniversal = true; + + const { + result: listDataResult, + run: reloadAccounts, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setResult: _setListDataResult, + } = usePromiseResult( + async () => { + if (!selectedAccount?.focusedWallet || !usedDeriveType) { + defaultLogger.accountSelector.listData.listDataMissingParams({ + focusedWallet: selectedAccount?.focusedWallet, + deriveType: usedDeriveType, + selectedAccount: selectedAccountRef.current, + }); + return Promise.resolve(undefined); + } + + // await timerUtils.wait(1000); + const accountSelectorAccountsListData = + await serviceAccountSelector.buildAccountSelectorAccountsListData({ + focusedWallet: selectedAccount?.focusedWallet, + linkedNetworkId, + selectedNetworkId, + deriveType: usedDeriveType, + othersNetworkId: selectedAccount?.networkId, + keepAllOtherAccounts, + }); + + return accountSelectorAccountsListData; + }, + [ + keepAllOtherAccounts, + linkedNetworkId, + selectedNetworkId, + usedDeriveType, + selectedAccount?.focusedWallet, + selectedAccount?.networkId, + serviceAccountSelector, + ], + { + // debounced: 100, + checkIsFocused: false, + watchLoading: false, + swrKey: accountsListSwrKey, + onIsLoadingChange(loading) { + // setIsLoading(loading); + void accountSelectorAccountsListIsLoadingAtom.set(loading); + }, + }, + ); + + // Drives EmptyView's skeleton-vs-no-wallet decision. We're "resolved" when + // either: (a) we already have a result (real fetch or SWR cache hit), or + // (b) storage finished hydrating and confirmed there's no focused wallet + // — i.e. the user truly has none. The early-return path inside the + // usePromiseResult method also resolves to undefined, so without (b) the + // genuine "no wallets" case would render an infinite skeleton. + const hasResolved = useMemo(() => { + if (listDataResult !== undefined) return true; + if (storageReady && !selectedAccount?.focusedWallet) return true; + return false; + }, [listDataResult, storageReady, selectedAccount?.focusedWallet]); + + const sectionDataOriginal = useMemo( + () => listDataResult?.sectionData || [], + [listDataResult?.sectionData], + ); + const indexedAccountIdsKey = useMemo( + () => sectionDataOriginal.flatMap((s) => s.data.map((item) => item.id)), + [sectionDataOriginal], + ); + // Stabilize reference — only produce a new array when content changes + const indexedAccountIds = useMemo( + () => indexedAccountIdsKey, + // eslint-disable-next-line react-hooks/exhaustive-deps + [indexedAccountIdsKey.join(',')], + ); + + // Lazy-load address map only when searching (avoids DB read on every wallet/network switch) + const isSearching = !!searchText; + const { result: accountAddressMap } = usePromiseResult( + async () => { + if (!isSearching || linkedNetworkId) return undefined; + if (!selectedAccount?.focusedWallet || !indexedAccountIds.length) { + return undefined; + } + return serviceAccountSelector.buildAccountAddressMap({ + focusedWallet: selectedAccount.focusedWallet, + indexedAccountIds, + }); + }, + [ + isSearching, + linkedNetworkId, + selectedAccount?.focusedWallet, + indexedAccountIds, + serviceAccountSelector, + ], + { + checkIsFocused: false, + }, + ); + // When address map is expected but not yet loaded, hold off filtering to avoid "no result" flash + const isIndexedAccountWallet = accountUtils.isIndexedAccountWallet({ + walletId: selectedAccount?.focusedWallet, + }); + const addressMapLoading = + isSearching && + !linkedNetworkId && + isIndexedAccountWallet && + accountAddressMap === undefined; + + const sectionData = useMemo(() => { + if (!searchText || addressMapLoading) { + return sectionDataOriginal; + } + const query = searchText.toLowerCase(); + const sectionDataFiltered: IAccountSelectorAccountsListSectionData[] = []; + sectionDataOriginal.forEach((section) => { + const { data, ...others } = section; + sectionDataFiltered.push({ + ...others, + data: + (data as IDBIndexedAccount[])?.filter((item) => { + if (item.name?.toLowerCase().includes(query)) { + return true; + } + // Prefer inline address when available (set by linked-network queries) + const address = + (item as unknown as IDBAccount).address || + item.associateAccount?.address || + ''; + if (address.toLowerCase().includes(query)) { + return true; + } + // Fallback: pre-lowercased addresses from All Network map + const addresses = accountAddressMap?.[item.id]; + if (addresses?.some((addr) => addr.includes(query))) { + return true; + } + return false; + }) ?? [], + }); + }); + return sectionDataFiltered; + }, [sectionDataOriginal, searchText, accountAddressMap, addressMapLoading]); + + // Load account values asynchronously in batches via atoms, scoped by selector num + useAccountSelectorValuesLoaderV2({ + num, + accountsForValuesQuery: listDataResult?.accountsForValuesQuery, + linkedNetworkId, + }); + + const accountsCount = useMemo( + () => listDataResult?.accountsCount ?? 0, + [listDataResult?.accountsCount], + ); + const focusedWalletInfo = useMemo( + () => listDataResult?.focusedWalletInfo, + [listDataResult?.focusedWalletInfo], + ); + + const isDeprecatedWallet = useMemo( + () => focusedWalletInfo?.wallet?.deprecated, + [focusedWalletInfo?.wallet?.deprecated], + ); + + const { enabledNetworksCompatibleWithWalletId, networkInfoMap } = + useEnabledNetworksCompatibleWithWalletIdInAllNetworks({ + walletId: focusedWalletInfo?.wallet?.id ?? '', + networkId: selectedNetworkId, + withNetworksInfo: true, + }); + + useEffect(() => { + const fn = async () => { + await reloadAccounts(); + }; + appEventBus.on(EAppEventBusNames.AccountUpdate, fn); + appEventBus.on(EAppEventBusNames.WalletUpdate, fn); + return () => { + appEventBus.off(EAppEventBusNames.AccountUpdate, fn); + appEventBus.off(EAppEventBusNames.WalletUpdate, fn); + }; + }, [reloadAccounts]); + + const { bottom, top } = useSafeAreaInsets(); + const theme = useAccountSelectorNativeListThemeV2(); + const appTheme = useTheme(); + const editable = !!isEditableRouteParams && sectionData.length > 0; + const isMockedStandardHwWallet = focusedWalletInfo?.wallet?.isMocked; + const isHiddenWallet = !!focusedWalletInfo?.wallet?.passphraseState; + const title = isOthers ? 'Others' : focusedWalletInfo?.wallet?.name || ''; + const [listHeight, setListHeight] = useState(0); + const [pendingMenu, setPendingMenu] = useState<{ + anchor: NativeListActionAnchor; + account: IAccountEditActionListV2Props; + }>(); + const [pendingCreate, setPendingCreate] = useState<{ + record: IAccountSelectorRowRecordV2; + walletId: string; + networkId?: string; + deriveType?: IAccountDeriveTypes; + }>(); + const creatingRef = useRef(false); + const { handleAddAccount } = useAddAccount({ + num, + isOthersUniversal, + focusedWalletInfo, + }); + const { rows: accountRows, records } = useAccountSelectorAccountRowsV2({ + num, + sections: sectionData, + selectedAccount, + wallet: focusedWalletInfo?.wallet, + linkedNetworkId, + linkNetwork, + isOthersUniversal, + hideAddress, + allowSelectEmptyAccount, + editable, + mergeDeriveAssetsEnabled: listDataResult?.mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + theme, + }); + const listIdentity = `${focusedWalletInfo?.wallet?.id ?? ''}:${linkedNetworkId ?? ''}:${usedDeriveType ?? ''}:${searchText}`; + const identityRef = useRef(listIdentity); + const generationRef = useRef(1); + if (identityRef.current !== listIdentity) { + identityRef.current = listIdentity; + generationRef.current += 1; + } + const generation = generationRef.current; + const snapshot = useMemo(() => { + const rows: RowModel[] = []; + if (isDeprecatedWallet) { + rows.push({ + type: 'system', + variant: 'warning', + key: 'deprecated-wallet', + title: intl.formatMessage({ + id: ETranslations.wallet_wallet_device_has_been_reset_alert_title, + }), + message: intl.formatMessage({ + id: ETranslations.wallet_wallet_device_has_been_reset_alert_desc, + }), + backgroundColor: appTheme.bgCautionSubdued.val, + borderColor: appTheme.borderCautionSubdued.val, + backgroundFullWidth: true, + }); + } + const byId = new Map(accountRows.map((row) => [row.key, row])); + sectionData.forEach((section, sectionIndex) => { + if (!section.data.length && section.emptyText) { + rows.push({ + type: 'action', + key: `empty:${sectionIndex}`, + presentation: 'accountSelector', + tone: 'primary', + title: section.emptyText, + actionKey: 'empty', + pressDisabled: true, + height: 56, + }); + } + section.data.forEach((item) => { + const row = byId.get(item.id); + if (row) rows.push(row); + }); + if ( + isEditableRouteParams && + !searchText && + focusedWalletInfo?.wallet?.id && + !isMockedStandardHwWallet && + sectionDataOriginal.length + ) { + rows.push({ + type: 'action', + key: `add-account:${sectionIndex}`, + testID: AccountManagerTestIDs.accountAddAccount, + presentation: 'accountSelector', + title: intl.formatMessage({ + id: platformEnv.isWebDappMode + ? ETranslations.onboarding_connect_external_wallet + : ETranslations.global_add_account, + }), + actionKey: 'add-account', + icon: { + kind: 'icon', + name: 'PlusSmallOutline', + backgroundColor: theme.strongBackground, + tintColor: theme.icon, + }, + height: 48, + }); + } + }); + return { + schemaVersion: 1, + generation, + theme, + layout: { + kind: 'linear', + contentPaddingHorizontal: 8, + contentPaddingBottom: 12, + itemSpacing: 0, + }, + rows, + }; + }, [ + appTheme, + isDeprecatedWallet, + accountRows, + sectionData, + isEditableRouteParams, + searchText, + focusedWalletInfo?.wallet?.id, + isMockedStandardHwWallet, + sectionDataOriginal.length, + intl, + generation, + theme, + ]); + const nativeSnapshot = useAccountSelectorNativeSnapshotV2({ + snapshot, + listRef, + listHeight, + }); + const selectedKey = isOthersUniversal + ? selectedAccount.othersWalletAccountId + : selectedAccount.indexedAccountId; + const selectedIndex = records.findIndex( + (record) => record.key === selectedKey, + ); + const initialScrollKey = + !searchText && listHeight > 0 && selectedIndex * 60 > listHeight + ? selectedKey + : undefined; + const closeMenu = useCallback( + (token: string) => + setPendingMenu((current) => + current?.anchor.token === token ? undefined : current, + ), + [], + ); + const finishCreate = useCallback(() => { + creatingRef.current = false; + setPendingCreate(undefined); + }, []); + useEffect(() => { + setPendingMenu(undefined); + }, [listIdentity]); + + const handleAccountPress = useCallback( + async (record: IAccountSelectorRowRecordV2) => { + if ( + record.isCreatingAddress || + (!allowSelectEmptyAccount && record.shouldShowCreateAddressButton) + ) + return; + if (isOthersUniversal) { + let autoChangeToAccountMatchedNetworkId = record.avatarNetworkId; + if ( + selectedAccount.networkId && + networkUtils.isAllNetwork({ networkId: selectedAccount.networkId }) + ) + autoChangeToAccountMatchedNetworkId = selectedAccount.networkId; + const confirmed = await actions.current.confirmAccountSelect({ + num, + indexedAccount: undefined, + othersWalletAccount: record.account, + autoChangeToAccountMatchedNetworkId, + }); + if (!confirmed) return; + } else if (focusedWalletInfo) { + const confirmed = await actions.current.confirmAccountSelect({ + num, + indexedAccount: record.indexedAccount, + othersWalletAccount: undefined, + autoChangeToAccountMatchedNetworkId: undefined, + }); + if (!confirmed) return; + } + resetAccountManagerStacksModal(); + }, + [ + actions, + allowSelectEmptyAccount, + focusedWalletInfo, + isOthersUniversal, + num, + selectedAccount.networkId, + ], + ); + + const deprecatedAlert = isDeprecatedWallet ? ( + + ) : null; + + const nativeListProps: Omit< + NativeListProps, + | 'initialScrollIndex' + | 'initialScrollKey' + | 'initialScrollViewPosition' + | 'initialScrollViewOffset' + > = { + style: { flex: 1 }, + testID: 'account-selector-account-list-v2', + snapshot: nativeSnapshot, + onActionAnchorInvalidated: (event) => closeMenu(event.token), + onRowAction: (event) => { + if (event.actionKey === 'add-account') { + void handleAddAccount(); + return; + } + const record = records.find( + (candidate) => candidate.key === event.rowKey, + ); + if (!record) return; + if ( + event.actionKey === 'account-more' && + event.anchor && + editable && + !record.isCreatingAddress && + !record.shouldShowCreateAddressButton + ) { + setPendingMenu({ + anchor: event.anchor, + account: { + avatarNetworkId: record.avatarNetworkId, + accountsCount, + indexedAccount: record.indexedAccount, + firstIndexedAccount: isOthersUniversal + ? undefined + : (record.section.firstAccount as IDBIndexedAccount), + account: record.account, + firstAccount: isOthersUniversal + ? (record.section.firstAccount as IDBAccount) + : undefined, + wallet: focusedWalletInfo?.wallet, + networkId: linkedNetworkId ?? selectedNetworkId, + }, + }); + } else if ( + event.actionKey === 'create-address' && + record.shouldShowCreateAddressButton && + !record.isCreatingAddress && + !creatingRef.current && + focusedWalletInfo?.wallet?.id + ) { + creatingRef.current = true; + setPendingCreate({ + record, + walletId: focusedWalletInfo.wallet.id, + networkId: linkedNetworkId, + deriveType: selectedAccount.deriveType, + }); + } else if (event.actionKey === 'press') { + void handleAccountPress(record); + } + }, + }; + const accountList = initialScrollKey ? ( + + ) : ( + + ); + + return ( + <> + + + {focusedWalletInfo?.wallet?.id && + accountUtils.isBotWallet({ walletId: focusedWalletInfo.wallet.id }) ? ( + + ) : null} + {platformEnv.isWebDappMode && + accountUtils.isHwWallet({ walletId: focusedWalletInfo?.wallet?.id }) ? ( + + ) : null} + {focusedWalletInfo?.wallet?.id && isHiddenWallet && editable ? ( + + ) : null} + {!platformEnv.isWebDappMode && + !isMockedStandardHwWallet && + sectionDataOriginal.length && + focusedWalletInfo?.wallet?.id ? ( + + ) : null} + {isMockedStandardHwWallet ? deprecatedAlert : null} + {isMockedStandardHwWallet ? ( + + + {intl.formatMessage({ + id: ETranslations.no_standard_wallet_desc, + })} + + {isEditableRouteParams ? ( + + ) : null} + + ) : null} + {!isMockedStandardHwWallet && !sectionData.length ? ( + + ) : null} + {!isMockedStandardHwWallet && sectionData.length ? ( + setListHeight(event.nativeEvent.layout.height)} + > + {listHeight > 0 ? accountList : null} + + ) : null} + + {pendingMenu ? ( + + ) : null} + {pendingCreate ? ( + + ) : null} + + ); +} + +export const WalletDetailsV2 = memo(WalletDetailsViewV2); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.test.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.test.tsx new file mode 100644 index 000000000000..356a07c5729b --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.test.tsx @@ -0,0 +1,167 @@ +/** @jest-environment jsdom */ +import { renderHook } from '@testing-library/react'; + +import { + buildAccountSelectorRowPatchesV2, + useAccountSelectorNativeSnapshotV2, +} from './accountSelectorAccountRowsV2'; + +import type { + IdentityRow, + NativeListRef, + NativeListSnapshot, +} from '@onekeyfe/react-native-native-list'; + +jest.mock( + '@onekeyhq/kit/src/background/instance/backgroundApiProxy', + () => ({}), +); +jest.mock('@onekeyhq/kit/src/hooks/usePromiseResult', () => ({})); +jest.mock( + '@onekeyhq/kit/src/states/jotai/contexts/accountSelector', + () => ({}), +); +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({})); +jest.mock('@onekeyhq/shared/src/locale', () => ({})); +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({})); +jest.mock('@onekeyhq/shared/src/utils/accountUtils', () => ({})); +jest.mock('@onekeyhq/shared/src/utils/networkUtils', () => ({})); +jest.mock('../accountSelectorNativeListV2', () => ({})); +jest.mock('./accountSelectorValueRowsV2', () => ({})); + +const baseRow: IdentityRow = { + type: 'identity', + leading: { kind: 'account' }, + key: 'account-0', + title: 'Account 0', + selected: false, + subtitleSegments: [{ text: '--' }], +}; +const snapshot = (row = baseRow, generation = 1): NativeListSnapshot => ({ + schemaVersion: 1, + layout: { kind: 'linear' }, + generation, + rows: [row], +}); + +describe('account V2 snapshot updates', () => { + it('compares a new snapshot once, patches latest values and skips same-reference renders', () => { + const listRef = { + current: { applyPatches: jest.fn() } as unknown as NativeListRef, + }; + const initial = snapshot(); + const { result, rerender } = renderHook( + ({ current }) => + useAccountSelectorNativeSnapshotV2({ + snapshot: current, + listRef, + listHeight: 600, + }), + { initialProps: { current: initial } }, + ); + const balance = snapshot({ + ...baseRow, + subtitleSegments: [{ text: '$1.00' }], + }); + const metadataRead = jest.fn(() => 1); + Object.defineProperty(balance, 'generation', { + enumerable: true, + get: metadataRead, + }); + rerender({ current: balance }); + expect(metadataRead).toHaveBeenCalledTimes(1); + expect(result.current).toBe(initial); + expect(listRef.current.applyPatches).toHaveBeenCalledTimes(1); + rerender({ current: balance }); + expect(metadataRead).toHaveBeenCalledTimes(1); + expect(listRef.current.applyPatches).toHaveBeenCalledTimes(1); + rerender({ + current: snapshot({ + ...baseRow, + selected: true, + subtitleSegments: [{ text: '$1.00' }], + }), + }); + expect(listRef.current.applyPatches).toHaveBeenLastCalledWith([ + { type: 'identity', key: baseRow.key, changes: { selected: true } }, + ]); + }); + + it('retains patches received before mount and resets for structure or field removal', () => { + const listRef: { current: NativeListRef | null } = { current: null }; + const initial = snapshot(); + const { result, rerender } = renderHook( + ({ current, height }) => + useAccountSelectorNativeSnapshotV2({ + snapshot: current, + listRef, + listHeight: height, + }), + { initialProps: { current: initial, height: 0 } }, + ); + const balance = snapshot({ + ...baseRow, + subtitleSegments: [{ text: '$1.00' }], + }); + rerender({ current: balance, height: 0 }); + const selected = snapshot({ + ...baseRow, + selected: true, + subtitleSegments: [{ text: '$1.00' }], + }); + rerender({ current: selected, height: 0 }); + const applyPatches = jest.fn(); + listRef.current = { applyPatches } as unknown as NativeListRef; + rerender({ current: selected, height: 600 }); + expect(applyPatches).toHaveBeenCalledWith([ + { + type: 'identity', + key: baseRow.key, + changes: { selected: true, subtitleSegments: [{ text: '$1.00' }] }, + }, + ]); + const replacement = snapshot({ ...baseRow, key: 'new-wallet-account' }, 2); + rerender({ current: replacement, height: 600 }); + expect(result.current).toBe(replacement); + const removed = snapshot( + { + type: 'identity', + leading: { kind: 'account' }, + key: 'new-wallet-account', + title: 'No subtitle', + }, + 2, + ); + rerender({ current: removed, height: 600 }); + expect(result.current).toBe(removed); + expect(applyPatches).toHaveBeenCalledTimes(1); + }); + + it('replaces reordered/deleted/action rows but permits explicit empty subtitle patches', () => { + const initial = snapshot(); + expect(buildAccountSelectorRowPatchesV2(initial, initial)).toEqual([]); + expect( + buildAccountSelectorRowPatchesV2(initial, { ...initial, rows: [] }), + ).toBeUndefined(); + expect( + buildAccountSelectorRowPatchesV2( + initial, + snapshot({ ...baseRow, subtitleSegments: [] }), + ), + ).toEqual([ + { type: 'identity', key: baseRow.key, changes: { subtitleSegments: [] } }, + ]); + const action: NativeListSnapshot = { + ...initial, + rows: [{ type: 'action', actionKey: 'add', key: 'add', title: 'Add' }], + }; + expect( + buildAccountSelectorRowPatchesV2(action, { + ...action, + rows: [ + { type: 'action', actionKey: 'add', key: 'add', title: 'Changed' }, + ], + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts new file mode 100644 index 000000000000..a172ffd1ad9e --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts @@ -0,0 +1,436 @@ +import type { RefObject } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { isEqual } from 'lodash'; +import { useIntl } from 'react-intl'; + +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { useActiveAccount } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import type { + IDBAccount, + IDBIndexedAccount, + IDBWallet, +} from '@onekeyhq/kit-bg/src/dbs/local/types'; +import type { + IAccountSelectorAccountsListSectionData, + IAccountSelectorSelectedAccount, +} from '@onekeyhq/kit-bg/src/dbs/simple/entity/SimpleDbEntityAccountSelector'; +import { + useAccountSelectorDeFiMapAtom, + useAccountSelectorValuesMapAtom, + useActiveAccountValueAtom, + useCurrencyPersistAtom, + useIndexedAccountAddressCreationStateAtom, + useSettingsPersistAtom, + useSettingsValuePersistAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { INetworkDeriveInfo } from '@onekeyhq/kit-bg/src/vaults/types'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +import { AccountManagerTestIDs } from '../../../testIDs'; +import { accountSelectorAccountVisualV2 } from '../accountSelectorNativeListV2'; + +import { createAccountSelectorValueRowsV2 } from './accountSelectorValueRowsV2'; + +import type { + IdentityRow, + NativeListRef, + NativeListSnapshot, + NativeListTheme, + RowPatch, +} from '@onekeyfe/react-native-native-list'; + +export type IAccountSelectorRowRecordV2 = { + key: string; + item: IDBAccount | IDBIndexedAccount; + account?: IDBAccount; + indexedAccount?: IDBIndexedAccount; + section: IAccountSelectorAccountsListSectionData; + index: number; + avatarNetworkId?: string; + shouldShowCreateAddressButton: boolean; + isCreatingAddress: boolean; +}; + +export function useAccountSelectorAccountRowsV2({ + num, + sections, + selectedAccount, + wallet, + linkedNetworkId, + linkNetwork, + isOthersUniversal, + hideAddress, + allowSelectEmptyAccount, + editable, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + theme, +}: { + num: number; + sections: IAccountSelectorAccountsListSectionData[]; + selectedAccount: IAccountSelectorSelectedAccount; + wallet?: IDBWallet; + linkedNetworkId?: string; + linkNetwork?: boolean; + isOthersUniversal: boolean; + hideAddress?: boolean; + allowSelectEmptyAccount?: boolean; + editable: boolean; + mergeDeriveAssetsEnabled?: boolean; + enabledNetworksCompatibleWithWalletId: IServerNetwork[]; + networkInfoMap: Record; + theme: NativeListTheme; +}) { + const intl = useIntl(); + const { + activeAccount: { network }, + } = useActiveAccount({ num }); + const [valuesMap] = useAccountSelectorValuesMapAtom(); + const [deFiMap] = useAccountSelectorDeFiMapAtom(); + const [activeAccountValue] = useActiveAccountValueAtom(); + const [addressCreationState] = useIndexedAccountAddressCreationStateAtom(); + const [{ currencyMap }] = useCurrencyPersistAtom(); + const [{ currencyInfo }] = useSettingsPersistAtom(); + const [settingsValue] = useSettingsValuePersistAtom(); + const records = useMemo( + () => + sections.flatMap((section) => + section.data.map((item, index): IAccountSelectorRowRecordV2 => { + const account = isOthersUniversal ? (item as IDBAccount) : undefined; + const indexedAccount = isOthersUniversal + ? undefined + : (item as IDBIndexedAccount); + const matchedNetwork = + isOthersUniversal && account + ? accountUtils.getAccountCompatibleNetwork({ + account, + networkId: linkNetwork + ? linkedNetworkId || selectedAccount.networkId + : account.createAtNetwork, + }) + : undefined; + const avatarNetworkId = + matchedNetwork || + (indexedAccount && linkNetwork + ? linkedNetworkId || selectedAccount.networkId + : undefined); + const associate = indexedAccount?.associateAccount; + const isEmptyAddress = + !isOthersUniversal && + !!linkedNetworkId && + !associate?.address && + !( + associate?.addressDetail?.isValid && + associate.addressDetail.normalizedAddress + ); + return { + key: item.id, + item, + account, + indexedAccount, + section, + index, + avatarNetworkId, + shouldShowCreateAddressButton: !!(linkNetwork && isEmptyAddress), + isCreatingAddress: !!( + addressCreationState?.indexedAccountId === indexedAccount?.id && + addressCreationState?.walletId === wallet?.id + ), + }; + }), + ), + [ + sections, + isOthersUniversal, + linkNetwork, + linkedNetworkId, + selectedAccount.networkId, + addressCreationState, + wallet?.id, + ], + ); + const networkIdsKey = [ + ...new Set( + records + .map((record) => record.avatarNetworkId) + .filter((id): id is string => !!id), + ), + ] + .toSorted() + .join(','); + const { result: avatarNetworks } = usePromiseResult( + async () => { + const ids = networkIdsKey ? networkIdsKey.split(',') : []; + const networks = await Promise.all( + ids.map(async (id) => + backgroundApiProxy.serviceNetwork.getNetworkSafe({ networkId: id }), + ), + ); + return Object.fromEntries( + networks + .filter((item): item is IServerNetwork => !!item) + .map((item) => [item.id, item]), + ); + }, + [networkIdsKey], + { checkIsFocused: false }, + ); + const staticRows = useMemo( + () => + records.map((record): IdentityRow => { + const { + account, + indexedAccount, + avatarNetworkId, + item, + shouldShowCreateAddressButton, + isCreatingAddress, + } = record; + const associate = indexedAccount?.associateAccount; + const isEmptyAddress = + !isOthersUniversal && + !!linkedNetworkId && + !associate?.address && + !( + associate?.addressDetail?.isValid && + associate.addressDetail.normalizedAddress + ); + const rawAddress = account?.address || associate?.address || ''; + const address = accountUtils.shortenAddress({ + address: accountUtils.shortenAddress({ address: rawAddress }), + leadingLength: 6, + trailingLength: 4, + }); + const subtitleSegments: NonNullable< + IdentityRow['subtitleSegments'] + >[number][] = []; + if ( + (isEmptyAddress || isOthersUniversal || !hideAddress) && + (address || isEmptyAddress) + ) { + subtitleSegments.push({ + text: isEmptyAddress + ? intl.formatMessage({ id: ETranslations.wallet_no_address }) + : address, + tone: isEmptyAddress ? 'caution' : 'secondary', + separatorBefore: !(platformEnv.isWebDappMode || platformEnv.isE2E), + }); + } + let trailing: IdentityRow['trailing'] = []; + if (isCreatingAddress) trailing = [{ kind: 'spinner' }]; + else if (shouldShowCreateAddressButton) + trailing = [ + { + kind: 'icon', + name: 'PlusSmallOutline', + tintColor: theme.iconSubdued, + testID: 'account-manager-plus-button-icon-btn', + actionKey: 'create-address', + }, + ]; + else if (editable) + trailing = [ + { + kind: 'icon', + name: 'DotHorOutline', + tintColor: theme.iconSubdued, + testID: AccountManagerTestIDs.accountEditButton(item.name), + actionKey: 'account-more', + }, + ]; + return { + type: 'identity', + presentation: 'accountSelector', + key: item.id, + testID: AccountManagerTestIDs.accountItem(record.index), + height: 60, + title: item.name, + leading: accountSelectorAccountVisualV2({ + account, + indexedAccount, + network: avatarNetworkId + ? (avatarNetworks?.[avatarNetworkId] ?? + networkUtils.getLocalNetworkInfo(avatarNetworkId)) + : undefined, + theme, + }), + selected: isOthersUniversal + ? selectedAccount.othersWalletAccountId === item.id + : selectedAccount.indexedAccountId === item.id, + subtitleSegments, + pressDisabled: + isCreatingAddress || + (!allowSelectEmptyAccount && shouldShowCreateAddressButton), + trailing, + accessibilityLabel: [ + item.name, + ...subtitleSegments.map((segment) => segment.text), + ].join(', '), + }; + }), + [ + records, + isOthersUniversal, + linkedNetworkId, + hideAddress, + intl, + avatarNetworks, + theme, + selectedAccount.othersWalletAccountId, + selectedAccount.indexedAccountId, + allowSelectEmptyAccount, + editable, + ], + ); + // The cache owns only the current account set, not previous wallets. + const getValueRows = useMemo(() => createAccountSelectorValueRowsV2(), []); + const accountValues = valuesMap[num]; + const accountDeFi = deFiMap[num]; + const rows = useMemo( + () => + getValueRows({ + staticRows, + records, + accountValues, + accountDeFi, + activeAccountValue, + context: { + walletId: wallet?.id ?? '', + networkId: network?.id, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + currencyMap, + targetCurrency: currencyInfo.id, + hideValue: !!settingsValue.hideValue, + }, + skipValues: !!(platformEnv.isWebDappMode || platformEnv.isE2E), + }), + [ + getValueRows, + staticRows, + records, + accountValues, + accountDeFi, + activeAccountValue, + wallet?.id, + network?.id, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + currencyMap, + currencyInfo.id, + settingsValue.hideValue, + ], + ); + return { records, rows }; +} + +const accountRowPatchFieldsV2 = new Set([ + 'title', + 'leading', + 'selected', + 'subtitleSegments', + 'pressDisabled', + 'trailing', + 'accessibilityLabel', +]); + +export function buildAccountSelectorRowPatchesV2( + previous: NativeListSnapshot, + next: NativeListSnapshot, +): RowPatch[] | undefined { + if (previous === next) return []; + const { rows: previousRows, ...previousMetadata } = previous; + const { rows: nextRows, ...nextMetadata } = next; + if ( + previousRows.length !== nextRows.length || + !isEqual(previousMetadata, nextMetadata) + ) { + return undefined; + } + if (previousRows === nextRows) return []; + const patches: RowPatch[] = []; + for (let index = 0; index < nextRows.length; index += 1) { + const row = nextRows[index]; + const previousRow = previousRows[index]; + if (row !== previousRow) { + if (row.key !== previousRow.key || row.type !== previousRow.type) { + return undefined; + } + if (row.type !== 'identity' || previousRow.type !== 'identity') { + if (!isEqual(row, previousRow)) return undefined; + } else { + const fields = new Set([ + ...Object.keys(previousRow), + ...Object.keys(row), + ] as (keyof IdentityRow)[]); + const changedFields: (keyof IdentityRow)[] = []; + for (const field of fields) { + if (!isEqual(row[field], previousRow[field])) { + // Undefined fields are omitted by JSON; a snapshot is needed to clear them. + if ( + !accountRowPatchFieldsV2.has(field) || + row[field] === undefined + ) { + return undefined; + } + changedFields.push(field); + } + } + if (changedFields.length) { + patches.push({ + type: 'identity', + key: row.key, + changes: Object.fromEntries( + changedFields.map((field) => [field, row[field]]), + ) as Extract['changes'], + }); + } + } + } + } + return patches; +} + +// A structural change replaces the prop; field patches keep the mounted base stable. +export function useAccountSelectorNativeSnapshotV2({ + snapshot, + listRef, + listHeight, +}: { + snapshot: NativeListSnapshot; + listRef: RefObject; + listHeight: number; +}) { + const [nativeSnapshot, setNativeSnapshot] = useState(snapshot); + const appliedSnapshotRef = useRef< + { base: NativeListSnapshot; latest: NativeListSnapshot } | undefined + >(undefined); + const previousSnapshot = + appliedSnapshotRef.current?.base === nativeSnapshot + ? appliedSnapshotRef.current.latest + : nativeSnapshot; + const rowPatches = useMemo( + () => buildAccountSelectorRowPatchesV2(previousSnapshot, snapshot), + [previousSnapshot, snapshot], + ); + if (rowPatches === undefined) setNativeSnapshot(snapshot); + useEffect(() => { + const list = listRef.current; + if (!list) { + appliedSnapshotRef.current = undefined; + return; + } + if (rowPatches?.length) list.applyPatches(rowPatches); + appliedSnapshotRef.current = { base: nativeSnapshot, latest: snapshot }; + }, [nativeSnapshot, snapshot, rowPatches, listHeight, listRef]); + return nativeSnapshot; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.test.ts new file mode 100644 index 000000000000..a9e134e3a6cc --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.test.ts @@ -0,0 +1,136 @@ +import { createAccountSelectorValueRowsV2 } from './accountSelectorValueRowsV2'; +import { formatAccountSelectorValueV2 } from './accountSelectorValueV2'; + +import type { IAccountSelectorRowRecordV2 } from './accountSelectorAccountRowsV2'; +import type { IdentityRow } from '@onekeyfe/react-native-native-list'; + +function fixture(count = 1000) { + const staticRows: IdentityRow[] = Array.from( + { length: count }, + (_, index) => ({ + type: 'identity', + leading: { kind: 'account' }, + key: `account-${index}`, + title: `Account ${index}`, + subtitleSegments: [{ text: `address-${index}` }], + }), + ); + return { + staticRows, + records: staticRows.map((row) => ({ + item: { id: row.key }, + key: row.key, + })) as IAccountSelectorRowRecordV2[], + accountValues: Object.fromEntries( + staticRows.map((row) => [ + row.key, + { accountId: row.key, currency: 'usd', value: '1' }, + ]), + ), + accountDeFi: {}, + activeAccountValue: undefined, + context: { + walletId: 'hd-1', + networkId: 'evm--1', + enabledNetworksCompatibleWithWalletId: [], + networkInfoMap: {}, + currencyMap: { + usd: { id: 'usd', unit: '$', name: 'Dollar', type: [], value: '1' }, + }, + targetCurrency: 'usd', + hideValue: false, + }, + skipValues: false, + }; +} + +describe('account V2 formatting invalidation', () => { + it('reuses all 1000 rows for equal context references and unrelated active updates', () => { + const format = jest.fn(formatAccountSelectorValueV2); + const getRows = createAccountSelectorValueRowsV2(format); + const input = fixture(); + const initial = getRows(input); + expect(format).toHaveBeenCalledTimes(1000); + expect( + getRows({ + ...input, + context: { + ...input.context, + networkInfoMap: {}, + enabledNetworksCompatibleWithWalletId: [], + }, + activeAccountValue: { + accountId: 'another-wallet', + currency: 'usd', + value: '10', + }, + }), + ).toBe(initial); + expect(format).toHaveBeenCalledTimes(1000); + expect( + getRows({ + ...input, + accountValues: { + ...input.accountValues, + 'account-17': { + accountId: 'account-17', + currency: 'usd', + value: '2', + }, + }, + })[17].subtitleSegments?.[0].text, + ).toBe('$2.00'); + expect(format).toHaveBeenCalledTimes(1001); + }); + + it('invalidates only the old/new active accounts and preserves static row changes', () => { + const format = jest.fn(formatAccountSelectorValueV2); + const getRows = createAccountSelectorValueRowsV2(format); + const input = fixture(3); + getRows(input); + const active = { accountId: 'account-0', currency: 'usd', value: '10' }; + expect( + getRows({ ...input, activeAccountValue: active })[0].subtitleSegments?.[0] + .text, + ).toBe('$10.00'); + expect(format).toHaveBeenCalledTimes(4); + const rows = getRows({ + ...input, + activeAccountValue: { ...active, accountId: 'account-1' }, + }); + expect(rows[0].subtitleSegments?.[0].text).toBe('$1.00'); + expect(rows[1].subtitleSegments?.[0].text).toBe('$10.00'); + expect(format).toHaveBeenCalledTimes(6); + const selectedRows = getRows({ + ...input, + activeAccountValue: { ...active, accountId: 'account-1' }, + staticRows: input.staticRows.map((row) => ({ + ...row, + selected: row.key === 'account-1', + })), + }); + expect(selectedRows[1].selected).toBe(true); + expect(format).toHaveBeenCalledTimes(6); + expect(selectedRows[1].subtitleSegments?.[1].text).toBe('address-1'); + }); + + it('updates DeFi, rate and hidden-value context and evicts removed accounts', () => { + const format = jest.fn(formatAccountSelectorValueV2); + const getRows = createAccountSelectorValueRowsV2(format); + const input = fixture(2); + getRows(input); + const deFi = { 'account-0': { overview: {}, perpsNetWorthUsd: '3' } }; + expect( + getRows({ ...input, accountDeFi: deFi })[0].subtitleSegments?.[0].text, + ).toBe('$4.00'); + expect(format).toHaveBeenCalledTimes(3); + expect( + getRows({ ...input, context: { ...input.context, hideValue: true } })[0] + .subtitleSegments?.[0].text, + ).toBe('****'); + expect(format).toHaveBeenCalledTimes(5); + getRows({ ...input, staticRows: [], records: [] }); + getRows(input); + expect(format).toHaveBeenCalledTimes(7); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.ts new file mode 100644 index 000000000000..bed3ce741cbc --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.ts @@ -0,0 +1,117 @@ +import { isEqual } from 'lodash'; + +import type { + IAccountSelectorDeFiMap, + IAccountSelectorValuesMap, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; + +import { formatAccountSelectorValueV2 } from './accountSelectorValueV2'; + +import type { IAccountSelectorRowRecordV2 } from './accountSelectorAccountRowsV2'; +import type { IdentityRow } from '@onekeyfe/react-native-native-list'; + +type IValueParams = Parameters[0]; +type IFormattingContext = Omit< + IValueParams, + | 'accountValue' + | 'activeAccountValue' + | 'overview' + | 'linkedAccountId' + | 'linkedNetworkId' +> & { networkId?: string }; + +export function createAccountSelectorValueRowsV2( + formatValue = formatAccountSelectorValueV2, +) { + let context: IFormattingContext | undefined; + let previousRows: IdentityRow[] = []; + let previousStaticRows: IdentityRow[] | undefined; + const cache = new Map< + string, + { + params: Pick< + IValueParams, + | 'accountValue' + | 'activeAccountValue' + | 'overview' + | 'linkedAccountId' + | 'linkedNetworkId' + >; + value: ReturnType; + base: IdentityRow; + row: IdentityRow; + } + >(); + return ({ + staticRows, + records, + accountValues, + accountDeFi, + activeAccountValue, + context: nextContext, + skipValues, + }: { + staticRows: IdentityRow[]; + records: IAccountSelectorRowRecordV2[]; + accountValues: IAccountSelectorValuesMap[number]; + accountDeFi: IAccountSelectorDeFiMap[number]; + activeAccountValue: IValueParams['activeAccountValue']; + context: IFormattingContext; + skipValues: boolean; + }): IdentityRow[] => { + // Compare shared network/rate content once, not once per account. + if (!isEqual(context, nextContext)) { + context = nextContext; + cache.clear(); + } + if (previousStaticRows !== staticRows) { + const keys = new Set(staticRows.map((row) => row.key)); + for (const key of cache.keys()) { + if (!keys.has(key)) cache.delete(key); + } + previousStaticRows = staticRows; + } + const rows = staticRows.map((row, index) => { + const record = records[index]; + if (skipValues || record.shouldShowCreateAddressButton) return row; + const accountValue = accountValues?.[row.key]; + const params = { + accountValue, + // An active value only overrides its own account in the formatter. + activeAccountValue: + accountValue?.accountId === activeAccountValue?.accountId + ? activeAccountValue + : undefined, + overview: accountDeFi?.[row.key], + linkedAccountId: + record.indexedAccount?.associateAccount?.id ?? record.item.id, + linkedNetworkId: record.avatarNetworkId ?? nextContext.networkId, + }; + const cached = cache.get(row.key); + const value = + cached && isEqual(cached.params, params) + ? cached.value + : formatValue({ ...nextContext, ...params }); + if (cached?.base === row && cached.value === value) return cached.row; + const subtitleSegments = [value, ...(row.subtitleSegments ?? [])]; + const valueRow: IdentityRow = { + ...row, + subtitleSegments, + accessibilityLabel: [ + row.title, + ...subtitleSegments.map((segment) => segment.text), + ].join(', '), + }; + cache.set(row.key, { params, value, base: row, row: valueRow }); + return valueRow; + }); + if ( + rows.length === previousRows.length && + rows.every((row, index) => row === previousRows[index]) + ) { + return previousRows; + } + previousRows = rows; + return rows; + }; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts new file mode 100644 index 000000000000..acd24cd2a53b --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts @@ -0,0 +1,143 @@ +import { formatAccountSelectorValueV2 } from './accountSelectorValueV2'; + +const currencyMap = { + usd: { id: 'usd', unit: '$', name: 'US Dollar', type: [], value: '1' }, + cny: { id: 'cny', unit: '¥', name: 'Chinese Yuan', type: [], value: '7' }, +}; +const defaults = { + walletId: 'hd-wallet', + linkedAccountId: 'account-1', + linkedNetworkId: 'evm--1', + enabledNetworksCompatibleWithWalletId: [], + networkInfoMap: {}, + currencyMap, + targetCurrency: 'usd', + hideValue: false, +}; + +describe('account selector V2 values', () => { + it('adds scalar token, DeFi, and USD perps balances before converting display currency', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + accountValue: { accountId: 'account-1', currency: 'cny', value: '70' }, + overview: { + overview: { + 'evm--1': { + netWorth: 14, + totalValue: 14, + totalDebt: 0, + totalReward: 0, + currency: 'cny', + }, + }, + perpsNetWorthUsd: '3', + }, + }), + ).toEqual({ text: '$15.00', tone: 'secondary' }); + }); + + it('prefers the active account value without discarding the selector DeFi value', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + accountValue: { accountId: 'account-1', currency: 'usd', value: '1' }, + activeAccountValue: { + accountId: 'account-1', + currency: 'usd', + value: '20', + }, + overview: { overview: {}, perpsNetWorthUsd: '5' }, + }), + ).toEqual({ text: '$25.00', tone: 'secondary' }); + }); + + it('preserves unavailable single-network values and hides placeholders when requested', () => { + const params = { + ...defaults, + accountValue: { accountId: 'account-1', currency: 'usd', value: {} }, + }; + expect(formatAccountSelectorValueV2(params)).toEqual({ + text: '--', + tone: 'disabled', + }); + expect( + formatAccountSelectorValueV2({ ...params, hideValue: true }), + ).toEqual({ text: '****', tone: 'disabled' }); + }); + + it('sums only the linked merge-derive chain and does not add DeFi or perps', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + linkedNetworkId: 'btc--0', + mergeDeriveAssetsEnabled: true, + accountValue: { + accountId: 'account-1', + currency: 'usd', + value: { + 'account-1_btc--0': '2', + 'account-2_btc--0': '3', + 'account-1_evm--1': '100', + }, + }, + overview: { overview: {}, perpsNetWorthUsd: '50' }, + }), + ).toEqual({ text: '$5.00', tone: 'secondary' }); + }); + + it('ignores missing DeFi network entries in all-network mode', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + linkedNetworkId: 'onekeyall--0', + accountValue: { accountId: 'account-1', currency: 'usd', value: {} }, + overview: { + overview: { + 'evm--1': undefined as never, + 'evm--137': { + netWorth: 4, + totalValue: 4, + totalDebt: 0, + totalReward: 0, + currency: 'usd', + }, + }, + }, + }), + ).toEqual({ text: '$4.00', tone: 'secondary' }); + }); + + it('keeps the source currency unit until the target exchange rate is available', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + currencyMap: { usd: currencyMap.usd }, + targetCurrency: 'cny', + accountValue: { accountId: 'account-1', currency: 'usd', value: '10' }, + }), + ).toEqual({ text: '$10.00', tone: 'secondary' }); + }); + + it('preserves the zero-count subscript for small balances', () => { + expect( + formatAccountSelectorValueV2({ + ...defaults, + accountValue: { + accountId: 'account-1', + currency: 'usd', + value: '0.0000041', + }, + }), + ).toEqual({ + text: '$0.0000041', + textSegments: [ + { text: '$' }, + { text: '0.0' }, + { text: '5', style: 'subscript' }, + { text: '41' }, + ], + tone: 'secondary', + }); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts new file mode 100644 index 000000000000..e9e4e76516fb --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts @@ -0,0 +1,155 @@ +import BigNumber from 'bignumber.js'; + +import { convertFiat } from '@onekeyhq/kit/src/utils/fiatConvert'; +import type { + IAccountSelectorDeFiItem, + IAccountSelectorValueItem, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { INetworkDeriveInfo } from '@onekeyhq/kit-bg/src/vaults/types'; +import { USD_CURRENCY_ID } from '@onekeyhq/shared/src/consts/currencyConsts'; +import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; +import { + numberFormat, + numberFormatAsRenderText, +} from '@onekeyhq/shared/src/utils/numberUtils'; +import { calculateAccountTotalValue } from '@onekeyhq/shared/src/utils/tokenUtils'; +import type { ICurrencyItem, IServerNetwork } from '@onekeyhq/shared/types'; + +import type { SelectorTextSegment } from '@onekeyfe/react-native-native-list'; + +type IAccountValueV2 = { + accountValue?: IAccountSelectorValueItem; + activeAccountValue?: { + accountId: string; + currency: string; + value: Record | string; + }; + overview?: IAccountSelectorDeFiItem; + walletId: string; + linkedAccountId: string; + linkedNetworkId?: string; + mergeDeriveAssetsEnabled?: boolean; + enabledNetworksCompatibleWithWalletId: IServerNetwork[]; + networkInfoMap: Record; + currencyMap: Record; + targetCurrency: string; + hideValue: boolean; +}; + +export function formatAccountSelectorValueV2({ + accountValue, + activeAccountValue, + overview, + walletId, + linkedAccountId, + linkedNetworkId, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + currencyMap, + targetCurrency, + hideValue, +}: IAccountValueV2): SelectorTextSegment { + if (!accountValue?.currency) + return { text: hideValue ? '****' : '--', tone: 'disabled' }; + const resolved = + activeAccountValue?.accountId === accountValue.accountId + ? activeAccountValue + : accountValue; + const currency = resolved.currency ?? accountValue.currency; + const value = resolved.value ?? ''; + const perpsNetWorth = + overview?.perpsNetWorthUsd && currency + ? convertFiat({ + value: overview.perpsNetWorthUsd, + sourceCurrency: USD_CURRENCY_ID, + targetCurrency: currency, + currencyMap, + }) + : '0'; + let total: string | undefined; + if (typeof value === 'string') { + total = calculateAccountTotalValue({ + tokensValue: value, + deFiNetWorth: new BigNumber( + overview?.overview?.[linkedNetworkId ?? '']?.netWorth ?? '0', + ) + .plus(perpsNetWorth) + .toFixed(), + }); + } else if (linkedNetworkId && mergeDeriveAssetsEnabled) { + total = calculateAccountTotalValue({ + tokensValue: value, + deFiNetWorth: 0, + mergeDeriveAssetsEnabled: true, + networkId: linkedNetworkId, + }); + } else if ( + linkedAccountId && + linkedNetworkId && + !networkUtils.isAllNetwork({ networkId: linkedNetworkId }) + ) { + const deFiRaw = overview?.overview?.[linkedNetworkId]?.netWorth; + total = calculateAccountTotalValue({ + tokensValue: value, + deFiNetWorth: + deFiRaw === undefined && overview?.perpsNetWorthUsd === undefined + ? undefined + : new BigNumber(deFiRaw ?? '0').plus(perpsNetWorth).toFixed(), + accountId: linkedAccountId, + networkId: linkedNetworkId, + }); + } else { + const deFiAll = Object.values(overview?.overview ?? {}).reduce( + (sum, current) => + new BigNumber(sum).plus(current?.netWorth ?? '0').toFixed(), + perpsNetWorth, + ); + total = calculateAccountTotalValue({ + tokensValue: value, + deFiNetWorth: deFiAll, + walletId, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + }); + } + if (!total) return { text: hideValue ? '****' : '--', tone: 'disabled' }; + if (hideValue) return { text: '****', tone: 'secondary' }; + const converted = + total === '--' + ? total + : convertFiat({ + value: total, + sourceCurrency: currency, + targetCurrency, + currencyMap, + }); + const formatted = + converted === '--' + ? converted + : numberFormatAsRenderText(converted, { + formatter: 'price', + formatterOptions: { + currency: + currencyMap[targetCurrency]?.unit ?? currencyMap[currency]?.unit, + }, + }); + if (typeof formatted === 'string') + return { text: formatted, tone: 'secondary' }; + const textSegments = formatted.map((part) => + typeof part === 'string' + ? { text: part } + : { text: String(part.value), style: 'subscript' as const }, + ); + return { + text: numberFormat(converted, { + formatter: 'price', + formatterOptions: { + currency: + currencyMap[targetCurrency]?.unit ?? currencyMap[currency]?.unit, + }, + }), + textSegments, + tone: 'secondary', + }; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.test.ts new file mode 100644 index 000000000000..fd6517f5c4f4 --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.test.ts @@ -0,0 +1,259 @@ +/** @jest-environment jsdom */ +import type { + IAccountSelectorDeFiMap, + IAccountSelectorValuesMap, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { + loadAccountSelectorValuesV2, + yieldAccountSelectorValuesV2, +} from './useAccountSelectorValuesLoaderV2'; + +jest.mock('@onekeyhq/kit/src/background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: { serviceAccountSelector: {} }, +})); +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({})); + +type IBuildValues = NonNullable< + Parameters[1]['buildValues'] +>; +const accounts = (count: number, prefix = 'account') => + Array.from({ length: count }, (_, index) => ({ + accountId: `${prefix}-${index}`, + networkId: 'evm--1', + })); +const result: IBuildValues = async ({ accounts: batch }) => ({ + accountsValue: batch.map(({ accountId }) => ({ + accountId, + currency: 'usd', + value: { [`${accountId}_evm--1`]: '1' }, + })), + accountsDeFiOverview: batch.map(() => ({ + overview: {}, + perpsNetWorthUsd: '3', + })), +}); +function atom(initial: T) { + let value = initial; + const publications: T[] = []; + return { + read: () => value, + publications, + set: jest.fn(async (update: (previous: T) => T) => { + const next = update(value); + if (next !== value) publications.push(next); + value = next; + }), + }; +} +function dependencies() { + return { + valuesAtom: atom({}), + deFiAtom: atom({}), + buildValues: jest.fn(result), + yieldToUI: jest.fn(async () => undefined), + now: () => 0, + networkByNum: new Map(), + isCancelled: () => false, + }; +} + +describe('account V2 balance scheduling', () => { + it('keeps 20 service batches but publishes 4 maps for 1000 immediately resolved accounts', async () => { + const deps = dependencies(); + await loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(1000) }, + deps, + ); + expect(deps.buildValues).toHaveBeenCalledTimes(20); + expect( + deps.buildValues.mock.calls.every( + ([params]) => params.accounts.length === 50, + ), + ).toBe(true); + expect( + deps.valuesAtom.publications.map( + (value) => Object.keys(value[0] ?? {}).length, + ), + ).toEqual([50, 1000]); + expect(deps.deFiAtom.publications).toHaveLength(2); + expect(deps.yieldToUI).toHaveBeenCalledTimes(1); + deps.valuesAtom.publications.length = 0; + deps.deFiAtom.publications.length = 0; + await loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(1000) }, + deps, + ); + expect(deps.valuesAtom.publications).toHaveLength(0); + expect(deps.deFiAtom.publications).toHaveLength(0); + }); + + it('publishes and yields when the work budget expires, including failed batches', async () => { + const deps = dependencies(); + let time = 0; + deps.now = () => time; + deps.buildValues.mockImplementation(async (params) => { + time += 9; + if (params.accounts[0].accountId === 'account-50') + throw new OneKeyLocalError('batch failed'); + return result(params); + }); + await loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(150) }, + deps, + ); + expect(deps.buildValues).toHaveBeenCalledTimes(3); + expect(deps.yieldToUI).toHaveBeenCalledTimes(2); + expect(deps.valuesAtom.read()[0]?.['account-100'].value).toEqual({ + 'account-100_evm--1': '1', + }); + expect(deps.valuesAtom.read()[0]?.['account-50']).toBeUndefined(); + }); + + it('preserves refresh balances, prunes other wallets and clears stale network Perps', async () => { + const deps = dependencies(); + const input = { + num: 0, + accountsForValuesQuery: accounts(2), + linkedNetworkId: 'onekeyall--0', + }; + await loadAccountSelectorValuesV2(input, deps); + deps.valuesAtom.publications.length = 0; + deps.deFiAtom.publications.length = 0; + deps.buildValues.mockImplementation(async () => { + throw new OneKeyLocalError('offline'); + }); + await loadAccountSelectorValuesV2( + { ...input, linkedNetworkId: 'btc--0' }, + deps, + ); + expect(deps.valuesAtom.publications).toHaveLength(0); + expect(deps.valuesAtom.read()[0]?.['account-0'].value).toEqual({ + 'account-0_evm--1': '1', + }); + expect(deps.deFiAtom.read()[0]).toEqual({}); + await loadAccountSelectorValuesV2( + { ...input, accountsForValuesQuery: accounts(2, 'other-wallet') }, + deps, + ); + expect(deps.valuesAtom.read()[0]).toEqual({}); + }); + + it('drops cancelled responses and keeps concurrent selector nums isolated', async () => { + const deps = dependencies(); + let cancelled = false; + let release: (() => void) | undefined; + const oldRequest = loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(50, 'old') }, + { + ...deps, + isCancelled: () => cancelled, + buildValues: async (params) => { + await new Promise((resolve) => { + release = resolve; + }); + return result(params); + }, + }, + ); + // Let initialization reach the deferred service call. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + cancelled = true; + await Promise.all([ + loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(2, 'new') }, + deps, + ), + loadAccountSelectorValuesV2( + { num: 1, accountsForValuesQuery: accounts(2, 'second') }, + deps, + ), + ]); + release?.(); + await oldRequest; + expect(Object.keys(deps.valuesAtom.read()[0] ?? {})).toEqual([ + 'new-0', + 'new-1', + ]); + expect(Object.keys(deps.valuesAtom.read()[1] ?? {})).toEqual([ + 'second-0', + 'second-1', + ]); + }); + + it('guards a deferred empty-account cleanup after a new load starts', async () => { + const deps = dependencies(); + await loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: accounts(1) }, + deps, + ); + let cancelled = false; + let runCleanup: (() => Promise) | undefined; + const cleanup = loadAccountSelectorValuesV2( + { num: 0, accountsForValuesQuery: [] }, + { + ...deps, + isCancelled: () => cancelled, + valuesAtom: { + set: (update) => + new Promise((resolve) => { + runCleanup = async () => { + await deps.valuesAtom.set(update); + resolve(); + }; + }), + }, + }, + ); + cancelled = true; + await runCleanup?.(); + await cleanup; + expect(deps.valuesAtom.read()[0]?.['account-0'].value).toEqual({ + 'account-0_evm--1': '1', + }); + }); + + it('yields beyond a frame callback and remains bounded when rAF is suspended', async () => { + jest.useFakeTimers(); + let frameCallback: FrameRequestCallback | undefined; + const scheduler: { + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (frame: number) => void; + } = globalThis; + const raf = jest + .spyOn(scheduler, 'requestAnimationFrame') + .mockImplementation((callback) => { + frameCallback = callback; + return 1; + }); + const cancel = jest + .spyOn(scheduler, 'cancelAnimationFrame') + .mockImplementation(() => undefined); + try { + let finished = false; + const task = yieldAccountSelectorValuesV2().then(() => { + finished = true; + }); + await Promise.resolve(); + expect(finished).toBe(false); + frameCallback?.(0); + await Promise.resolve(); + expect(finished).toBe(false); + jest.advanceTimersByTime(0); + await task; + expect(finished).toBe(true); + const suspended = yieldAccountSelectorValuesV2(); + jest.advanceTimersByTime(100); + await suspended; + expect(jest.getTimerCount()).toBe(0); + } finally { + raf.mockRestore(); + cancel.mockRestore(); + jest.useRealTimers(); + } + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.ts new file mode 100644 index 000000000000..c4dd1958d083 --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.ts @@ -0,0 +1,212 @@ +import { useEffect, useRef } from 'react'; + +import { isEqual } from 'lodash'; + +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { + accountSelectorDeFiMapAtom, + accountSelectorValuesMapAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { + IAccountSelectorDeFiItem, + IAccountSelectorDeFiMap, + IAccountSelectorValueItem, + IAccountSelectorValuesMap, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; + +const BATCH_SIZE = 50; +const WORK_BUDGET_MS = 8; +const deFiNetworkByNum = new Map(); +type IBuildValues = + typeof backgroundApiProxy.serviceAccountSelector.buildAccountSelectorAccountsValuesData; +type ILoadParams = { + num: number; + accountsForValuesQuery: Parameters[0]['accounts'] | undefined; + linkedNetworkId?: string; +}; +type IAtomWriter = { set: (update: (previous: T) => T) => Promise }; + +// A resolved service Promise alone never gives the browser a paint opportunity. +export function yieldAccountSelectorValuesV2(): Promise { + return new Promise((resolve) => { + const scheduled: { + frame?: number; + afterFrame?: ReturnType; + fallback?: ReturnType; + } = {}; + const finish = () => { + if (scheduled.frame !== undefined) cancelAnimationFrame(scheduled.frame); + clearTimeout(scheduled.fallback); + clearTimeout(scheduled.afterFrame); + resolve(); + }; + // Hidden windows may suspend rAF; do not retain a cancelled loader indefinitely. + scheduled.fallback = setTimeout(finish, 100); + scheduled.frame = requestAnimationFrame(() => { + scheduled.afterFrame = setTimeout(finish, 0); + }); + }); +} + +function pruneValues( + previous: Partial>>, + num: number, + desiredIds: Set, + clear = false, +) { + const current = previous[num]; + if (!current) return previous; + if (!desiredIds.size) { + const next = { ...previous }; + delete next[num]; + return next; + } + const keys = Object.keys(current); + if (!clear && keys.every((key) => desiredIds.has(key))) return previous; + const next: Record = {}; + if (!clear) { + for (const key of keys) if (desiredIds.has(key)) next[key] = current[key]; + } + return { ...previous, [num]: next }; +} + +function mergeValues( + previous: Partial>>, + num: number, + pending: Record, +) { + const current = previous[num]; + let next = current; + for (const [key, value] of Object.entries(pending)) { + if (!isEqual(current?.[key], value)) { + if (!next || next === current) next = { ...current }; + next[key] = value; + } + } + return next === current ? previous : { ...previous, [num]: next }; +} + +export async function loadAccountSelectorValuesV2( + { num, accountsForValuesQuery, linkedNetworkId }: ILoadParams, + { + isCancelled, + valuesAtom = accountSelectorValuesMapAtom, + deFiAtom = accountSelectorDeFiMapAtom, + buildValues = (params) => + backgroundApiProxy.serviceAccountSelector.buildAccountSelectorAccountsValuesData( + params, + ), + yieldToUI = yieldAccountSelectorValuesV2, + now = () => performance.now(), + networkByNum = deFiNetworkByNum, + }: { + isCancelled: () => boolean; + valuesAtom?: IAtomWriter; + deFiAtom?: IAtomWriter; + buildValues?: IBuildValues; + yieldToUI?: () => Promise; + now?: () => number; + networkByNum?: Map; + }, +) { + const accounts = accountsForValuesQuery ?? []; + const desiredIds = new Set(accounts.map((account) => account.accountId)); + await valuesAtom.set((previous) => + isCancelled() ? previous : pruneValues(previous, num, desiredIds), + ); + if (isCancelled()) return; + const networkChanged = + networkByNum.has(num) && networkByNum.get(num) !== linkedNetworkId; + await deFiAtom.set((previous) => + isCancelled() + ? previous + : pruneValues(previous, num, desiredIds, networkChanged), + ); + if (isCancelled()) return; + if (!accounts.length) { + networkByNum.delete(num); + return; + } + networkByNum.set(num, linkedNetworkId); + + // Pending results are bounded by this account set and dropped on cancellation. + let pendingValues: Record = {}; + let pendingDeFi: Record = {}; + let hasPending = false; + let firstResult = true; + let sliceStarted = now(); + const publish = async () => { + await valuesAtom.set((previous) => + isCancelled() ? previous : mergeValues(previous, num, pendingValues), + ); + if (isCancelled()) return; + await deFiAtom.set((previous) => + isCancelled() ? previous : mergeValues(previous, num, pendingDeFi), + ); + pendingValues = {}; + pendingDeFi = {}; + hasPending = false; + }; + + for (let start = 0; start < accounts.length; start += BATCH_SIZE) { + if (isCancelled()) return; + const batch = accounts.slice(start, start + BATCH_SIZE); + try { + const { accountsValue, accountsDeFiOverview } = await buildValues({ + accounts: batch, + linkedNetworkId, + }); + if (isCancelled()) return; + for (const value of accountsValue ?? []) { + if (value && desiredIds.has(value.accountId)) + pendingValues[value.accountId] = value; + } + for (let index = 0; index < batch.length; index += 1) { + pendingDeFi[batch[index].accountId] = accountsDeFiOverview?.[index]; + } + hasPending = true; + } catch (_error) { + // A failed service batch must not prevent later accounts from loading. + } + const budgetUsed = now() - sliceStarted >= WORK_BUDGET_MS; + const lastBatch = start + BATCH_SIZE >= accounts.length; + if (hasPending && (firstResult || budgetUsed || lastBatch)) { + try { + await publish(); + } catch (_error) { + // Keep pending results for the next publication if the bridge failed. + } + firstResult = false; + if (isCancelled()) return; + if (!lastBatch) { + await yieldToUI(); + sliceStarted = now(); + } + } else if (budgetUsed && !lastBatch) { + await yieldToUI(); + sliceStarted = now(); + } + } +} + +export function useAccountSelectorValuesLoaderV2({ + num, + accountsForValuesQuery, + linkedNetworkId, +}: ILoadParams) { + const loadingIdRef = useRef(0); + useEffect(() => { + loadingIdRef.current += 1; + const loadId = loadingIdRef.current; + void loadAccountSelectorValuesV2( + { num, accountsForValuesQuery, linkedNetworkId }, + { + isCancelled: () => loadId !== loadingIdRef.current, + }, + ); + // The shared atoms outlive this view; only cancel this view's pending work. + return () => { + loadingIdRef.current += 1; + }; + }, [num, accountsForValuesQuery, linkedNetworkId]); +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBarV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBarV2.tsx new file mode 100644 index 000000000000..6e4d8b3c93e8 --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBarV2.tsx @@ -0,0 +1,632 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + type IdentityRow, + NativeList, + type NativeListActionAnchor, + type NativeListRef, + type NativeListSnapshot, + type RowModel, +} from '@onekeyfe/react-native-native-list'; +import { debounce, noop } from 'lodash'; +import { useIntl } from 'react-intl'; +import { StyleSheet, type View } from 'react-native'; + +import { + Page, + Stack, + Tooltip, + XStack, + useMedia, + useSafeAreaInsets, + useTheme, +} from '@onekeyhq/components'; +import { HeaderIconButton } from '@onekeyhq/components/src/layouts/Navigation/Header'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { useHardwareWalletConnectStatus } from '@onekeyhq/kit/src/hooks/useHardwareWalletConnectStatus'; +import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { useSelectedAccount } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import { useAccountSelectorActions } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector/actions'; +import { getBotWalletNameBadges } from '@onekeyhq/kit/src/utils/botWalletStatusUtils'; +import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types'; +import type { IAccountSelectorFocusedWallet } from '@onekeyhq/kit-bg/src/dbs/simple/entity/SimpleDbEntityAccountSelector'; +import { + useAccountSelectorStatusAtom, + useSettingsPersistAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { emptyArray } from '@onekeyhq/shared/src/consts'; +import { BOT_WALLET_STATUS_DEACTIVATED } from '@onekeyhq/shared/src/consts/dbConsts'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { swrKeys } from '@onekeyhq/shared/src/utils/swrCacheUtils'; + +import { + resolveWalletPassphraseProtection, + shouldShowCreateHiddenWalletSidebarButtonForWallet, +} from '../../../components/WalletEdit/WalletEditButtonUtils'; +import { useAccountSelectorRoute } from '../../../router/useAccountSelectorRoute'; +import { AccountManagerTestIDs } from '../../../testIDs'; +import { + accountSelectorWalletVisualV2, + useAccountSelectorNativeListThemeV2, +} from '../accountSelectorNativeListV2'; +import { useAddHiddenWallet } from '../WalletDetails/hooks/useAddHiddenWallet'; + +import { AccountSelectorCreateWalletButton } from './AccountSelectorCreateWalletButton'; +import { buildGroupedAccountSelectorWallets } from './walletListUtils'; + +import type { IAccountSelectorWalletInfo } from '../../../type'; + +interface IWalletListProps { + num: number; + hideNonBackedUpWallet?: boolean; +} + +export function AccountSelectorWalletListSideBarV2({ + num, + hideNonBackedUpWallet, +}: IWalletListProps) { + const { serviceAccount } = backgroundApiProxy; + const { bottom, top } = useSafeAreaInsets(); + const actions = useAccountSelectorActions(); + const route = useAccountSelectorRoute(); + // const linkNetwork = route.params?.linkNetwork; + const isEditableRouteParams = route.params?.editable; + const { selectedAccount } = useSelectedAccount({ num }); + const focusWalletChanged = useRef(false); + + // Detect connected hardware wallets via WebUSB + // Note: connectedDevices reference is stable - only changes when device list actually changes + const { isWalletConnected } = useHardwareWalletConnectStatus(); + + const [layoutRefreshTS, setLayoutRefreshTS] = useState(0); + useEffect(() => { + const fn = debounce( + () => { + setLayoutRefreshTS((ts) => ts + 1); + }, + 600, + { + leading: false, + trailing: true, + }, + ); + appEventBus.on(EAppEventBusNames.HardwareDeviceStateUpdate, fn); + appEventBus.on(EAppEventBusNames.HardwareFeaturesUpdate, fn); + return () => { + appEventBus.off(EAppEventBusNames.HardwareDeviceStateUpdate, fn); + appEventBus.off(EAppEventBusNames.HardwareFeaturesUpdate, fn); + }; + }, []); + const [accountSelectorStatus] = useAccountSelectorStatusAtom(); + const reloadWalletsHook = `${layoutRefreshTS}-${ + accountSelectorStatus?.passphraseProtectionChangedAt ?? 0 + }`; + + // Sidebar SWR cache. Invalidation is handled outside this component: + // - Wallet/Account CRUD funnels through WalletUpdate / AccountUpdate + // (see ServiceAccount emits) — listeners below call reloadWallets, + // which runs the fetcher and overwrites this slot via usePromiseResult. + // - OneKey state / third-party features / passphrase toggle flow through + // reloadWalletsHook -> useEffect refetch -> same overwrite path. + // - Bulk wipes (ServiceApp.resetApp, ServiceE2E.clearWalletsAndAccounts) + // clear the cold-start cache in the bg service before emitting the + // wipe event, so this hook reads an empty MMKV on next mount. + const walletsSwrKey = swrKeys.walletListSideBar({ hideNonBackedUpWallet }); + + const { + result: walletsResult, + setResult, + run: reloadWallets, + } = usePromiseResult< + | { + wallets: IAccountSelectorWalletInfo[]; + } + | undefined + >( + async () => { + noop(reloadWalletsHook); + defaultLogger.accountSelector.perf.buildWalletListSideBarData(); + const r = await serviceAccount.getWallets({ + nestedHiddenWallets: true, + ignoreEmptySingletonWalletAccounts: true, + ignoreNonBackedUpWallets: hideNonBackedUpWallet, + }); + + const botWalletEntries = await Promise.all( + r.wallets.map(async (wallet) => { + const isBotWallet = accountUtils.isBotWallet({ walletId: wallet.id }); + if (!isBotWallet) { + return { + wallet, + isBotWallet, + isBotDeactivated: false, + }; + } + + const meta = + await backgroundApiProxy.serviceAccount.getBotWalletMetadata( + wallet.id, + ); + if (!meta?.visible) { + return null; + } + + return { + wallet, + isBotWallet, + isBotDeactivated: meta.status === BOT_WALLET_STATUS_DEACTIVATED, + }; + }), + ); + + const filteredWalletEntries = botWalletEntries.filter( + ( + entry, + ): entry is { + wallet: IDBWallet; + isBotWallet: boolean; + isBotDeactivated: boolean; + } => Boolean(entry), + ); + + const wallets = buildGroupedAccountSelectorWallets(filteredWalletEntries); + + return { + wallets, + }; + }, + [serviceAccount, hideNonBackedUpWallet, reloadWalletsHook], + { + checkIsFocused: false, + swrKey: walletsSwrKey, + }, + ); + + const wallets = walletsResult?.wallets ?? emptyArray; + + defaultLogger.accountSelector.perf.renderWalletListSideBar({ + selectedAccount, + walletsCount: wallets?.length ?? 0, + }); + + useEffect(() => { + if ( + walletsResult?.wallets && + hideNonBackedUpWallet && + !focusWalletChanged.current + ) { + const backedUpWalletsMap = walletsResult.wallets.reduce( + (acc, wallet) => { + acc[wallet.id] = wallet; + wallet.hiddenWallets?.forEach((hiddenWallet) => { + acc[hiddenWallet.id] = hiddenWallet; + }); + wallet.botWallets?.forEach((botWallet) => { + acc[botWallet.id] = botWallet; + }); + return acc; + }, + {} as Record, + ); + + if ( + !backedUpWalletsMap[selectedAccount.focusedWallet ?? ''] && + !backedUpWalletsMap[selectedAccount.walletId ?? ''] + ) { + void actions.current.updateSelectedAccountFocusedWallet({ + num, + focusedWallet: walletsResult.wallets[0]?.id, + }); + } + + focusWalletChanged.current = true; + } + }, [ + walletsResult?.wallets, + actions, + num, + selectedAccount, + hideNonBackedUpWallet, + ]); + + useEffect(() => { + const fn = async () => { + await reloadWallets(); + }; + appEventBus.on(EAppEventBusNames.WalletUpdate, fn); + appEventBus.on(EAppEventBusNames.AccountUpdate, fn); + return () => { + appEventBus.off(EAppEventBusNames.WalletUpdate, fn); + appEventBus.off(EAppEventBusNames.AccountUpdate, fn); + }; + }, [reloadWallets]); + + const onWalletPress = useCallback( + (focusedWallet: IAccountSelectorFocusedWallet) => { + void actions.current.updateSelectedAccountFocusedWallet({ + num, + focusedWallet, + }); + }, + [actions, num], + ); + + const [settings, setSettings] = useSettingsPersistAtom(); + const intl = useIntl(); + const theme = useAccountSelectorNativeListThemeV2(true); + const appTheme = useTheme(); + const { + createHiddenWalletWithDialogConfirm, + isLoading: isAddingHiddenWallet, + } = useAddHiddenWallet(); + useEffect(() => { + if (settings.showAddHiddenInWalletSidebar === undefined) { + setSettings((prev) => ({ ...prev, showAddHiddenInWalletSidebar: true })); + } + }, [settings.showAddHiddenInWalletSidebar, setSettings]); + + const shouldShowCreateHiddenWalletButtonFn = useCallback( + ({ wallet }: { wallet: IDBWallet | undefined }) => { + noop(reloadWalletsHook); + if (!wallet) return false; + const deviceInfo = wallet.associatedDeviceInfo; + return shouldShowCreateHiddenWalletSidebarButtonForWallet({ + isEditableRouteParams: !!isEditableRouteParams, + showAddHiddenInWalletSidebar: settings.showAddHiddenInWalletSidebar, + isDeprecated: wallet.deprecated, + isHiddenWallet: accountUtils.isHwHiddenWallet({ wallet }), + isHwOrQrWallet: accountUtils.isHwOrQrWallet({ walletId: wallet.id }), + isHwWallet: accountUtils.isHwWallet({ + walletId: wallet.id, + }), + isQrWallet: accountUtils.isQrWallet({ + walletId: wallet.id, + }), + hasPassphraseProtection: resolveWalletPassphraseProtection({ + deviceState: deviceInfo?.deviceStateInfo, + features: deviceInfo?.featuresInfo, + }), + hiddenWalletsLength: wallet.hiddenWallets?.length ?? 0, + vendor: deviceInfo?.vendor, + }); + }, + [ + isEditableRouteParams, + settings.showAddHiddenInWalletSidebar, + reloadWalletsHook, + ], + ); + + const { md } = useMedia(); + const listRef = useRef(null); + const containerRef = useRef(null); + const tooltipTokenRef = useRef(undefined); + const [walletTooltip, setWalletTooltip] = useState<{ + anchor: NativeListActionAnchor; + left: number; + top: number; + title: string; + }>(); + const closeWalletTooltip = useCallback(() => { + const token = tooltipTokenRef.current; + tooltipTokenRef.current = undefined; + if (token) listRef.current?.setActionAnchorState({ token, open: false }); + setWalletTooltip(undefined); + }, []); + useEffect(() => { + if (!md) closeWalletTooltip(); + const list = listRef.current; + return () => { + const token = tooltipTokenRef.current; + if (token) list?.setActionAnchorState({ token, open: false }); + }; + }, [closeWalletTooltip, md]); + + // Pre-compute wallet connection status map for stable reference + const walletConnectionMap = useMemo(() => { + const map = new Map(); + wallets.forEach((wallet) => { + // Deprecated wallets should not show connection status + if (wallet.deprecated) { + map.set(wallet.id, false); + return; + } + // Hidden wallets (passphrase wallets) should never show connection status + if (accountUtils.isHwHiddenWallet({ wallet })) { + map.set(wallet.id, false); + return; + } + + const isHwWallet = accountUtils.isHwWallet({ walletId: wallet.id }); + const isConnected = isHwWallet ? isWalletConnected(wallet) : false; + map.set(wallet.id, isConnected); + }); + return map; + }, [wallets, isWalletConnected]); + + const isShowCloseButton = md && !platformEnv.isNativeIOS; + const shouldHideWalletList = + walletsResult !== undefined && + wallets.length === 0 && + !isEditableRouteParams; + + const { snapshot, walletIds, walletNames, hiddenWalletActions } = + useMemo(() => { + const ids = new Set(); + const names = new Map(); + const hiddenActions = new Map(); + const toIdentity = ( + wallet: IAccountSelectorWalletInfo, + badge?: string | number, + ): IdentityRow => { + ids.add(wallet.id); + names.set(wallet.id, wallet.name); + const badges = getBotWalletNameBadges({ + isBotWallet: accountUtils.isBotWallet({ walletId: wallet.id }), + isBotWalletDeactivated: + wallet.botStatus === BOT_WALLET_STATUS_DEACTIVATED, + }); + return { + type: 'identity', + key: wallet.id, + testID: `wallet-${wallet.id}`, + presentation: 'walletSidebar', + height: badges.length ? 90 : 68, + title: wallet.name, + titleActionKey: + md && !platformEnv.isNative ? 'wallet.tooltip.name' : undefined, + titleActionOnHover: md && !platformEnv.isNative, + leading: accountSelectorWalletVisualV2({ + wallet, + connected: walletConnectionMap.get(wallet.id), + badge: badge ?? wallet.badge, + badgeBackground: appTheme.bgApp.val, + connectionColor: appTheme.bgSuccessStrong.val, + theme, + }), + selected: selectedAccount.focusedWallet === wallet.id, + opacity: wallet.deprecated ? 0.5 : 1, + badges: badges.map((item) => ({ + key: item.key, + text: item.label, + tone: item.tone === 'caution' ? 'warning' : 'neutral', + })), + draggable: true, + accessibilityLabel: [ + wallet.name, + ...badges.map((item) => item.label), + ].join(', '), + }; + }; + const getChildWallets = (wallet: IAccountSelectorWalletInfo) => { + if (accountUtils.isHwOrQrWallet({ walletId: wallet.id })) + return wallet.hiddenWallets ?? []; + if (wallet.isKeyless) return wallet.botWallets ?? []; + return []; + }; + const rows: RowModel[] = wallets.map((wallet) => { + const parent = toIdentity(wallet); + const childWallets = getChildWallets(wallet); + const hasGroup = + !accountUtils.isHwHiddenWallet({ wallet }) && + (childWallets.length > 0 || + shouldShowCreateHiddenWalletButtonFn({ wallet })); + if (!hasGroup) return parent; + const children = (childWallets ?? []).map((child, index) => + toIdentity( + child, + md && accountUtils.isHwOrQrWallet({ walletId: wallet.id }) + ? index + 1 + : undefined, + ), + ); + if (shouldShowCreateHiddenWalletButtonFn({ wallet })) { + const key = `add-hidden:${wallet.id}`; + hiddenActions.set(key, wallet); + const title = intl.formatMessage({ + id: ETranslations.global_hidden_wallet, + }); + names.set(key, title); + children.push({ + type: 'identity', + key, + presentation: 'walletSidebar', + height: 68, + title, + titleActionKey: + md && !platformEnv.isNative ? 'wallet.tooltip.name' : undefined, + titleActionOnHover: md && !platformEnv.isNative, + leading: { + kind: 'wallet', + backgroundColor: '#00000000', + shape: 'circle', + borderStyle: 'dashed', + borderColor: theme.separator, + fallbackIcon: { + name: 'PlusSmallOutline', + tintColor: theme.iconSubdued, + }, + }, + draggable: false, + }); + } + return { + type: 'walletGroup', + key: wallet.id, + parent, + children, + draggable: true, + }; + }); + return { + walletIds: ids, + walletNames: names, + hiddenWalletActions: hiddenActions, + snapshot: { + schemaVersion: 1, + generation: 1, + theme, + layout: { + kind: 'linear', + contentPaddingHorizontal: 8, + contentPaddingTop: 8, + contentPaddingBottom: 8, + itemSpacing: 12, + }, + rows, + // cspell:ignore reorderable + capabilities: { reorderable: true }, + } satisfies NativeListSnapshot, + }; + }, [ + appTheme, + intl, + md, + selectedAccount.focusedWallet, + shouldShowCreateHiddenWalletButtonFn, + theme, + walletConnectionMap, + wallets, + ]); + + if (shouldHideWalletList) { + return null; + } + + return ( + + {/* Close action */} + {isShowCloseButton ? ( + + + + + + ) : null} + { + if (event.token === tooltipTokenRef.current) closeWalletTooltip(); + }} + onRowAction={(event) => { + if (event.actionKey === 'wallet.tooltip.name' && event.anchor) { + const title = walletNames.get(event.rowKey ?? ''); + if (!title || platformEnv.isNative || !md) return; + closeWalletTooltip(); + const { anchor } = event; + tooltipTokenRef.current = anchor.token; + listRef.current?.setActionAnchorState({ + token: anchor.token, + open: true, + }); + containerRef.current?.measureInWindow((x, y) => { + if (tooltipTokenRef.current !== anchor.token) return; + setWalletTooltip({ + anchor, + left: anchor.windowRect.x - x, + top: anchor.windowRect.y - y, + title, + }); + }); + return; + } + if (!event.rowKey || event.actionKey !== 'press') return; + closeWalletTooltip(); + const walletToAdd = hiddenWalletActions.get(event.rowKey); + if (walletToAdd) { + if (!isAddingHiddenWallet) + void createHiddenWalletWithDialogConfirm({ wallet: walletToAdd }); + return; + } + if (walletIds.has(event.rowKey)) onWalletPress(event.rowKey); + }} + onReorder={async (event) => { + if (!walletsResult) return; + const fromIndex = wallets.findIndex( + (wallet) => wallet.id === event.key, + ); + if (fromIndex < 0) return; + const reordered = [...wallets]; + const [moved] = reordered.splice(fromIndex, 1); + const toIndex = Math.max( + 0, + Math.min(event.toIndex, reordered.length), + ); + reordered.splice(toIndex, 0, moved); + setResult({ wallets: reordered }); + await serviceAccount.insertWalletOrder({ + targetWalletId: moved.id, + startWalletId: reordered[toIndex - 1]?.id, + endWalletId: reordered[toIndex + 1]?.id, + emitEvent: true, + }); + }} + /> + {walletTooltip ? ( + + { + if (!open) closeWalletTooltip(); + }} + renderContent={walletTooltip.title} + renderTrigger={ + + } + /> + + ) : null} + {/* Others */} + {isEditableRouteParams ? ( + + + {/* */} + + ) : null} + + ); +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.test.ts new file mode 100644 index 000000000000..4b8ebad10cce --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.test.ts @@ -0,0 +1,34 @@ +import { ANDROID_PACKAGE_NAME } from '@onekeyhq/shared/src/config/appConfig'; + +import { accountSelectorAssetUriV2 } from './accountSelectorNativeListV2'; + +let mockResolvedAssetUri: string | undefined; + +jest.mock('react-native', () => ({ + Image: { + resolveAssetSource: () => + mockResolvedAssetUri ? { uri: mockResolvedAssetUri } : undefined, + }, + Platform: { OS: 'android' }, +})); + +describe('accountSelectorAssetUriV2', () => { + beforeEach(() => { + mockResolvedAssetUri = undefined; + }); + + it('converts an Android release drawable name to a loadable resource URI', () => { + mockResolvedAssetUri = 'wallet_avatar_bear'; + + expect(accountSelectorAssetUriV2(1)).toBe( + `android.resource://${ANDROID_PACKAGE_NAME}/drawable/wallet_avatar_bear`, + ); + }); + + it('keeps a Metro development asset URL unchanged', () => { + const uri = 'http://10.0.2.2:8088/assets/wallet/avatar/Bear.png'; + mockResolvedAssetUri = uri; + + expect(accountSelectorAssetUriV2(1)).toBe(uri); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts new file mode 100644 index 000000000000..7f5544841438 --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts @@ -0,0 +1,265 @@ +import { useMemo } from 'react'; + +import { EFirmwareType } from '@onekeyfe/hd-shared'; +import { Image, Platform } from 'react-native'; + +import { useTheme } from '@onekeyhq/components'; +import { buildOptimizedImageSource } from '@onekeyhq/components/src/primitives/Image/optimization'; +import { getWalletAvatarProvider } from '@onekeyhq/kit/src/components/WalletAvatar/getWalletAvatarProvider'; +import type { + IDBAccount, + IDBExternalAccount, + IDBIndexedAccount, + IDBWallet, +} from '@onekeyhq/kit-bg/src/dbs/local/types'; +import { ANDROID_PACKAGE_NAME } from '@onekeyhq/shared/src/config/appConfig'; +import { presetNetworksMap } from '@onekeyhq/shared/src/config/presetNetworks'; +import { EOAuthSocialLoginProvider } from '@onekeyhq/shared/src/consts/authConsts'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { AllWalletAvatarImages } from '@onekeyhq/shared/src/utils/avatarUtils'; +import externalWalletLogoUtils from '@onekeyhq/shared/src/utils/externalWalletLogoUtils'; + +import type { + LeadingVisual, + NativeListTheme, +} from '@onekeyfe/react-native-native-list'; +import type { ImageSourcePropType } from 'react-native'; + +export function useAccountSelectorNativeListThemeV2( + sidebar = false, +): NativeListTheme { + const theme = useTheme(); + return useMemo( + () => ({ + background: sidebar ? theme.bgSubdued.val : theme.bgApp.val, + rowBackground: sidebar ? theme.bgSubdued.val : theme.bgApp.val, + rowSelectedBackground: theme.bgActive.val, + rowPressedBackground: theme.bgActive.val, + subduedBackground: theme.bgSubdued.val, + strongBackground: theme.bgStrong.val, + primaryText: theme.text.val, + secondaryText: theme.textSubdued.val, + disabledText: theme.textDisabled.val, + icon: theme.icon.val, + iconSubdued: theme.iconSubdued.val, + separator: theme.borderSubdued.val, + accent: theme.iconActive.val, + positive: theme.textSuccess.val, + negative: theme.textCritical.val, + inverseBackground: theme.bgInverse.val, + inverseText: theme.textInverse.val, + info: theme.textInfo.val, + caution: theme.textCaution.val, + cautionBackground: theme.bgCautionSubdued.val, + }), + [sidebar, theme], + ); +} + +export function accountSelectorAssetUriV2( + source: ImageSourcePropType | string, +): string { + if (typeof source === 'string') return source; + const uri = Image.resolveAssetSource(source)?.uri ?? ''; + if (Platform.OS === 'android' && uri && !uri.includes(':')) { + return `android.resource://${ANDROID_PACKAGE_NAME}/drawable/${uri}`; + } + return uri; +} + +function accountSelectorRemoteImageV2(uri: string, size: number) { + const optimized = platformEnv.isNative + ? undefined + : buildOptimizedImageSource({ + source: { uri }, + resolvedSource: { uri }, + width: size, + height: size, + allowRelativeUrl: platformEnv.isWeb || platformEnv.isWebEmbed, + }); + return { + uri: optimized?.source?.uri ?? uri, + width: size, + height: size, + retryTimes: 1, + ...(optimized?.optimized ? { fallbackUri: optimized.rawUri } : {}), + }; +} + +export function accountSelectorWalletVisualV2({ + wallet, + connected, + badge, + badgeBackground, + connectionColor, + theme, +}: { + wallet: IDBWallet; + connected?: boolean; + badge?: string | number; + badgeBackground: string; + connectionColor: string; + theme: NativeListTheme; +}): LeadingVisual { + const overlays: NonNullable< + Extract['overlays'] + >[number][] = []; + if (wallet.firmwareTypeAtCreated === EFirmwareType.BitcoinOnly) { + overlays.push({ + position: 'topLeft', + width: 18, + height: 16, + padding: 1, + offsetX: 0, + offsetY: 4, + image: { + ...accountSelectorRemoteImageV2(presetNetworksMap.btc.logoURI, 14), + contentFit: 'contain', + }, + }); + } + let name: string | undefined; + if (accountUtils.isBotWallet({ walletId: wallet.id })) name = 'BotIllus'; + else if (wallet.isKeyless) { + name = + getWalletAvatarProvider(wallet) === EOAuthSocialLoginProvider.Google + ? 'GoogleIllus' + : 'AppleBrand'; + } else if (connected) name = 'Circle'; + if (name || badge !== undefined) { + let size: number | undefined; + let tintColor = theme.primaryText; + if (name) { + size = 18; + tintColor = theme.icon ?? theme.primaryText; + } + if (name === 'Circle') { + size = 14; + tintColor = connectionColor; + } else if (name === 'AppleBrand') { + tintColor = theme.accent; + } + overlays.push({ + position: 'bottomRight', + size, + height: name ? undefined : 16, + padding: name ? 2 : undefined, + offsetX: name ? 2 : 1, + offsetY: 2, + name, + text: name ? undefined : String(badge), + tintColor, + backgroundColor: + name && name !== 'Circle' ? badgeBackground : theme.subduedBackground, + }); + } + if (accountUtils.isHwHiddenWallet({ wallet })) { + return { + kind: 'wallet', + backgroundColor: '#00000000', + fallbackIcon: { name: 'LockSolid', tintColor: theme.icon }, + overlays, + }; + } + const imageName = wallet.avatarInfo?.img; + return { + kind: 'wallet', + shape: 'square', + backgroundColor: '#00000000', + image: imageName + ? { + uri: accountSelectorAssetUriV2( + AllWalletAvatarImages[imageName] ?? AllWalletAvatarImages.bear, + ), + width: 40, + height: 40, + contentFit: 'cover', + retryTimes: 1, + } + : undefined, + fallbackText: wallet.avatarInfo?.emoji ?? '', + overlays, + }; +} + +export function accountSelectorAccountVisualV2({ + account, + indexedAccount, + network, + theme, +}: { + account?: IDBAccount; + indexedAccount?: IDBIndexedAccount; + network?: { + logoURI?: string; + isCustomNetwork?: boolean; + isAllNetworks?: boolean; + name?: string; + }; + theme: NativeListTheme; +}): LeadingVisual { + let uri: string | undefined; + if (account && accountUtils.isExternalAccount({ accountId: account.id })) { + const external = account as IDBExternalAccount; + const peerMeta = external.connectionInfo?.walletConnect?.peerMeta; + const externalLogo = + (peerMeta + ? externalWalletLogoUtils.getLogoInfoFromWalletConnect({ peerMeta }) + .logo + : undefined) || + external.connectionInfo?.evmEIP6963?.info?.icon || + external.connectionInfo?.evmInjected?.icon || + peerMeta?.icons?.[0] || + (external.connectionInfo?.walletConnect + ? externalWalletLogoUtils.getLogoInfo('walletconnect').logo + : undefined); + uri = externalLogo ? accountSelectorAssetUriV2(externalLogo) : undefined; + } + if (!uri) { + const seed = ( + indexedAccount?.idHash || + indexedAccount?.id || + account?.address || + '' + ).replaceAll(':', ''); + if (seed) { + // The image loader owns generation and persistent caching, outside row construction. + uri = `onekey-avatar://blockie/v1/${encodeURIComponent(seed.toLowerCase())}`; + } + } + return { + kind: 'account', + shape: 'rounded', + image: uri + ? { uri, width: 32, height: 32, contentFit: 'contain', retryTimes: 1 } + : undefined, + backgroundColor: theme.strongBackground, + fallbackIcon: { + name: + account && accountUtils.isExternalAccount({ accountId: account.id }) + ? 'AccountErrorCustom' + : 'CrossedSmallSolid', + tintColor: theme.secondaryText, + }, + overlays: network + ? [ + { + position: 'bottomRight', + size: 20, + padding: 2, + offset: 4, + backgroundColor: theme.rowBackground, + image: + !network.isCustomNetwork && + !network.isAllNetworks && + network.logoURI + ? accountSelectorRemoteImageV2(network.logoURI, 16) + : undefined, + text: network.isCustomNetwork ? network.name?.[0] : undefined, + name: network.isAllNetworks ? 'AllNetworksSolid' : undefined, + }, + ] + : undefined, + }; +} diff --git a/packages/kit/src/views/AccountManagerStacks/router/index.tsx b/packages/kit/src/views/AccountManagerStacks/router/index.tsx index 97ff10bd21ef..6ba1067e20c6 100644 --- a/packages/kit/src/views/AccountManagerStacks/router/index.tsx +++ b/packages/kit/src/views/AccountManagerStacks/router/index.tsx @@ -4,7 +4,10 @@ import type { IAccountManagerStacksParamList } from '@onekeyhq/shared/src/routes import { EAccountManagerStacksRoutes } from '@onekeyhq/shared/src/routes/accountManagerStacks'; const AccountSelectorStackPage = LazyLoadPage( - () => import('../pages/AccountSelectorStack'), + () => import('../pages/AccountSelectorStack/AccountSelectorStackV2'), + undefined, + undefined, + false, ); const ExportPrivateKeys = LazyLoadPage( diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkContentV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkContentV2.tsx new file mode 100644 index 000000000000..10cf1fba2d00 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkContentV2.tsx @@ -0,0 +1,194 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useEffect, useMemo } from 'react'; + +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { swrKeys } from '@onekeyhq/shared/src/utils/swrCacheUtils'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +import { NetworkSectionListV2 } from './NetworkSectionListV2'; + +const defaultChainSelectorNetworks: { + mainnetItems: IServerNetwork[]; + testnetItems: IServerNetwork[]; + unavailableItems: IServerNetwork[]; + frequentlyUsedItems: IServerNetwork[]; + allNetworkItem?: IServerNetwork; +} = { + mainnetItems: [], + testnetItems: [], + unavailableItems: [], + frequentlyUsedItems: [], +}; + +type INetworkContentPropsV2 = { + walletId?: string; + accountId?: string; + indexedAccountId?: string; + networkId?: string; + networkIds?: string[]; + onPressItem?: (network: IServerNetwork) => void; + onEditCustomNetwork?: (network: IServerNetwork) => void; + searchText?: string; + setSearchText?: Dispatch>; + accountAddress?: string; +}; + +export function NetworkContentV2({ + walletId, + accountId, + indexedAccountId, + networkId, + networkIds, + onPressItem, + onEditCustomNetwork, + searchText, + setSearchText, +}: INetworkContentPropsV2) { + // Stable hash of networkIds so the swrKey doesn't churn when the caller + // passes a fresh array reference with unchanged contents. + const networkIdsKey = useMemo(() => { + if (!networkIds) return undefined; + return networkIds.toSorted().join(','); + }, [networkIds]); + + const swrKey = useMemo( + () => + swrKeys.networkContentData({ + walletId, + accountId, + indexedAccountId, + networkIdsKey, + }), + [walletId, accountId, indexedAccountId, networkIdsKey], + ); + + const { + result: { + chainSelectorNetworks, + accountNetworkValues, + accountNetworkValueCurrency, + accountDeFiOverview, + zeroValue, + }, + run: refreshLocalData, + } = usePromiseResult( + async () => { + const [_accountsValue, _chainSelectorNetworks, _localDeFiOverview] = + await Promise.all([ + backgroundApiProxy.serviceAccountProfile.getAllNetworkAccountsValueByAccountId( + { accountId: indexedAccountId ?? accountId ?? '' }, + ), + backgroundApiProxy.serviceNetwork.getChainSelectorNetworksCompatibleWithAccountId( + { + accountId, + walletId, + networkIds, + useDefaultPinnedNetworks: true, + }, + ), + backgroundApiProxy.serviceDeFi.getAccountsLocalDeFiOverview({ + accounts: [ + { + accountId: indexedAccountId ?? accountId ?? '', + networkId: getNetworkIdsMap().onekeyall, + indexedAccountId, + }, + ], + networksEnabledOnly: false, + }), + ]); + + if (_accountsValue || _localDeFiOverview[0]) { + const { + chainSelectorNetworks: sortedChainSelectorNetworks, + formattedAccountNetworkValues, + accountDeFiOverview: _accountDeFiOverview, + // eslint-disable-next-line @typescript-eslint/no-shadow + zeroValue, + } = await backgroundApiProxy.serviceNetwork.sortChainSelectorNetworksByValue( + { + walletId: accountUtils.getWalletIdFromAccountId({ + accountId: _accountsValue?.accountId ?? '', + }), + chainSelectorNetworks: _chainSelectorNetworks, + accountNetworkValues: _accountsValue?.value ?? {}, + localDeFiOverview: _localDeFiOverview[0]?.overview ?? {}, + }, + ); + + return { + chainSelectorNetworks: sortedChainSelectorNetworks, + accountNetworkValues: formattedAccountNetworkValues, + accountNetworkValueCurrency: _accountsValue?.currency, + accountDeFiOverview: _accountDeFiOverview, + zeroValue, + }; + } + + return { + chainSelectorNetworks: _chainSelectorNetworks, + accountNetworkValues: {}, + accountDeFiOverview: {}, + zeroValue: true, + }; + }, + [accountId, networkIds, walletId, indexedAccountId], + { + initResult: { + chainSelectorNetworks: defaultChainSelectorNetworks, + accountNetworkValues: {}, + accountDeFiOverview: {}, + zeroValue: true, + }, + swrKey, + }, + ); + + useEffect(() => { + const fn = async () => { + try { + // Use alwaysSetState to bypass the isFocused check, because this + // event can fire while the navigation-back animation is still + // running (screen not yet focused), which would silently skip + // the refresh and leave stale data in the search list. + await refreshLocalData({ alwaysSetState: true }); + } catch { + // silently ignore refresh errors + } + }; + appEventBus.on(EAppEventBusNames.AddedCustomNetwork, fn); + return () => { + appEventBus.off(EAppEventBusNames.AddedCustomNetwork, fn); + }; + }, [refreshLocalData]); + + return ( + + ); +} diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkSectionListV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkSectionListV2.tsx new file mode 100644 index 000000000000..500c8bbef191 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkSectionListV2.tsx @@ -0,0 +1,416 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { NativeList } from '@onekeyfe/react-native-native-list'; +import BigNumber from 'bignumber.js'; +import { useIntl } from 'react-intl'; + +import { + Empty, + SearchBar, + Stack, + useSafeAreaInsets, +} from '@onekeyhq/components'; +import { NETWORK_SHOW_VALUE_THRESHOLD_USD } from '@onekeyhq/shared/src/consts/networkConsts'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EAppSWRCacheScopes } from '@onekeyhq/shared/src/storage/syncStorageKeys'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +import { useFuseSearch } from '../../hooks/useFuseSearch'; +import { ChainSelectorTestIDs } from '../../testIDs'; +import RecentNetworks from '../RecentNetworks'; + +import { + getNetworkValueV2, + useNetworkListPresentationV2, +} from './useNetworkListPresentationV2'; +import { useNetworkTooltipV2 } from './useNetworkTooltipV2'; + +import type { IServerNetworkMatch } from '../../types'; +import type { + IdentityRow, + NativeListRef, + NativeListSnapshot, + RowActionEvent, + RowModel, + TrailingAccessory, +} from '@onekeyfe/react-native-native-list'; + +type INetworkSectionV2 = { + key: string; + title?: string; + data: IServerNetworkMatch[]; + unavailable?: boolean; +}; + +type INetworkSectionListPropsV2 = { + recentNetworksEnabled?: boolean; + accountNetworkValues: Record; + mainnetItems: IServerNetwork[]; + testnetItems: IServerNetwork[]; + unavailableItems: IServerNetwork[]; + frequentlyUsedItems: IServerNetwork[]; + allNetworkItem?: IServerNetwork; + networkId?: string; + walletId?: string; + accountId?: string; + indexedAccountId?: string; + onPressItem?: (network: IServerNetwork) => void; + onEditCustomNetwork?: (network: IServerNetwork) => void; + accountNetworkValueCurrency?: string; + accountDeFiOverview: Record; + showAllNetworkInRecentNetworks?: boolean; + zeroValue?: boolean; + searchText?: string; + setSearchText?: Dispatch>; +}; + +const LIST_STYLE_V2 = { flex: 1 }; + +export function NetworkSectionListV2({ + recentNetworksEnabled, + walletId, + accountId, + accountNetworkValues, + accountNetworkValueCurrency, + mainnetItems, + testnetItems, + frequentlyUsedItems, + unavailableItems, + onPressItem, + onEditCustomNetwork, + networkId, + allNetworkItem, + accountDeFiOverview, + showAllNetworkInRecentNetworks, + zeroValue, + searchText: searchTextProp, + setSearchText: setSearchTextProp, +}: INetworkSectionListPropsV2) { + const intl = useIntl(); + const { bottom } = useSafeAreaInsets(); + const [searchTextLocal, setSearchTextLocal] = useState(''); + const searchText = searchTextProp ?? searchTextLocal; + const setSearchText = setSearchTextProp ?? setSearchTextLocal; + const listRef = useRef(null); + const { + containerRef, + showTooltip, + closeTooltip, + onActionAnchorInvalidated, + tooltipElement, + } = useNetworkTooltipV2(listRef); + const { nativeTheme, formatCurrencyValue, getNetworkLeading } = + useNetworkListPresentationV2(accountNetworkValueCurrency); + + const availableNetworks = useMemo( + () => [ + ...mainnetItems, + ...testnetItems, + ...(allNetworkItem ? [allNetworkItem] : []), + ], + [allNetworkItem, mainnetItems, testnetItems], + ); + const networksToSearch = useMemo( + () => [ + ...(allNetworkItem ? [allNetworkItem] : []), + ...mainnetItems, + ...testnetItems, + ], + [allNetworkItem, mainnetItems, testnetItems], + ); + const networkFuseSearch = useFuseSearch(networksToSearch); + + const sections = useMemo(() => { + if (searchText) { + const data = networkFuseSearch(searchText); + return data.length ? [{ key: 'network-search', data }] : []; + } + const frequentlyUsedIds = new Set( + frequentlyUsedItems.map((item) => item.id), + ); + const groups = mainnetItems.reduce>( + (result, item) => { + if (!frequentlyUsedIds.has(item.id)) { + const letter = item.name[0].toUpperCase(); + (result[letter] ??= []).push(item); + } + return result; + }, + {}, + ); + const result: INetworkSectionV2[] = [ + { key: 'network-frequently-used', data: frequentlyUsedItems }, + ...Object.entries(groups) + .toSorted(([a], [b]) => a.charCodeAt(0) - b.charCodeAt(0)) + .map(([title, data]) => ({ + key: `network-letter-${title}`, + title, + data, + })), + ]; + if (testnetItems.length) { + result.push({ + key: 'network-testnets', + title: intl.formatMessage({ id: ETranslations.global_testnet }), + data: testnetItems.filter((item) => !frequentlyUsedIds.has(item.id)), + }); + } + if (unavailableItems.length) { + result.push({ + key: 'network-unavailable', + title: intl.formatMessage({ + id: ETranslations.network_selector_unavailable_networks, + }), + data: unavailableItems, + unavailable: true, + }); + } + return result; + }, [ + frequentlyUsedItems, + intl, + mainnetItems, + networkFuseSearch, + searchText, + testnetItems, + unavailableItems, + ]); + + const buildNetworkRow = useCallback( + ( + network: IServerNetwork, + sectionKey: string, + disabled = false, + ): IdentityRow => { + const value = getNetworkValueV2({ + network, + accountNetworkValues, + accountDeFiOverview, + }); + const trailing: TrailingAccessory[] = []; + if (network.isCustomNetwork && !disabled) { + trailing.push({ + kind: 'icon', + name: 'PencilOutline', + actionKey: 'network.edit', + hoverActionKey: 'network.tooltip.edit', + accessibilityLabel: intl.formatMessage({ + id: ETranslations.global_edit, + }), + }); + } + if (new BigNumber(value).gt(NETWORK_SHOW_VALUE_THRESHOLD_USD)) { + trailing.push({ kind: 'value', ...formatCurrencyValue(value) }); + } + const title = network.isAllNetworks + ? intl.formatMessage({ id: ETranslations.global_all_networks }) + : network.name; + return { + type: 'identity', + presentation: 'networkSelector', + height: 48, + key: network.id, + testID: network.id, + sectionKey, + groupId: network.id, + groupPosition: 'single', + size: 'small', + title, + // V1's EditableListItem overrides ListItem.Text with a plain title. + // Search highlights belong to the portfolio list only. + leading: getNetworkLeading(network), + trailing, + disabled, + selected: networkId === network.id, + accessibilityLabel: title, + }; + }, + [ + accountDeFiOverview, + accountNetworkValues, + formatCurrencyValue, + getNetworkLeading, + intl, + networkId, + ], + ); + + const rows = useMemo(() => { + const result: RowModel[] = []; + if (!searchText) { + if (!zeroValue) { + result.push({ + type: 'sectionHeader', + key: 'network-assets-description', + sectionKey: 'network-frequently-used', + presentation: 'networkSelector', + height: 47, + sticky: false, + title: intl.formatMessage({ + id: ETranslations.network_found_assets_on_networks, + }), + titleActionKey: 'network.tooltip.assets', + titleActionOnHover: true, + }); + } + if (allNetworkItem) { + result.push(buildNetworkRow(allNetworkItem, 'network-frequently-used')); + } + } + sections.forEach((section, sectionIndex) => { + if (sectionIndex) { + result.push({ + type: 'system', + variant: 'spacer', + key: `${section.key}-spacer`, + sectionKey: section.key, + height: 20, + }); + } + if (section.title) { + result.push({ + type: 'sectionHeader', + key: `${section.key}-header`, + presentation: 'networkSelector', + sectionKey: section.key, + title: section.title, + indexTitle: section.key.startsWith('network-letter-') + ? section.title + : undefined, + height: 36, + }); + } + result.push( + ...section.data.map((network) => + buildNetworkRow(network, section.key, section.unavailable), + ), + ); + }); + return result; + }, [allNetworkItem, buildNetworkRow, intl, searchText, sections, zeroValue]); + + const initialScrollKey = useMemo(() => { + if (searchText.trim()) return undefined; + let precedingItems = 0; + for (const section of sections) { + const itemIndex = section.data.findIndex((item) => item.id === networkId); + if (itemIndex !== -1) { + const index = Math.max(0, itemIndex - (section.title ? 1 : 0)); + if (precedingItems + index <= 7) return rows[0]?.key; + if (section.title && index === 0) return `${section.key}-header`; + return section.data[Math.max(0, index - 1)]?.id; + } + precedingItems += section.data.length; + } + return undefined; + }, [networkId, rows, searchText, sections]); + + const snapshot = useMemo( + () => ({ + schemaVersion: 1, + generation: 1, + theme: nativeTheme, + layout: { + kind: 'sectioned', + stickyHeaders: true, + contentPaddingHorizontal: 8, + contentPaddingTop: 0, + contentPaddingBottom: bottom || 8, + itemSpacing: 0, + }, + rows, + capabilities: { sectionIndex: { enabled: !searchText } }, + selection: { mode: 'none', selectedKeys: [] }, + }), + [bottom, nativeTheme, rows, searchText], + ); + + const handleRowAction = useCallback( + (event: RowActionEvent) => { + if ( + event.actionKey === 'network.tooltip.assets' || + event.actionKey === 'network.tooltip.edit' + ) { + showTooltip(event); + return; + } + const section = sections.find((item) => + item.data.some((network) => network.id === event.rowKey), + ); + if (section?.unavailable) return; + const network = + section?.data.find((item) => item.id === event.rowKey) ?? + (allNetworkItem?.id === event.rowKey ? allNetworkItem : undefined); + if (!network) return; + if (event.actionKey === 'network.edit' && network.isCustomNetwork) { + closeTooltip(); + onEditCustomNetwork?.(network); + } else if (event.actionKey === 'press') { + onPressItem?.(network); + } + }, + [ + allNetworkItem, + closeTooltip, + onEditCustomNetwork, + onPressItem, + sections, + showTooltip, + ], + ); + + const handleSearchChange = useCallback( + (text: string) => { + closeTooltip(); + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + setSearchText(text); + }, + [closeTooltip, setSearchText], + ); + + return ( + + + + + {recentNetworksEnabled ? ( + + ) : null} + + {rows.length ? ( + + ) : null} + {sections.length === 0 ? ( + + ) : null} + {tooltipElement} + + + ); +} diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx new file mode 100644 index 000000000000..ef944ca3f330 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx @@ -0,0 +1,349 @@ +/** + * @jest-environment jsdom + */ +import type { ComponentProps, ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import { act, render } from '@testing-library/react'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { AllNetworksManagerContext } from '../AllNetworksManager/AllNetworksManagerContext'; + +import NetworksSectionListV2 from './NetworksSectionListV2'; + +import type { IServerNetworkMatch } from '../../types'; +import type { NativeListProps } from '@onekeyfe/react-native-native-list'; + +const mockNativeList = jest.fn((_props: NativeListProps) => null); +const mockRun = jest.fn(); +const mockMissingCount = jest.fn(); +const mockNetwork = (id: string): IServerNetworkMatch => + ({ id, name: id, isTestnet: false }) as IServerNetworkMatch; +const mockNetworks = [mockNetwork('a'), mockNetwork('b'), mockNetwork('c')]; +const mockValues = { a: '2', b: '2', c: '2' }; +const mockDeFiOverview = {}; +const mockIntl = { + formatMessage: ({ id }: { id: string }, values?: { count?: number }) => + values?.count === undefined ? id : `${id}:${values.count}`, +}; +const mockGetNetworkValue = jest.fn( + ({ + network, + accountNetworkValues, + }: { + network: IServerNetworkMatch; + accountNetworkValues: Record; + }) => accountNetworkValues[network.id] ?? '0', +); +const mockFormatCurrencyValue = jest.fn((value: string) => ({ text: value })); +const mockGetNetworkLeading = jest.fn(() => ({ + kind: 'network', + fallbackText: 'N', +})); +const mockPresentation = { + nativeTheme: { rowBackground: '#ffffff' }, + formatCurrencyValue: mockFormatCurrencyValue, + getNetworkLeading: mockGetNetworkLeading, +}; +let mockMissingNetworks = [{ networkId: 'a' }]; +let mockSearch = ''; +let mockState = { + enabledNetworks: { a: true } as Record, + disabledNetworks: {} as Record, +}; + +jest.mock('@onekeyfe/react-native-native-list', () => ({ + NativeList: (props: NativeListProps) => mockNativeList(props), +})); +jest.mock('@onekeyhq/components', () => ({ + Stack: ({ children }: { children?: ReactNode }) => children, + Empty: () => null, + SearchBar: () => null, +})); +jest.mock('react-intl', () => ({ + useIntl: () => mockIntl, +})); +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isNative: false }, +})); +jest.mock('@onekeyhq/shared/src/utils/networkUtils', () => ({ + isEnabledNetworksInAllNetworks: ({ + networkId, + enabledNetworks, + }: { + networkId: string; + enabledNetworks: Record; + }) => Boolean(enabledNetworks[networkId]), +})); +jest.mock('@onekeyhq/kit/src/hooks/useAllNetwork', () => ({ + useEnabledNetworksCompatibleWithWalletIdInAllNetworks: ({ + enabledNetworks, + }: { + enabledNetworks: IServerNetworkMatch[]; + }) => { + // The shared hook already fetches when its enabled-network input changes. + useEffect(() => { + mockRun(); + }, [enabledNetworks]); + return { enabledNetworksWithoutAccount: mockMissingNetworks, run: mockRun }; + }, +})); +jest.mock('../../hooks/usePureChainSelectorSections', () => ({ + usePureChainSelectorSections: ({ + networks, + searchKey, + }: { + networks: IServerNetworkMatch[]; + searchKey: string; + }) => { + const sections = useMemo( + () => + searchKey + ? [{ data: networks.filter((network) => network.id === searchKey) }] + : [ + { title: 'Assets', totalValue: '2', data: networks.slice(0, 2) }, + { title: 'C', data: networks.slice(2) }, + ], + [networks, searchKey], + ); + return { sections }; + }, +})); +jest.mock('./useNetworkListPresentationV2', () => ({ + getNetworkValueV2: (params: Parameters[0]) => + mockGetNetworkValue(params), + getNetworkTitleMatchV2: () => undefined, + useNetworkListPresentationV2: () => mockPresentation, +})); +jest.mock('./useNetworkTooltipV2', () => ({ + useNetworkTooltipV2: () => ({}), +})); + +type IContextValueV2 = ComponentProps< + typeof AllNetworksManagerContext.Provider +>['value']; + +function HarnessV2({ + networks = mockNetworks, + values = mockValues, + isCreatingMissingAddresses = false, +}: { + networks?: IServerNetworkMatch[]; + values?: Record; + isCreatingMissingAddresses?: boolean; +}) { + const [state, setState] = useState(mockState); + mockState = state; + const networkCollection = useMemo( + () => ({ + mainNetworks: networks, + frequentlyUsedNetworks: [], + }), + [networks], + ); + const enabledNetworks = useMemo( + () => networks.filter((network) => state.enabledNetworks[network.id]), + [networks, state], + ); + const value = useMemo( + () => ({ + walletId: 'wallet', + accountId: undefined, + indexedAccountId: undefined, + networks: networkCollection, + networksState: state, + setNetworksState: setState, + enabledNetworks, + searchKey: mockSearch, + setSearchKey: jest.fn(), + isCreatingEnabledAddresses: false, + setIsCreatingEnabledAddresses: jest.fn(), + isCreatingMissingAddresses, + setIsCreatingMissingAddresses: jest.fn(), + missingAddressCount: 0, + setMissingAddressCount: mockMissingCount, + accountNetworkValues: values, + accountDeFiOverview: mockDeFiOverview, + }), + [ + state, + networkCollection, + enabledNetworks, + values, + isCreatingMissingAddresses, + ], + ); + return ( + + + + ); +} + +function getNativePropsV2() { + const props = mockNativeList.mock.calls.at(-1)?.[0]; + if (!props) throw new OneKeyLocalError('NativeList did not render'); + return props; +} + +describe('portfolio NativeList selection adapter V2', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearch = ''; + mockMissingNetworks = [{ networkId: 'a' }]; + mockState = { enabledNetworks: { a: true }, disabledNetworks: {} }; + }); + + it('preserves selections outside the search result and writes both state maps', () => { + mockSearch = 'b'; + render(); + expect(getNativePropsV2().snapshot.selection?.selectedKeys).toEqual([]); + act(() => { + getNativePropsV2().onSelectionDelta?.({ + addedKeys: ['b', 'stale-network'], + removedKeys: [], + source: 'row', + }); + }); + expect(mockState.enabledNetworks).toEqual({ a: true, b: true }); + expect(mockState.disabledNetworks).toEqual({ b: false }); + expect(getNativePropsV2().snapshot.selection?.selectedKeys).toEqual(['b']); + act(() => { + getNativePropsV2().onSelectionDelta?.({ + addedKeys: [], + removedKeys: ['b'], + source: 'row', + }); + }); + expect(mockState.enabledNetworks).toEqual({ a: true, b: false }); + expect(mockState.disabledNetworks).toEqual({ b: true }); + }); + + it('deselects a partial selection before selecting all compatible networks', () => { + render(); + act(() => { + getNativePropsV2().onRowAction?.({ actionKey: 'network.toggleAll' }); + }); + expect(mockState).toEqual({ + enabledNetworks: {}, + disabledNetworks: { a: true, b: true, c: true }, + }); + act(() => { + getNativePropsV2().onRowAction?.({ actionKey: 'network.toggleAll' }); + }); + expect(mockState).toEqual({ + enabledNetworks: { a: true, b: true, c: true }, + disabledNetworks: {}, + }); + }); + + it('keeps other sections selected when the assets section is toggled', () => { + mockState = { enabledNetworks: { a: true, c: true }, disabledNetworks: {} }; + render(); + const getGroupState = () => { + const row = getNativePropsV2().snapshot.rows.find( + (item) => item.key === 'portfolio-assets-header', + ); + return row?.type === 'sectionHeader' ? row.checkbox?.state : undefined; + }; + expect(getGroupState()).toBe('indeterminate'); + act(() => { + getNativePropsV2().onSelectionDelta?.({ + addedKeys: [], + removedKeys: ['a', 'b'], + source: 'section', + }); + }); + expect(getNativePropsV2().snapshot.selection?.selectedKeys).toEqual(['c']); + expect(getGroupState()).toBe('unchecked'); + act(() => { + getNativePropsV2().onSelectionDelta?.({ + addedKeys: ['a', 'b'], + removedKeys: [], + source: 'section', + }); + }); + expect(getNativePropsV2().snapshot.selection?.selectedKeys).toEqual([ + 'a', + 'b', + 'c', + ]); + expect(getGroupState()).toBe('checked'); + expect(mockGetNetworkValue).toHaveBeenCalledTimes(3); + }); + + it('keeps the original missing-address computation connected to the footer', () => { + render(); + expect(mockMissingCount).toHaveBeenCalledWith(1); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(getNativePropsV2().snapshot.layout.stickyHeaders).toBe(false); + expect( + getNativePropsV2().snapshot.rows.filter((row) => row.type === 'identity'), + ).toHaveLength(3); + }); + + it('changes only selection presentation when a network is checked', () => { + render(); + const before = getNativePropsV2().snapshot.rows; + expect(mockGetNetworkValue).toHaveBeenCalledTimes(3); + expect(mockFormatCurrencyValue).toHaveBeenCalledTimes(4); + expect(mockGetNetworkLeading).toHaveBeenCalledTimes(3); + act(() => { + getNativePropsV2().onSelectionDelta?.({ + addedKeys: ['b'], + removedKeys: [], + source: 'row', + }); + }); + expect({ + queries: mockRun.mock.calls.length, + values: mockGetNetworkValue.mock.calls.length, + formatting: mockFormatCurrencyValue.mock.calls.length, + images: mockGetNetworkLeading.mock.calls.length, + }).toEqual({ queries: 2, values: 3, formatting: 4, images: 3 }); + const after = getNativePropsV2().snapshot.rows; + const previousRow = before.find((row) => row.key === 'b'); + const row = after.find((item) => item.key === 'b'); + expect(row?.type === 'identity' && row.leading).toBe( + previousRow?.type === 'identity' && previousRow.leading, + ); + expect(row?.type === 'identity' && row.trailing).toContainEqual({ + kind: 'checkbox', + state: 'checked', + target: { scope: 'row' }, + }); + const header = after.find((item) => item.key === 'portfolio-assets-header'); + expect(header?.type === 'sectionHeader' && header.checkbox?.state).toBe( + 'checked', + ); + expect(after[0].type === 'sectionHeader' && after[0].title).toContain(':2'); + }); + + it('refreshes money, missing-address results, and changed network metadata', () => { + const view = render(); + mockMissingNetworks = []; + view.rerender( + , + ); + expect(mockMissingCount).toHaveBeenLastCalledWith(0); + const row = getNativePropsV2().snapshot.rows.find( + (item) => item.key === 'b', + ); + expect(row?.type === 'identity' && row.trailing).toContainEqual({ + kind: 'value', + text: '8', + }); + view.rerender(); + expect( + getNativePropsV2() + .snapshot.rows.filter((item) => item.type === 'identity') + .map((item) => item.key), + ).toEqual(['a', 'd']); + expect(mockRun).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx new file mode 100644 index 000000000000..1b9de8a55d3f --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx @@ -0,0 +1,394 @@ +import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; + +import { NativeList } from '@onekeyfe/react-native-native-list'; +import BigNumber from 'bignumber.js'; +import { useIntl } from 'react-intl'; + +import { Empty, SearchBar, Stack } from '@onekeyhq/components'; +import { useEnabledNetworksCompatibleWithWalletIdInAllNetworks } from '@onekeyhq/kit/src/hooks/useAllNetwork'; +import { NETWORK_SHOW_VALUE_THRESHOLD_USD } from '@onekeyhq/shared/src/consts/networkConsts'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { isEnabledNetworksInAllNetworks } from '@onekeyhq/shared/src/utils/networkUtils'; + +import { usePureChainSelectorSections } from '../../hooks/usePureChainSelectorSections'; +import { ChainSelectorTestIDs } from '../../testIDs'; +import { AllNetworksManagerContext } from '../AllNetworksManager/AllNetworksManagerContext'; + +import { + getNetworkTitleMatchV2, + getNetworkValueV2, + useNetworkListPresentationV2, +} from './useNetworkListPresentationV2'; +import { useNetworkTooltipV2 } from './useNetworkTooltipV2'; + +import type { + CheckboxState, + IdentityRow, + NativeListRef, + NativeListSnapshot, + RowActionEvent, + RowModel, + SelectionDeltaEvent, + TrailingAccessory, +} from '@onekeyfe/react-native-native-list'; + +const LIST_STYLE_V2 = { flex: 1 }; + +export default function NetworksSectionListV2() { + const intl = useIntl(); + const { + walletId, + indexedAccountId, + networks, + enabledNetworks, + searchKey, + setSearchKey, + accountNetworkValues, + accountNetworkValueCurrency, + accountDeFiOverview, + networksState, + setNetworksState, + setMissingAddressCount, + } = useContext(AllNetworksManagerContext); + const listRef = useRef(null); + const { + containerRef, + showTooltip, + closeTooltip, + onActionAnchorInvalidated, + tooltipElement, + } = useNetworkTooltipV2(listRef); + const { + nativeTheme, + formatCurrencyValue, + getNetworkLeading, + sectionBackground, + } = useNetworkListPresentationV2(accountNetworkValueCurrency); + const { sections } = usePureChainSelectorSections({ + networks: networks.mainNetworks, + searchKey, + accountNetworkValues, + accountDeFiOverview, + }); + + const { enabledNetworksWithoutAccount } = + useEnabledNetworksCompatibleWithWalletIdInAllNetworks({ + walletId: walletId ?? '', + indexedAccountId, + filterNetworksWithoutAccount: true, + enabledNetworks, + }); + useEffect(() => { + setMissingAddressCount(enabledNetworksWithoutAccount.length); + }, [enabledNetworksWithoutAccount.length, setMissingAddressCount]); + + const selectedIds = useMemo( + () => + new Set( + networks.mainNetworks + .filter((network) => + isEnabledNetworksInAllNetworks({ + networkId: network.id, + isTestnet: network.isTestnet, + enabledNetworks: networksState.enabledNetworks, + disabledNetworks: networksState.disabledNetworks, + }), + ) + .map((network) => network.id), + ), + [networks.mainNetworks, networksState], + ); + const networkIds = useMemo( + () => new Set(networks.mainNetworks.map((network) => network.id)), + [networks.mainNetworks], + ); + + // Checking a network must not reformat balances or rebuild image descriptors. + const sectionRows = useMemo( + () => + sections.map((section) => { + const sectionKey = section.totalValue + ? 'portfolio-assets' + : `portfolio-section-${section.title ?? 'search'}`; + return { + section, + sectionKey, + formattedValue: section.totalValue + ? formatCurrencyValue(section.totalValue) + : undefined, + rows: section.data.map((network): IdentityRow => { + const value = getNetworkValueV2({ + network, + accountNetworkValues, + accountDeFiOverview, + }); + const trailing: TrailingAccessory[] = []; + if (new BigNumber(value).gt(NETWORK_SHOW_VALUE_THRESHOLD_USD)) { + trailing.push({ kind: 'value', ...formatCurrencyValue(value) }); + } + return { + type: 'identity', + presentation: 'networkSelector', + height: 48, + key: network.id, + testID: `all-networks-manager-item-${network.id}`, + sectionKey, + groupId: network.id, + groupPosition: 'single', + size: 'small', + title: network.name, + titleMatch: getNetworkTitleMatchV2(network), + leading: getNetworkLeading(network), + trailing, + accessibilityLabel: network.name, + }; + }), + }; + }), + [ + accountDeFiOverview, + accountNetworkValues, + formatCurrencyValue, + getNetworkLeading, + sections, + ], + ); + + const rows = useMemo(() => { + const result: RowModel[] = []; + if (!sectionRows.length) return result; + if (!searchKey.trim()) { + result.push({ + type: 'sectionHeader', + presentation: 'networkSelector', + variant: 'summary', + key: 'portfolio-summary', + sectionKey: 'portfolio-summary', + height: 71, + title: intl.formatMessage( + { id: ETranslations.network_view_assets_from_n_networks }, + { count: enabledNetworks.length }, + ), + titleActionKey: 'network.tooltip.selection', + titleActionOnHover: true, + // V1 deliberately treats a partial selection as "deselect all". + value: intl.formatMessage({ + id: enabledNetworks.length + ? ETranslations.global_deselect_all + : ETranslations.global_select_all, + }), + valueActionKey: 'network.toggleAll', + valueActionTestID: ChainSelectorTestIDs.allNetworksToggleAllBtn, + }); + } else { + // NetworkListHeader keeps its mt=16/pb=12 wrapper while searching. + result.push({ + type: 'system', + variant: 'spacer', + key: 'portfolio-search-header-spacing', + height: 28, + heightRounding: 'nearest', + }); + } + sectionRows.forEach( + ({ section, sectionKey, formattedValue, rows: networkRows }, index) => { + if (index) { + result.push({ + type: 'system', + variant: 'spacer', + key: `${sectionKey}-spacer`, + sectionKey, + height: 20, + heightRounding: 'nearest', + }); + } + if (section.title) { + if (formattedValue) { + const selectedCount = section.data.filter((network) => + selectedIds.has(network.id), + ).length; + let state: CheckboxState = 'indeterminate'; + if (!selectedCount) state = 'unchecked'; + else if (selectedCount === section.data.length) state = 'checked'; + result.push({ + type: 'sectionHeader', + presentation: 'networkSelector', + key: `${sectionKey}-header`, + sectionKey, + height: 56, + backgroundColor: sectionBackground, + backgroundFullWidth: true, + title: section.title, + titleActionKey: 'network.tooltip.assets', + titleActionOnHover: true, + value: formattedValue.text, + valueSegments: formattedValue.textSegments, + checkbox: { + kind: 'checkbox', + state, + target: { scope: 'section', sectionKey }, + }, + }); + } else { + result.push({ + type: 'sectionHeader', + presentation: 'networkSelector', + key: `${sectionKey}-header`, + sectionKey, + title: section.title, + height: 36, + indexTitle: + section.title.length === 1 ? section.title : undefined, + heightRounding: 'nearest', + }); + } + } + networkRows.forEach((row) => { + result.push({ + ...row, + trailing: [ + ...(row.trailing ?? []), + { + kind: 'checkbox', + state: selectedIds.has(row.key) ? 'checked' : 'unchecked', + target: { scope: 'row' }, + }, + ], + }); + }); + }, + ); + return result; + }, [ + enabledNetworks.length, + intl, + searchKey, + sectionBackground, + sectionRows, + selectedIds, + ]); + + const snapshot = useMemo( + () => ({ + schemaVersion: 1, + generation: 1, + theme: { + ...nativeTheme, + // Selection is represented by checkboxes in V1, without a row fill. + rowSelectedBackground: nativeTheme.rowBackground, + }, + layout: { + kind: 'sectioned', + stickyHeaders: false, + contentPaddingHorizontal: 8, + contentPaddingTop: 0, + contentPaddingBottom: 0, + itemSpacing: 0, + }, + rows, + capabilities: { sectionIndex: { enabled: !searchKey.trim() } }, + selection: { + mode: 'multiple', + selectedKeys: rows + .filter((row) => row.type === 'identity' && selectedIds.has(row.key)) + .map((row) => row.key), + rowPressToggles: true, + }, + }), + [nativeTheme, rows, searchKey, selectedIds], + ); + + const handleSelectionDelta = useCallback( + (event: SelectionDeltaEvent) => { + setNetworksState((previous) => { + const next = { + enabledNetworks: { ...previous.enabledNetworks }, + disabledNetworks: { ...previous.disabledNetworks }, + }; + // Deltas update only affected networks, including when the list is + // filtered. Replacing the state with visible keys would lose selection. + event.addedKeys.forEach((key) => { + if (!networkIds.has(key)) return; + next.enabledNetworks[key] = true; + next.disabledNetworks[key] = false; + }); + event.removedKeys.forEach((key) => { + if (!networkIds.has(key)) return; + next.enabledNetworks[key] = false; + next.disabledNetworks[key] = true; + }); + return next; + }); + }, + [networkIds, setNetworksState], + ); + + const handleRowAction = useCallback( + (event: RowActionEvent) => { + if (event.actionKey === 'network.toggleAll') { + const allNetworks = Object.fromEntries( + networks.mainNetworks.map((network) => [network.id, true]), + ); + setNetworksState( + enabledNetworks.length + ? { enabledNetworks: {}, disabledNetworks: allNetworks } + : { enabledNetworks: allNetworks, disabledNetworks: {} }, + ); + } else if ( + event.actionKey === 'network.tooltip.assets' || + event.actionKey === 'network.tooltip.selection' + ) { + showTooltip(event); + } + }, + [ + enabledNetworks.length, + networks.mainNetworks, + setNetworksState, + showTooltip, + ], + ); + + const handleSearchChange = useCallback( + (text: string) => { + closeTooltip(); + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + setSearchKey(text); + }, + [closeTooltip, setSearchKey], + ); + + return ( + + + + + + {sections.length ? ( + + ) : ( + + )} + {tooltipElement} + + + ); +} diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/PortfolioContentV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/PortfolioContentV2.tsx new file mode 100644 index 000000000000..e0d58b6c88ee --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/PortfolioContentV2.tsx @@ -0,0 +1,115 @@ +import { memo, useMemo } from 'react'; + +import { Stack } from '@onekeyhq/components'; + +import { AllNetworksManagerContext } from '../AllNetworksManager/AllNetworksManagerContext'; + +import NetworksSectionListV2 from './NetworksSectionListV2'; + +import type { IServerNetworkMatch } from '../../types'; + +type IPortfolioContentPropsV2 = { + walletId: string; + accountId: string | undefined; + indexedAccountId: string | undefined; + networksState: { + enabledNetworks: Record; + disabledNetworks: Record; + }; + setNetworksState: React.Dispatch< + React.SetStateAction<{ + enabledNetworks: Record; + disabledNetworks: Record; + }> + >; + enabledNetworks: IServerNetworkMatch[]; + searchKey: string; + setSearchKey: React.Dispatch>; + isCreatingEnabledAddresses: boolean; + setIsCreatingEnabledAddresses: React.Dispatch>; + isCreatingMissingAddresses: boolean; + setIsCreatingMissingAddresses: React.Dispatch>; + missingAddressCount: number; + setMissingAddressCount: React.Dispatch>; + networks: { + mainNetworks: IServerNetworkMatch[]; + frequentlyUsedNetworks: IServerNetworkMatch[]; + }; + accountNetworkValues: Record; + accountNetworkValueCurrency?: string; + accountDeFiOverview: Record; +}; + +function PortfolioContentV2({ + walletId, + accountId, + indexedAccountId, + networksState, + setNetworksState, + enabledNetworks, + searchKey, + setSearchKey, + isCreatingEnabledAddresses, + setIsCreatingEnabledAddresses, + isCreatingMissingAddresses, + setIsCreatingMissingAddresses, + missingAddressCount, + setMissingAddressCount, + networks, + accountNetworkValues, + accountNetworkValueCurrency, + accountDeFiOverview, +}: IPortfolioContentPropsV2) { + const contextValue = useMemo( + () => ({ + walletId, + indexedAccountId, + accountId, + networks, + networksState, + setNetworksState, + enabledNetworks, + searchKey, + setSearchKey, + isCreatingEnabledAddresses, + setIsCreatingEnabledAddresses, + isCreatingMissingAddresses, + setIsCreatingMissingAddresses, + missingAddressCount, + setMissingAddressCount, + accountNetworkValues, + accountNetworkValueCurrency, + accountDeFiOverview, + }), + [ + walletId, + indexedAccountId, + accountId, + networks, + networksState, + setNetworksState, + enabledNetworks, + searchKey, + setSearchKey, + isCreatingEnabledAddresses, + setIsCreatingEnabledAddresses, + isCreatingMissingAddresses, + setIsCreatingMissingAddresses, + missingAddressCount, + setMissingAddressCount, + accountNetworkValues, + accountNetworkValueCurrency, + accountDeFiOverview, + ], + ); + + return ( + + + + + + ); +} + +export default memo(PortfolioContentV2); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.test.tsx new file mode 100644 index 000000000000..e6130c0f741f --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.test.tsx @@ -0,0 +1,252 @@ +/** + * @jest-environment jsdom + */ +import type { ComponentProps, ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +import { act, render } from '@testing-library/react'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import UnifiedNetworkSelectorPageV2 from './UnifiedNetworkSelectorV2'; + +import type PortfolioContentV2 from './PortfolioContentV2'; +import type { IServerNetworkMatch } from '../../types'; + +type IPortfolioProps = ComponentProps; +type INetworksState = IPortfolioProps['networksState']; +type IMeta = { + allNetworksState: INetworksState; + allNetworks: IServerNetworkMatch[]; + compatibleNetworks: { + mainnetItems: IServerNetworkMatch[]; + frequentlyUsedItems: IServerNetworkMatch[]; + }; +}; +type IEnvironment = { networkMeta: IMeta; walletId: string }; + +const mockNetworks = ['a', 'b'].map( + (id) => ({ id, name: id, isTestnet: false }) as IServerNetworkMatch, +); +function mockMeta(allNetworksState: INetworksState): IMeta { + return { + allNetworksState, + allNetworks: mockNetworks, + compatibleNetworks: { + mainnetItems: mockNetworks, + frequentlyUsedItems: [], + }, + }; +} + +const mockCachedMeta = mockMeta({ + enabledNetworks: { a: true }, + disabledNetworks: { b: true }, +}); +const mockEnvironmentContext = createContext({ + networkMeta: mockCachedMeta, + walletId: 'wallet-1', +}); +const mockPortfolio = jest.fn((_props: IPortfolioProps) => null); +const mockRefresh = jest.fn(); +const mockNavigation = { push: jest.fn() }; +const mockActions = { current: { updateSelectedAccountNetwork: jest.fn() } }; +const mockCreateAddress = jest.fn(); +const mockFindNetworks = jest.fn(); +const mockIntl = { formatMessage: ({ id }: { id: string }) => id }; +const mockRoute = { params: { num: 0, sceneName: 'home' } }; +function mockContainer({ children }: { children?: ReactNode }) { + return children; +} + +jest.mock('@onekeyhq/components', () => ({ + Button: mockContainer, + HeaderIconButton: () => null, + Page: Object.assign(mockContainer, { + Header: () => null, + Body: mockContainer, + Footer: mockContainer, + }), + SizableText: mockContainer, + Stack: mockContainer, + YStack: mockContainer, + PagerView: mockContainer, + resetChainSelectorModal: jest.fn(), +})); +jest.mock('@react-navigation/core', () => ({ useRoute: () => mockRoute })); +jest.mock('react-intl', () => ({ useIntl: () => mockIntl })); +jest.mock('@onekeyhq/kit/src/background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: {}, +})); +jest.mock('@onekeyhq/kit/src/components/AccountSelector', () => ({ + AccountSelectorProviderMirror: mockContainer, +})); +jest.mock('@onekeyhq/kit/src/hooks/useAppNavigation', () => ({ + __esModule: true, + default: () => mockNavigation, +})); +jest.mock('@onekeyhq/kit/src/hooks/usePromiseResult', () => ({ + usePromiseResult: ( + _callback: unknown, + _deps: unknown, + options: { swrKey: string }, + ) => { + const { networkMeta } = useContext(mockEnvironmentContext); + return { + result: options.swrKey.startsWith('meta:') ? networkMeta : undefined, + run: mockRefresh, + }; + }, +})); +jest.mock('@onekeyhq/kit/src/states/jotai/contexts/accountSelector', () => ({ + useActiveAccount: () => { + const { walletId } = useContext(mockEnvironmentContext); + return { + activeAccount: { + network: { id: 'all' }, + account: { id: `${walletId}-account` }, + wallet: { id: walletId }, + }, + }; + }, +})); +jest.mock( + '@onekeyhq/kit/src/states/jotai/contexts/accountSelector/actions', + () => ({ useAccountSelectorActions: () => mockActions }), +); +jest.mock( + '@onekeyhq/kit/src/components/AccountSelector/hooks/useAccountSelectorCreateAddress', + () => ({ + useAccountSelectorCreateAddress: () => ({ + createAddress: mockCreateAddress, + }), + }), +); +jest.mock('../../hooks/useFindNetworksWithoutAccount', () => ({ + useFindNetworksWithoutAccount: () => ({ + findNetworksWithoutAccount: mockFindNetworks, + }), +})); +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isNative: false }, +})); +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { AddedCustomNetwork: 'AddedCustomNetwork' }, + appEventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }, +})); +jest.mock('@onekeyhq/shared/src/utils/accountUtils', () => ({ + __esModule: true, + default: { isOthersWallet: () => false }, +})); +jest.mock('@onekeyhq/shared/src/utils/networkUtils', () => ({ + __esModule: true, + default: { isAllNetwork: () => true }, + isEnabledNetworksInAllNetworks: ({ + networkId, + enabledNetworks, + disabledNetworks, + }: INetworksState & { networkId: string }) => + enabledNetworks[networkId] && !disabledNetworks[networkId], +})); +jest.mock('@onekeyhq/shared/src/utils/swrCacheUtils', () => ({ + swrCacheUtils: { get: () => mockCachedMeta }, + swrKeys: { + unifiedNetworkSelectorMeta: ({ walletId }: { walletId: string }) => + `meta:${walletId}`, + unifiedNetworkSelectorValues: () => 'values', + }, +})); +jest.mock('./PortfolioContentV2', () => ({ + __esModule: true, + default: (props: IPortfolioProps) => mockPortfolio(props), +})); +jest.mock('./NetworkContentV2', () => ({ NetworkContentV2: () => null })); +jest.mock('../UnifiedNetworkSelector/TabSwitcher', () => ({ + TabSwitcher: () => null, +})); +jest.mock('./useNetworkListPresentationV2', () => ({ + preloadNetworkImagesV2: jest.fn(), +})); + +function selectorElement(value: IEnvironment) { + return ( + + + + ); +} + +function portfolioProps() { + const props = mockPortfolio.mock.calls.at(-1)?.[0]; + if (!props) throw new OneKeyLocalError('Portfolio did not render'); + return props; +} + +describe('network selector selection revalidation', () => { + beforeEach(() => jest.clearAllMocks()); + + it('refreshes cached selection before any local edits', () => { + const view = render( + selectorElement({ networkMeta: mockCachedMeta, walletId: 'wallet-1' }), + ); + const refreshed = mockMeta({ + enabledNetworks: { b: true }, + disabledNetworks: { a: true }, + }); + view.rerender( + selectorElement({ networkMeta: refreshed, walletId: 'wallet-1' }), + ); + expect(portfolioProps().networksState).toEqual(refreshed.allNetworksState); + }); + + it.each(['functional', 'replacement'] as const)( + 'preserves %s selection edits when an in-flight refresh finishes', + (updateType) => { + const view = render( + selectorElement({ networkMeta: mockCachedMeta, walletId: 'wallet-1' }), + ); + const selected: INetworksState = { + enabledNetworks: { a: true, b: true }, + disabledNetworks: { b: false }, + }; + act(() => { + portfolioProps().setNetworksState( + updateType === 'functional' ? () => selected : selected, + ); + }); + view.rerender( + selectorElement({ + networkMeta: mockMeta(mockCachedMeta.allNetworksState), + walletId: 'wallet-1', + }), + ); + expect(portfolioProps().networksState).toEqual(selected); + expect(portfolioProps().enabledNetworks.map(({ id }) => id)).toEqual([ + 'a', + 'b', + ]); + }, + ); + + it('does not retain the edit guard for a different account', () => { + const view = render( + selectorElement({ networkMeta: mockCachedMeta, walletId: 'wallet-1' }), + ); + act(() => { + portfolioProps().setNetworksState({ + enabledNetworks: { a: true, b: true }, + disabledNetworks: {}, + }); + }); + const refreshed = mockMeta({ + enabledNetworks: { b: true }, + disabledNetworks: { a: true }, + }); + view.rerender( + selectorElement({ networkMeta: refreshed, walletId: 'wallet-2' }), + ); + expect(portfolioProps().networksState).toEqual(refreshed.allNetworksState); + }); +}); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx new file mode 100644 index 000000000000..f41bb60b4f5a --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx @@ -0,0 +1,895 @@ +import type { RefObject } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useRoute } from '@react-navigation/core'; +import { useIntl } from 'react-intl'; + +import { + Button, + HeaderIconButton, + Page, + SizableText, + Stack, + YStack, + resetChainSelectorModal, +} from '@onekeyhq/components'; +import { PagerView } from '@onekeyhq/components/src/composite/Carousel/pager'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { AccountSelectorProviderMirror } from '@onekeyhq/kit/src/components/AccountSelector'; +import { useAccountSelectorCreateAddress } from '@onekeyhq/kit/src/components/AccountSelector/hooks/useAccountSelectorCreateAddress'; +import useAppNavigation from '@onekeyhq/kit/src/hooks/useAppNavigation'; +import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { useActiveAccount } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector'; +import { useAccountSelectorActions } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector/actions'; +import type { IAccountDeriveTypes } from '@onekeyhq/kit-bg/src/vaults/types'; +import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + type EChainSelectorPages, + EChainSelectorPages as EChainSelectorPagesEnum, + type IChainSelectorParamList, +} from '@onekeyhq/shared/src/routes'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import networkUtils, { + isEnabledNetworksInAllNetworks, +} from '@onekeyhq/shared/src/utils/networkUtils'; +import { + swrCacheUtils, + swrKeys, +} from '@onekeyhq/shared/src/utils/swrCacheUtils'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; +import { EAccountSelectorSceneName } from '@onekeyhq/shared/types'; + +import { useFindNetworksWithoutAccount } from '../../hooks/useFindNetworksWithoutAccount'; +import { ChainSelectorTestIDs } from '../../testIDs'; +import { TabSwitcher } from '../UnifiedNetworkSelector/TabSwitcher'; + +import { NetworkContentV2 } from './NetworkContentV2'; +import PortfolioContentV2 from './PortfolioContentV2'; +import { preloadNetworkImagesV2 } from './useNetworkListPresentationV2'; + +import type { IServerNetworkMatch } from '../../types'; +import type { ITabType } from '../UnifiedNetworkSelector/TabSwitcher'; +import type { RouteProp } from '@react-navigation/core'; +import type NativePagerView from 'react-native-pager-view'; + +const TAB_TO_INDEX: Record = { portfolio: 0, network: 1 }; +const INDEX_TO_TAB: ITabType[] = ['portfolio', 'network']; + +function UnifiedNetworkSelectorV2() { + const intl = useIntl(); + const navigation = useAppNavigation(); + const { createAddress } = useAccountSelectorCreateAddress(); + const { findNetworksWithoutAccount } = useFindNetworksWithoutAccount(); + const actions = useAccountSelectorActions(); + + const route = + useRoute< + RouteProp< + IChainSelectorParamList, + EChainSelectorPages.UnifiedNetworkSelector + > + >(); + + const { + num, + sceneName, + networkIds, + recordNetworkHistoryEnabled, + onNetworksChanged, + defaultTab, + } = route.params; + + const { + activeAccount: { network: activeNetwork, account, wallet, indexedAccount }, + } = useActiveAccount({ num }); + + const walletId = wallet?.id ?? ''; + const accountId = account?.id; + const indexedAccountId = indexedAccount?.id; + const networkId = activeNetwork?.id; + const isOthersWallet = accountUtils.isOthersWallet({ walletId }); + + // Determine if tab switcher should be shown + const showTabSwitcher = useMemo(() => { + // Single network mode - no tab switcher + if (defaultTab === 'network' && !networkUtils.isAllNetwork({ networkId })) { + return false; + } + return true; + }, [defaultTab, networkId]); + + // Determine initial tab + const initialTab = useMemo((): ITabType => { + if (networkUtils.isAllNetwork({ networkId })) { + return 'portfolio'; + } + if (isOthersWallet) { + return 'network'; + } + return defaultTab ?? 'network'; + }, [defaultTab, isOthersWallet, networkId]); + + const [activeTab, setActiveTab] = useState(initialTab); + + // Portfolio tab state (from AllNetworksManager). + // Seed from the SWR cache synchronously so the first render doesn't flash + // the "no results" empty state before the useEffect below copies the + // revalidated networkMeta into state. The setter is still needed because + // handleAddCustomNetwork updates this locally before persisting to bg. + const [networksState, setNetworksState] = useState<{ + enabledNetworks: Record; + disabledNetworks: Record; + }>(() => { + const cached = swrCacheUtils.get<{ + allNetworksState: { + enabledNetworks: Record; + disabledNetworks: Record; + }; + }>(swrKeys.unifiedNetworkSelectorMeta({ walletId, accountId })); + return ( + cached?.allNetworksState ?? { + enabledNetworks: {}, + disabledNetworks: {}, + } + ); + }); + + const enabledNetworksInit = useRef(false); + const hasLocalNetworkChanges = useRef(false); + + useEffect(() => { + hasLocalNetworkChanges.current = false; + }, [accountId, walletId]); + + const updateNetworksSelection = useCallback( + (value) => { + hasLocalNetworkChanges.current = true; + setNetworksState(value); + }, + [], + ); + + const [originalEnabledNetworks, setOriginalEnabledNetworks] = useState< + IServerNetworkMatch[] + >([]); + + const [missingAddressCount, setMissingAddressCount] = useState(0); + + const [isCreatingMissingAddresses, setIsCreatingMissingAddresses] = + useState(false); + + const [isCreatingEnabledAddresses, setIsCreatingEnabledAddresses] = + useState(false); + + const [searchKey, setSearchKey] = useState(''); + + const [_enabledNetworksWithoutAccount, setEnabledNetworksWithoutAccount] = + useState< + { + networkId: string; + deriveType: IAccountDeriveTypes; + }[] + >([]); + + // Use ref to track activeTab for closures (e.g. onSuccess in navigation) + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + + const pagerRef = useRef(null); + + const handleTabChange = useCallback((tab: ITabType) => { + setActiveTab(tab); + if (platformEnv.isNative) { + pagerRef.current?.setPage(TAB_TO_INDEX[tab]); + } + }, []); + + const handlePageSelected = useCallback( + (e: { nativeEvent: { position: number } }) => { + const newTab = INDEX_TO_TAB[e.nativeEvent.position]; + if (newTab && newTab !== activeTabRef.current) { + setActiveTab(newTab); + } + }, + [], + ); + + // Split into two hooks so the list skeleton can hydrate from MMKV on mount + // while balances/DeFi stay off the cache (stale money values would mislead + // the user more than a short skeleton does). + const { result: networkMeta, run: refreshNetworkMeta } = usePromiseResult( + async () => { + const [allNetworksStateResp, { networks: allNetworks }] = + await Promise.all([ + backgroundApiProxy.serviceAllNetwork.getAllNetworksState(), + backgroundApiProxy.serviceNetwork.getAllNetworks(), + ]); + + const compatibleNetworks = + await backgroundApiProxy.serviceNetwork.getChainSelectorNetworksCompatibleWithAccountId( + { + accountId, + walletId, + networkIds: allNetworks.map((network) => network.id), + excludeTestNetwork: true, + }, + ); + + return { + allNetworksState: { + enabledNetworks: allNetworksStateResp.enabledNetworks, + disabledNetworks: allNetworksStateResp.disabledNetworks, + }, + allNetworks, + compatibleNetworks, + }; + }, + [accountId, walletId], + { + swrKey: swrKeys.unifiedNetworkSelectorMeta({ walletId, accountId }), + }, + ); + + // Derive `networks` straight from the SWR result so the first render + // reflects cached data without a frame of empty arrays. Using useMemo + // instead of useState+useEffect eliminates the "no results" flash on + // Portfolio tab — previously the effect only copied networkMeta into + // state after mount, so the first render paint saw empty arrays. + // + // `networksState` stays as useState (seeded from cache above) because + // handleAddCustomNetwork needs a setter to optimistically toggle + // enable/disable before the bg round-trip. + const networks = useMemo<{ + allNetworks: IServerNetworkMatch[]; + mainNetworks: IServerNetworkMatch[]; + frequentlyUsedNetworks: IServerNetworkMatch[]; + }>( + () => ({ + allNetworks: networkMeta?.allNetworks ?? [], + mainNetworks: networkMeta?.compatibleNetworks.mainnetItems ?? [], + frequentlyUsedNetworks: + networkMeta?.compatibleNetworks.frequentlyUsedItems ?? [], + }), + [networkMeta], + ); + + useEffect(() => { + void preloadNetworkImagesV2(networks.allNetworks); + }, [networks.allNetworks]); + + // Revalidation may refresh the initial selection, but must not overwrite + // edits made while cached networks are already interactive. + useEffect(() => { + if (!networkMeta || hasLocalNetworkChanges.current) return; + setNetworksState(networkMeta.allNetworksState); + }, [accountId, networkMeta, walletId]); + + // Keep the summary and checkboxes in the same render as the selection change. + const enabledNetworks = useMemo( + () => + networks.mainNetworks.filter((network) => + isEnabledNetworksInAllNetworks({ + networkId: network.id, + enabledNetworks: networksState.enabledNetworks, + disabledNetworks: networksState.disabledNetworks, + isTestnet: network.isTestnet, + }), + ), + [networksState, networks.mainNetworks], + ); + useEffect(() => { + if (!enabledNetworksInit.current && networks.allNetworks.length > 0) { + setOriginalEnabledNetworks(enabledNetworks); + enabledNetworksInit.current = true; + } + }, [enabledNetworks, networks.allNetworks.length]); + + const compatibleNetworks = networkMeta?.compatibleNetworks; + + // Balances + DeFi: now SWR-cached via the cold-start MMKV instance so the + // "networks with assets" (有资产的网络) section is present on the very first + // render frame, eliminating the layout jump that happened when this section + // popped in only after the async resolved. The request still always fires and + // revalidates the cache in place; the cached values are local USD snapshots, + // so brief staleness before revalidation is acceptable. Only primitive + // (MMKV-serializable) fields are returned — Record values, a + // currency string, and Record DeFi overview; + // never the IServerNetwork objects from compatibleNetworks. Depends on + // compatibleNetworks for the sort step, so meta changes fan out here via the + // `compatibleNetworks` dep. + const { result: accountValuesResult } = usePromiseResult( + async (): Promise< + | { + accountNetworkValues: Record; + currency: string | undefined; + accountDeFiOverview: Record; + } + | undefined + > => { + // Return `undefined` (not an empty object) when we cannot compute a real + // result yet. usePromiseResult only writes the swr cache when the result + // is `!== undefined`, so this prevents a transient pre-meta run from + // overwriting a previously-good cached snapshot (which would bring the + // layout jump back on the next cold start). An empty *computed* result + // below (account genuinely has no assets) is still returned and cached. + if (!compatibleNetworks) { + return undefined; + } + if (!accountId && !indexedAccountId) { + return undefined; + } + + const [_accountsValue, _localDeFiOverview] = await Promise.all([ + backgroundApiProxy.serviceAccountProfile.getAllNetworkAccountsValueByAccountId( + { accountId: indexedAccountId ?? accountId ?? '' }, + ), + backgroundApiProxy.serviceDeFi.getAccountsLocalDeFiOverview({ + accounts: [ + { + accountId: indexedAccountId ?? accountId ?? '', + networkId: getNetworkIdsMap().onekeyall, + indexedAccountId, + }, + ], + networksEnabledOnly: false, + }), + ]); + + if (_accountsValue || _localDeFiOverview[0]) { + const { + formattedAccountNetworkValues, + accountDeFiOverview: _accountDeFiOverview, + } = + await backgroundApiProxy.serviceNetwork.sortChainSelectorNetworksByValue( + { + walletId: accountUtils.getWalletIdFromAccountId({ + accountId: _accountsValue?.accountId ?? '', + }), + chainSelectorNetworks: compatibleNetworks, + accountNetworkValues: _accountsValue?.value ?? {}, + localDeFiOverview: _localDeFiOverview[0]?.overview ?? {}, + }, + ); + + return { + accountNetworkValues: formattedAccountNetworkValues ?? {}, + currency: _accountsValue?.currency, + accountDeFiOverview: _accountDeFiOverview ?? {}, + }; + } + + // Defensive: `_accountsValue` is always a truthy object, so this branch + // is effectively unreachable, but if it ever is hit we have no data to + // compute — return `undefined` to leave the cache untouched. + return undefined; + }, + // walletId is kept in deps because it feeds the swrKey: a wallet-scope + // change must trigger revalidation. exhaustive-deps only inspects the + // callback body (which derives its own walletId from the resolved account) + // and therefore flags walletId as unnecessary — suppress that here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [accountId, indexedAccountId, compatibleNetworks, walletId], + { + swrKey: swrKeys.unifiedNetworkSelectorValues({ + walletId, + accountId, + indexedAccountId, + }), + }, + ); + + // Derive the portfolio values straight from the SWR result so the first + // render reflects cached data without a frame of empty objects. Declared + // after the usePromiseResult above to keep hook ordering stable. + const accountNetworkValues = useMemo( + () => accountValuesResult?.accountNetworkValues ?? {}, + [accountValuesResult], + ); + const accountNetworkValueCurrency = useMemo( + () => accountValuesResult?.currency, + [accountValuesResult], + ); + const accountDeFiOverview = useMemo( + () => accountValuesResult?.accountDeFiOverview ?? {}, + [accountValuesResult], + ); + + // Refresh portfolio data when a custom network is added. Meta revalidation + // produces a new compatibleNetworks reference, which cascades into the + // values hook via its deps — no explicit values refresh needed here. + useEffect(() => { + const fn = async () => { + try { + // alwaysSetState bypasses the isFocused guard because this event can + // fire while the back-nav animation is still running. + await refreshNetworkMeta({ alwaysSetState: true }); + } catch { + // silently ignore refresh errors + } + }; + appEventBus.on(EAppEventBusNames.AddedCustomNetwork, fn); + return () => { + appEventBus.off(EAppEventBusNames.AddedCustomNetwork, fn); + }; + }, [refreshNetworkMeta]); + + // Network tab callbacks + const handleNetworkPressItem = useCallback( + async (item: IServerNetwork) => { + if ( + sceneName === EAccountSelectorSceneName.home || + sceneName === EAccountSelectorSceneName.homeUrlAccount + ) { + // Log network switch if needed + } + + if (recordNetworkHistoryEnabled && activeNetwork) { + void backgroundApiProxy.serviceNetwork.updateRecentNetwork({ + networkId: activeNetwork.id, + }); + } + + try { + await actions.current.updateSelectedAccountNetwork({ + num, + networkId: item.id, + }); + } finally { + // Surgically drop only the ChainSelectorModal route. popStack() triggers + // the iOS RNSScreenStack window=NIL retry storm, and resetAboveMainRoute + // would also close any parent modal that pushed us here. + // See ios-overlay-navigation-freeze.md. + resetChainSelectorModal(); + } + }, + [actions, num, recordNetworkHistoryEnabled, activeNetwork, sceneName], + ); + + const handleAddCustomNetwork = useCallback(() => { + navigation.push(EChainSelectorPagesEnum.ChainListSearch, { + onSuccess: async (network: IServerNetwork) => { + if (activeTabRef.current === 'portfolio') { + // Portfolio tab: enable the new network and persist to backend. + // Persist before publishing the refresh event so the refreshed + // cache also includes the new network's enabled state. + const newEnabledNetworks = { + ...networksState.enabledNetworks, + [network.id]: true, + }; + const newDisabledNetworks = { + ...networksState.disabledNetworks, + [network.id]: false, + }; + updateNetworksSelection({ + enabledNetworks: newEnabledNetworks, + disabledNetworks: newDisabledNetworks, + }); + await backgroundApiProxy.serviceAllNetwork.updateAllNetworksState({ + enabledNetworks: newEnabledNetworks, + disabledNetworks: newDisabledNetworks, + cacheContext: { walletId, accountId }, + }); + appEventBus.emit(EAppEventBusNames.AddedCustomNetwork, undefined); + } else { + // Network tab: select network and close modal (original behavior) + void handleNetworkPressItem(network); + } + }, + }); + }, [ + navigation, + handleNetworkPressItem, + networksState, + updateNetworksSelection, + walletId, + accountId, + ]); + + const handleEditCustomNetwork = useCallback( + async (network: IServerNetwork) => { + const rpcInfo = + await backgroundApiProxy.serviceCustomRpc.getCustomRpcForNetwork( + network.id, + ); + navigation.push(EChainSelectorPagesEnum.AddCustomNetwork, { + state: 'edit', + networkId: network.id, + networkName: network.name, + rpcUrl: rpcInfo?.rpc ?? '', + chainId: network.chainId, + symbol: network.symbol, + blockExplorerUrl: network.explorerURL, + onSuccess: () => { + // Refresh will be handled by event bus + }, + onDeleteSuccess: () => { + navigation.pop(); + }, + }); + }, + [navigation], + ); + + const isSameEnabledNetworks = useMemo(() => { + return ( + enabledNetworks.length === originalEnabledNetworks.length && + enabledNetworks.every((network) => + originalEnabledNetworks.find((item) => item.id === network.id), + ) + ); + }, [enabledNetworks, originalEnabledNetworks]); + + // Portfolio tab done handler + const handlePortfolioDone = useCallback(async () => { + setIsCreatingEnabledAddresses(true); + try { + if (!isOthersWallet) { + // 1. Find networks missing addresses + const networksWithoutAccount = await findNetworksWithoutAccount({ + accountId: accountId ?? '', + indexedAccountId, + enabledNetworks, + }); + + setEnabledNetworksWithoutAccount(networksWithoutAccount); + + // 2. Create missing addresses if any + if (networksWithoutAccount.length > 0) { + await createAddress({ + num: 0, + account: { + walletId, + networkId: getNetworkIdsMap().onekeyall, + indexedAccountId, + deriveType: 'default', + }, + customNetworks: networksWithoutAccount, + }); + } + } else { + setEnabledNetworksWithoutAccount([]); + } + + // 3. Save network state only when selection changed + if (!isSameEnabledNetworks) { + await backgroundApiProxy.serviceAllNetwork.updateAllNetworksState({ + enabledNetworks: networksState.enabledNetworks, + disabledNetworks: networksState.disabledNetworks, + cacheContext: { walletId, accountId }, + }); + + appEventBus.emit(EAppEventBusNames.EnabledNetworksChanged, undefined); + } + + // 4. Switch to All Networks if not already on it + if (!networkUtils.isAllNetwork({ networkId })) { + void backgroundApiProxy.serviceNetwork.updateRecentNetwork({ + networkId: getNetworkIdsMap().onekeyall, + }); + + void actions.current.updateSelectedAccountNetwork({ + num, + networkId: getNetworkIdsMap().onekeyall, + }); + } + + // pop() falls through to popStack() when UnifiedNetworkSelector is the + // modal root, triggering the iOS RNSScreenStack window=NIL retry storm. + // Surgically drop only the ChainSelectorModal route to preserve any parent + // modal that pushed us here. See ios-overlay-navigation-freeze.md. + resetChainSelectorModal(); + + void onNetworksChanged?.(); + } finally { + setIsCreatingEnabledAddresses(false); + } + }, [ + accountId, + actions, + createAddress, + enabledNetworks, + findNetworksWithoutAccount, + indexedAccountId, + networkId, + networksState.disabledNetworks, + networksState.enabledNetworks, + num, + onNetworksChanged, + walletId, + isSameEnabledNetworks, + isOthersWallet, + ]); + + // Header title renderer + const renderHeaderTitle = useCallback(() => { + if (showTabSwitcher) { + return ( + + ); + } + + // Show simple title for network-only mode + return ( + + + {intl.formatMessage({ id: ETranslations.global_networks })} + + + ); + }, [showTabSwitcher, activeTab, handleTabChange, intl]); + + // Header right button + const renderHeaderRight = useCallback( + () => ( + + ), + [handleAddCustomNetwork, intl], + ); + + // Portfolio footer button text + const confirmButtonText = useMemo(() => { + if (isCreatingEnabledAddresses) { + return intl.formatMessage({ + id: ETranslations.global_creating_address, + }); + } + + if (enabledNetworks.length <= 0) { + return intl.formatMessage({ + id: ETranslations.network_none_selected, + }); + } + + if (missingAddressCount > 0) { + return `${intl.formatMessage({ + id: ETranslations.global_create_address, + })} & ${intl.formatMessage({ + id: ETranslations.global_apply, + })}`; + } + + return intl.formatMessage({ + id: ETranslations.global_done, + }); + }, [ + isCreatingEnabledAddresses, + enabledNetworks.length, + missingAddressCount, + intl, + ]); + + // Check if done button should be disabled + const isConfirmDisabled = useMemo(() => { + if (enabledNetworks.length <= 0) { + return true; + } + if (isCreatingEnabledAddresses || isCreatingMissingAddresses) { + return true; + } + + return false; + }, [enabledNetworks, isCreatingEnabledAddresses, isCreatingMissingAddresses]); + + return ( + { + if (networkUtils.isAllNetwork({ networkId })) { + appEventBus.emit(EAppEventBusNames.AccountDataUpdate, undefined); + } + }} + > + + + {/* eslint-disable no-nested-ternary */} + {showTabSwitcher ? ( + platformEnv.isNative ? ( + } + style={{ flex: 1 }} + initialPage={TAB_TO_INDEX[initialTab]} + onPageSelected={handlePageSelected} + keyboardDismissMode="on-drag" + pageWidth="100%" + > + + + + + + + + ) : ( + <> + + + + + + + + ) + ) : ( + + + + )} + {/* eslint-enable no-nested-ternary */} + + {activeTab === 'portfolio' ? ( + + + {missingAddressCount > 0 ? ( + + {intl.formatMessage( + { + id: ETranslations.current_account_missing_addresses, + }, + { count: missingAddressCount }, + )} + + ) : null} + + + + ) : null} + + ); +} + +const UnifiedNetworkSelectorMemoV2 = memo(UnifiedNetworkSelectorV2); + +export default function UnifiedNetworkSelectorPageV2() { + const route = + useRoute< + RouteProp< + IChainSelectorParamList, + EChainSelectorPages.UnifiedNetworkSelector + > + >(); + + const { num, sceneName, sceneUrl } = route.params; + + return ( + + + + ); +} diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/index.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/index.tsx new file mode 100644 index 000000000000..e1679ef322d2 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/index.tsx @@ -0,0 +1 @@ +export { default } from './UnifiedNetworkSelectorV2'; diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx new file mode 100644 index 000000000000..469c23a8e299 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx @@ -0,0 +1,160 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from '@testing-library/react'; + +import { numberFormatAsRenderText } from '@onekeyhq/shared/src/utils/numberUtils'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +import { + getNetworkValueV2, + preloadNetworkImagesV2, + useNetworkListPresentationV2, +} from './useNetworkListPresentationV2'; + +const mockTheme = new Proxy({}, { get: () => ({ val: '#000000' }) }); +const mockPreloadImages = jest.fn((_sources: unknown[]) => + Promise.resolve(true), +); +let mockHideValue = false; +let mockCurrency = 'usd'; +let mockCurrencyMap: Record = {}; + +jest.mock('@onekeyhq/components', () => ({ + Image: { + preloadImages: (sources: unknown[]) => mockPreloadImages(sources), + }, + useTheme: () => mockTheme, +})); +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isNative: true }, +})); +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + useCurrencyPersistAtom: () => [{ currencyMap: mockCurrencyMap }], + useSettingsPersistAtom: () => [{ currencyInfo: { id: mockCurrency } }], + useSettingsValuePersistAtom: () => [{ hideValue: mockHideValue }], +})); + +describe('network currency presentation V2', () => { + beforeEach(() => { + mockPreloadImages.mockClear(); + mockHideValue = false; + mockCurrency = 'usd'; + mockCurrencyMap = { + usd: { unit: '$', value: 1 }, + btc: { unit: '₿', value: 0.000_001 }, + eur: { unit: '€', value: 0.8 }, + }; + }); + + it('preloads each native network image once at its rendered size', async () => { + await preloadNetworkImagesV2([ + { id: 'evm--1', logoURI: 'https://example.com/eth.png' }, + { id: 'evm--137', logoURI: 'https://example.com/eth.png' }, + { + id: 'custom--1', + logoURI: 'https://example.com/custom.png', + isCustomNetwork: true, + }, + ] as IServerNetwork[]); + + expect(mockPreloadImages).toHaveBeenCalledWith([ + { + uri: 'https://example.com/eth.png', + width: 32, + height: 32, + optimize: true, + cachePolicy: 'memory-disk', + }, + ]); + }); + + it('keeps a skeleton visible until a network image is ready', () => { + const { result } = renderHook(() => useNetworkListPresentationV2('usd')); + expect( + result.current.getNetworkLeading({ + id: 'evm--1', + name: 'Ethereum', + logoURI: 'https://example.com/eth.png', + } as IServerNetwork), + ).toMatchObject({ + image: { + uri: 'https://example.com/eth.png', + loadingStrategy: 'skeleton', + }, + }); + }); + + it('ignores missing DeFi entries when summing all networks', () => { + expect( + getNetworkValueV2({ + network: { + id: 'onekeyall--0', + isAllNetworks: true, + } as Parameters[0]['network'], + accountNetworkValues: { 'evm--1': '2' }, + accountDeFiOverview: { + 'evm--1': undefined as never, + 'evm--137': { netWorth: 3 }, + }, + }), + ).toBe('5'); + }); + + it('uses the original Currency exchange-rate conversion and target unit', () => { + mockCurrency = 'eur'; + const { result } = renderHook(() => useNetworkListPresentationV2('usd')); + expect(result.current.formatCurrencyValue('10')).toEqual({ + text: numberFormatAsRenderText('8', { + formatter: 'price', + formatterOptions: { currency: '€' }, + }), + }); + }); + + it('preserves small-value number segments instead of expanding the displayed zeros', () => { + mockCurrency = 'btc'; + const { result } = renderHook(() => useNetworkListPresentationV2('usd')); + const actual = result.current.formatCurrencyValue('2'); + const original = numberFormatAsRenderText('0.000002', { + formatter: 'price', + formatterOptions: { currency: '₿' }, + }); + expect(Array.isArray(original)).toBe(true); + expect(actual.textSegments).toEqual( + Array.isArray(original) + ? original.map((part) => + typeof part === 'string' + ? { text: part } + : { text: String(part.value), style: 'subscript' }, + ) + : undefined, + ); + expect(actual.textSegments).toContainEqual({ + text: '5', + style: 'subscript', + }); + }); + + it('does not serialize hidden money in either text or native text segments', () => { + mockHideValue = true; + mockCurrency = 'btc'; + const { result } = renderHook(() => useNetworkListPresentationV2('usd')); + expect(result.current.formatCurrencyValue('1234567.89')).toEqual({ + text: '****', + }); + }); + + it('retains the source unit while the selected currency rate is unavailable', () => { + mockCurrency = 'eur'; + delete mockCurrencyMap.eur; + const { result } = renderHook(() => useNetworkListPresentationV2('usd')); + expect(result.current.formatCurrencyValue('10')).toEqual({ + text: numberFormatAsRenderText('10', { + formatter: 'price', + formatterOptions: { currency: '$' }, + }), + }); + }); +}); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts new file mode 100644 index 000000000000..9991eaba0899 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts @@ -0,0 +1,235 @@ +import { useCallback, useMemo } from 'react'; + +import BigNumber from 'bignumber.js'; + +import { Image, useTheme } from '@onekeyhq/components'; +import { buildOptimizedImageSource } from '@onekeyhq/components/src/primitives/Image/optimization'; +import { convertFiat } from '@onekeyhq/kit/src/utils/fiatConvert'; +import { + useCurrencyPersistAtom, + useSettingsPersistAtom, + useSettingsValuePersistAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + numberFormat, + numberFormatAsRenderText, +} from '@onekeyhq/shared/src/utils/numberUtils'; +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +import type { IServerNetworkMatch } from '../../types'; +import type { + IdentityRow, + LeadingVisual, + NativeListTheme, + TrailingAccessory, + ValueTextSegment, +} from '@onekeyfe/react-native-native-list'; + +type INetworkCurrencyTextV2 = Pick< + Extract, + 'text' | 'textSegments' +>; + +export function getNetworkValueV2({ + network, + accountNetworkValues, + accountDeFiOverview, +}: { + network: IServerNetwork; + accountNetworkValues: Record; + accountDeFiOverview: Record; +}): string { + if (network.isAllNetworks) { + return Object.values(accountDeFiOverview) + .reduce( + (total, value) => total.plus(value?.netWorth ?? 0), + Object.values(accountNetworkValues).reduce( + (total, value) => total.plus(value ?? '0'), + new BigNumber(0), + ), + ) + .toFixed(); + } + if (accountNetworkValues[network.id] === undefined) { + return '0'; + } + return new BigNumber(accountNetworkValues[network.id] ?? '0') + .plus(accountDeFiOverview[network.id]?.netWorth ?? 0) + .toFixed(); +} + +export function getNetworkTitleMatchV2( + network: IServerNetworkMatch, +): IdentityRow['titleMatch'] { + // MatchSizeableText highlights the longest Fuse match, with an earlier + // starting position breaking ties. Fuse's end position is inclusive. + const match = network.titleMatch?.indices.reduce< + readonly [number, number] | undefined + >((best, current) => { + if ( + !best || + current[1] - current[0] > best[1] - best[0] || + (current[1] - current[0] === best[1] - best[0] && current[0] < best[0]) + ) { + return current; + } + return best; + }, undefined); + return match ? [{ start: match[0], end: match[1] + 1 }] : undefined; +} + +export async function preloadNetworkImagesV2(networks: IServerNetwork[]) { + if (!platformEnv.isNative) return true; + const uris = [ + ...new Set( + networks + .filter( + (network) => + !network.isAllNetworks && + !network.isCustomNetwork && + Boolean(network.logoURI), + ) + .map((network) => network.logoURI), + ), + ]; + if (!uris.length) return true; + return Image.preloadImages( + uris.map((uri) => ({ + uri, + width: 32, + height: 32, + optimize: true, + cachePolicy: 'memory-disk', + })), + ); +} + +export function useNetworkListPresentationV2(sourceCurrency?: string) { + const theme = useTheme(); + const [{ currencyMap }] = useCurrencyPersistAtom(); + const [{ currencyInfo }] = useSettingsPersistAtom(); + const [settingsValue] = useSettingsValuePersistAtom(); + + const nativeTheme = useMemo( + () => ({ + background: theme.bgApp.val, + rowBackground: theme.bgApp.val, + rowSelectedBackground: theme.bgActive.val, + rowPressedBackground: theme.bgActive.val, + subduedBackground: theme.bgSubdued.val, + strongBackground: theme.bgStrong.val, + primaryText: theme.text.val, + secondaryText: theme.textSubdued.val, + disabledText: theme.textDisabled.val, + icon: theme.icon.val, + iconSubdued: theme.iconSubdued.val, + separator: theme.borderSubdued.val, + accent: theme.bgAccent.val, + positive: theme.textSuccess.val, + negative: theme.textCritical.val, + criticalBackground: theme.bgCritical.val, + inverseBackground: theme.bgInverse.val, + inverseText: theme.textInverse.val, + info: theme.textInfo.val, + checkboxBackground: theme.bgPrimary.val, + checkboxBorder: theme.borderStrong.val, + checkboxIcon: theme.iconInverse.val, + }), + [theme], + ); + + const formatCurrencyValue = useCallback( + (value: string): INetworkCurrencyTextV2 => { + if (settingsValue.hideValue) return { text: '****' }; + const effectiveSource = sourceCurrency ?? currencyInfo.id; + const effectiveTarget = currencyInfo.id; + const convertedValue = convertFiat({ + value, + sourceCurrency: effectiveSource, + targetCurrency: effectiveTarget, + currencyMap, + }); + const options = { + formatter: 'price' as const, + formatterOptions: { + currency: + currencyMap[effectiveTarget]?.unit ?? + currencyMap[effectiveSource]?.unit, + }, + }; + const rendered = numberFormatAsRenderText(convertedValue, options); + if (typeof rendered === 'string') return { text: rendered }; + return { + text: numberFormat(convertedValue, options), + textSegments: rendered.map((segment) => + typeof segment === 'string' + ? { text: segment } + : { + text: String(segment.value), + ...(segment.type === 'sub' ? { style: 'subscript' } : {}), + }, + ), + }; + }, + [currencyInfo.id, currencyMap, settingsValue.hideValue, sourceCurrency], + ); + + const getNetworkLeading = useCallback( + (network: IServerNetwork): LeadingVisual => { + if (network.isAllNetworks) { + return { + kind: 'icon', + name: 'AllNetworksSolid', + tintColor: theme.iconActive.val, + }; + } + if (network.isCustomNetwork) { + return { + kind: 'network', + shape: 'circle', + fallbackText: network.name[0]?.toUpperCase() ?? '', + backgroundColor: theme.bgInverse.val, + }; + } + const optimizedImage = platformEnv.isNative + ? undefined + : buildOptimizedImageSource({ + source: { uri: network.logoURI }, + resolvedSource: { uri: network.logoURI }, + width: 32, + height: 32, + allowRelativeUrl: platformEnv.isWeb || platformEnv.isWebEmbed, + }); + return { + kind: 'network', + image: { + uri: optimizedImage?.source?.uri ?? network.logoURI, + retryTimes: 1, + ...(optimizedImage?.optimized + ? { fallbackUri: optimizedImage.rawUri } + : {}), + width: 32, + height: 32, + contentFit: 'cover', + cachePolicy: 'memory-disk', + loadingStrategy: 'skeleton', + }, + shape: 'circle', + backgroundColor: theme.bgApp.val, + fallbackIcon: { + name: 'GlobusOutline', + tintColor: theme.iconSubdued.val, + }, + }; + }, + [theme], + ); + + return { + nativeTheme, + formatCurrencyValue, + getNetworkLeading, + sectionBackground: theme.bg.val, + }; +} diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkTooltipV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkTooltipV2.tsx new file mode 100644 index 000000000000..9be6686b73ce --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkTooltipV2.tsx @@ -0,0 +1,156 @@ +import type { RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useIntl } from 'react-intl'; + +import { + Popover, + SizableText, + Stack, + Tooltip, + YStack, +} from '@onekeyhq/components'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import type { + ActionAnchorInvalidatedEvent, + NativeListActionAnchor, + NativeListRef, + RowActionEvent, +} from '@onekeyfe/react-native-native-list'; +import type { View } from 'react-native'; + +type INetworkTooltipV2 = { + anchor: NativeListActionAnchor; + left: number; + top: number; + message: string; + isEdit: boolean; +}; + +export function useNetworkTooltipV2(listRef: RefObject) { + const intl = useIntl(); + const containerRef = useRef(null); + const activeTokenRef = useRef(undefined); + const [tooltip, setTooltip] = useState(); + + const closeTooltip = useCallback(() => { + const token = activeTokenRef.current; + activeTokenRef.current = undefined; + if (token) { + listRef.current?.setActionAnchorState({ token, open: false }); + } + setTooltip(undefined); + }, [listRef]); + + useEffect( + () => () => { + const token = activeTokenRef.current; + if (token) listRef.current?.setActionAnchorState({ token, open: false }); + }, + [listRef], + ); + + const showTooltip = useCallback( + (event: RowActionEvent) => { + const isEdit = event.actionKey === 'network.tooltip.edit'; + let translation = ETranslations.network_selection_performance_tip; + if (isEdit) { + translation = ETranslations.global_edit; + } else if (event.actionKey === 'network.tooltip.assets') { + translation = ETranslations.network_auto_detection_tip; + } + const { anchor } = event; + if (!anchor) return; + closeTooltip(); + activeTokenRef.current = anchor.token; + listRef.current?.setActionAnchorState({ + token: anchor.token, + open: true, + }); + containerRef.current?.measureInWindow((x, y) => { + if (activeTokenRef.current !== anchor.token) return; + setTooltip({ + anchor, + left: anchor.windowRect.x - x, + top: anchor.windowRect.y - y, + message: intl.formatMessage({ id: translation }), + isEdit, + }); + }); + }, + [closeTooltip, intl, listRef], + ); + + const onActionAnchorInvalidated = useCallback( + (event: ActionAnchorInvalidatedEvent) => { + if (activeTokenRef.current === event.token) closeTooltip(); + }, + [closeTooltip], + ); + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) closeTooltip(); + }, + [closeTooltip], + ); + + const tooltipElement = tooltip ? ( + + {platformEnv.isNative ? ( + + } + renderContent={ + + {tooltip.message} + + } + /> + ) : ( + + } + renderContent={tooltip.message} + /> + )} + + ) : null; + + return { + containerRef, + showTooltip, + closeTooltip, + onActionAnchorInvalidated, + tooltipElement, + }; +} diff --git a/packages/kit/src/views/ChainSelector/router/index.ts b/packages/kit/src/views/ChainSelector/router/index.ts index 7e359b5f82b8..256a89cd9aeb 100644 --- a/packages/kit/src/views/ChainSelector/router/index.ts +++ b/packages/kit/src/views/ChainSelector/router/index.ts @@ -27,7 +27,10 @@ const MultiNetworkSelector = LazyLoadPage( const ChainListSearch = LazyLoadPage(() => import('../pages/ChainListSearch')); const UnifiedNetworkSelector = LazyLoadPage( - () => import('../components/UnifiedNetworkSelector'), + () => import('../components/UnifiedNetworkSelectorV2'), + undefined, + undefined, + false, ); export const ChainSelectorRouter: IModalFlowNavigatorConfig< diff --git a/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx b/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx new file mode 100644 index 000000000000..b750c7d12be8 --- /dev/null +++ b/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + Button, + Page, + Progress, + SizableText, + Toast, + YStack, +} from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import type { IAppEventBusPayload } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { appEventBus } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { EAppEventBusNames } from '@onekeyhq/shared/src/eventBus/appEventBusNames'; + +type ILargeWalletDataCreationProgress = + IAppEventBusPayload[EAppEventBusNames.DevLargeWalletDataCreationProgress]; + +const INITIAL_PROGRESS: ILargeWalletDataCreationProgress = { + isRunning: false, + walletIndex: 0, + walletsCreated: 0, + walletsTotal: 10, + accountsCreatedInWallet: 0, + accountsPerWallet: 100, + accountsCreated: 0, + accountsTotal: 1000, +}; + +function getErrorMessage(error: unknown) { + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message; + } + return 'Unknown error'; +} + +export default function DevLargeWalletDataCreation() { + const [isRunning, setIsRunning] = useState(false); + const [progress, setProgress] = useState(INITIAL_PROGRESS); + + useEffect(() => { + const handleProgress = (value: ILargeWalletDataCreationProgress) => { + setProgress(value); + setIsRunning(value.isRunning); + }; + appEventBus.on( + EAppEventBusNames.DevLargeWalletDataCreationProgress, + handleProgress, + ); + return () => { + appEventBus.off( + EAppEventBusNames.DevLargeWalletDataCreationProgress, + handleProgress, + ); + }; + }, []); + + const handleCreate = useCallback( + async (walletCount: 10 | 100) => { + if (isRunning) { + return; + } + + setProgress({ + ...INITIAL_PROGRESS, + walletsTotal: walletCount, + accountsTotal: walletCount * INITIAL_PROGRESS.accountsPerWallet, + }); + setIsRunning(true); + try { + const result = + await backgroundApiProxy.serviceDemo.createLargeWalletsAndAccounts({ + walletCount, + }); + Toast.success({ + title: 'Real HD wallet data ready', + message: `${result.walletsCreated.toLocaleString()} wallet(s) and ${result.accountsCreated.toLocaleString()} account(s) created in ${( + result.durationMs / 1000 + ).toFixed(1)}s`, + }); + } catch (error) { + Toast.error({ + title: 'Failed to create real HD wallet data', + message: getErrorMessage(error), + }); + } finally { + setIsRunning(false); + } + }, + [isRunning], + ); + + const progressPercent = Math.min( + 100, + Math.max(0, (progress.accountsCreated / progress.accountsTotal) * 100), + ); + + return ( + + + + + + Create Real HD Wallets + + Choose 10 or 100 independent HD wallets with encrypted recovery + phrases and 100 indexed accounts in each wallet. The wallets + support normal address derivation, signing, wallet operations, and + cloud sync. + + + The page uses the current unlocked session without asking for a + password. Recovery phrases are never shown or logged. Keep the app + open until creation finishes. Each run adds another complete data + set. + + + + + + + {progressPercent.toFixed(1)}% + + + + Current wallet: {progress.walletIndex.toLocaleString()} /{' '} + {progress.walletsTotal.toLocaleString()} + + + Wallets created: {progress.walletsCreated.toLocaleString()} /{' '} + {progress.walletsTotal.toLocaleString()} + + + Accounts in current wallet:{' '} + {progress.accountsCreatedInWallet.toLocaleString()} /{' '} + {progress.accountsPerWallet.toLocaleString()} + + + Total accounts: {progress.accountsCreated.toLocaleString()} /{' '} + {progress.accountsTotal.toLocaleString()} + + + + + + {([10, 100] as const).map((walletCount) => ( + + ))} + + + + + ); +} diff --git a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx index 7fd2a2f14225..adafb2954105 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx @@ -897,7 +897,7 @@ const BaseDevSettingsSection = () => { title: 'Account & Wallet & Prime & Network', description: '账户 钱包 Prime 链和网络', keywords: - '允许添加相同助记词HD钱包 启用Keyless调试信息 启用Keyless云端同步 允许重置Keyless钱包 Referral Bind Guard 10s Test Add ServerNetwork Test Data 开启Prime 开启Prime Sandbox付款 In-App-Purchase Mac 内购 首页导出私钥临时入口 Export Accounts Data', + '允许添加相同助记词HD钱包 启用Keyless调试信息 启用Keyless云端同步 允许重置Keyless钱包 Referral Bind Guard 10s Test Add ServerNetwork Test Data Create 1000 Wallets Accounts Large Data NativeList 性能 压力测试 开启Prime 开启Prime Sandbox付款 In-App-Purchase Mac 内购 首页导出私钥临时入口 Export Accounts Data', }, { key: 'transaction', @@ -2487,6 +2487,19 @@ const BaseDevSettingsSection = () => { }} /> + { + navigation.push( + EModalSettingRoutes.SettingDevLargeWalletDataCreation, + ); + }} + /> + import('@onekeyhq/kit/src/views/Setting/pages/DevDrawingOrderStress'), ); +const DevLargeWalletDataCreation = LazyLoadPage( + () => + import('@onekeyhq/kit/src/views/Setting/pages/DevLargeWalletDataCreation'), +); + export const BasicModalSettingStack: IModalFlowNavigatorConfig< EModalSettingRoutes | EModalAddressBookRoutes, IModalSettingParamList & IModalAddressBookParamList @@ -395,6 +400,10 @@ export const BasicModalSettingStack: IModalFlowNavigatorConfig< name: EModalSettingRoutes.SettingDevDrawingOrderStressModal, component: DevDrawingOrderStress, }, + { + name: EModalSettingRoutes.SettingDevLargeWalletDataCreation, + component: DevLargeWalletDataCreation, + }, ...(ModalAddressBookRouter as IModalFlowNavigatorConfig< EModalSettingRoutes | EModalAddressBookRoutes, IModalSettingParamList & IModalAddressBookParamList diff --git a/packages/shared/src/eventBus/appEventBus.ts b/packages/shared/src/eventBus/appEventBus.ts index c83b365f15bf..b31843356801 100644 --- a/packages/shared/src/eventBus/appEventBus.ts +++ b/packages/shared/src/eventBus/appEventBus.ts @@ -666,6 +666,16 @@ export interface IAppEventBusPayload { [EAppEventBusNames.BtcFreshAddressUpdated]: undefined; [EAppEventBusNames.BtcFreshAddressConnectDappRejected]: undefined; [EAppEventBusNames.BtcFindAddressUpdated]: undefined; + [EAppEventBusNames.DevLargeWalletDataCreationProgress]: { + isRunning: boolean; + walletIndex: number; + walletsCreated: number; + walletsTotal: number; + accountsCreatedInWallet: number; + accountsPerWallet: number; + accountsCreated: number; + accountsTotal: number; + }; [EAppEventBusNames.ClientLogUploadProgress]: { stage: ELogUploadStage; progressPercent?: number; diff --git a/packages/shared/src/eventBus/appEventBusNames.ts b/packages/shared/src/eventBus/appEventBusNames.ts index fcf3e2e70c45..e27495624396 100644 --- a/packages/shared/src/eventBus/appEventBusNames.ts +++ b/packages/shared/src/eventBus/appEventBusNames.ts @@ -192,6 +192,7 @@ export enum EAppEventBusNames { BtcFreshAddressUpdated = 'BtcFreshAddressUpdated', BtcFreshAddressConnectDappRejected = 'BtcFreshAddressConnectDappRejected', BtcFindAddressUpdated = 'BtcFindAddressUpdated', + DevLargeWalletDataCreationProgress = 'DevLargeWalletDataCreationProgress', ClientLogUploadProgress = 'ClientLogUploadProgress', SwitchDiscoveryTabInNative = 'SwitchDiscoveryTabInNative', SwitchEarnMode = 'SwitchEarnMode', diff --git a/packages/shared/src/routes/setting.ts b/packages/shared/src/routes/setting.ts index 15c0aa2dc512..9936a3a5bb77 100644 --- a/packages/shared/src/routes/setting.ts +++ b/packages/shared/src/routes/setting.ts @@ -51,6 +51,7 @@ export enum EModalSettingRoutes { SettingDevBundleUpdateStatusModal = 'SettingDevBundleUpdateStatusModal', SettingDevSplitBundleTestModal = 'SettingDevSplitBundleTestModal', SettingDevDrawingOrderStressModal = 'SettingDevDrawingOrderStressModal', + SettingDevLargeWalletDataCreation = 'SettingDevLargeWalletDataCreation', // OneKey ID sub-pages SettingOneKeyIdPersonalInfo = 'SettingOneKeyIdPersonalInfo', SettingOneKeyIdSignInSecurity = 'SettingOneKeyIdSignInSecurity', @@ -143,6 +144,7 @@ export type IModalSettingParamList = { [EModalSettingRoutes.SettingDevBundleUpdateStatusModal]: undefined; [EModalSettingRoutes.SettingDevSplitBundleTestModal]: undefined; [EModalSettingRoutes.SettingDevDrawingOrderStressModal]: undefined; + [EModalSettingRoutes.SettingDevLargeWalletDataCreation]: undefined; // OneKey ID sub-pages [EModalSettingRoutes.SettingOneKeyIdPersonalInfo]: undefined; [EModalSettingRoutes.SettingOneKeyIdSignInSecurity]: undefined; diff --git a/packages/shared/src/storage/nativeSWRCachePersistence.test.ts b/packages/shared/src/storage/nativeSWRCachePersistence.test.ts index 0b0e1d4c7338..fc3172e635d0 100644 --- a/packages/shared/src/storage/nativeSWRCachePersistence.test.ts +++ b/packages/shared/src/storage/nativeSWRCachePersistence.test.ts @@ -239,6 +239,38 @@ describe('nativeSWRCachePersistence', () => { }); }); + it('bounds account entries across runtime patches and removes evicted physical keys', async () => { + const mmkv = new FakeMMKV(); + const persistence = loadPersistence(mmkv); + await persistence.ensureMigrated(); + persistence.applyPatch({ + removePrefixes: [], + removals: [], + updates: [ + ['unrelated', JSON.stringify({ d: 'keep', t: 1 })], + ...[1, 2, 3].map( + (i) => + [`accSelList:v1:hd-${i}`, JSON.stringify({ d: i, t: i })] as const, + ), + ], + }); + persistence.applyPatch({ + removePrefixes: [], + removals: [], + updates: [['accSelList:v1:hd-4', JSON.stringify({ d: 4, t: 4 })]], + }); + + expect(JSON.parse(persistence.readSerialized())).toEqual({ + unrelated: { d: 'keep', t: 1 }, + 'accSelList:v1:hd-2': { d: 2, t: 2 }, + 'accSelList:v1:hd-3': { d: 3, t: 3 }, + 'accSelList:v1:hd-4': { d: 4, t: 4 }, + }); + expect( + mmkv.getAllKeys().some((key) => key.endsWith(':accSelList:v1:hd-1')), + ).toBe(false); + }); + it('drops a logical key whose UTF-8 bytes exceed the MMKV key budget', async () => { const mmkv = new FakeMMKV(); const persistence = loadPersistence(mmkv); diff --git a/packages/shared/src/utils/swrCacheLimits.ts b/packages/shared/src/utils/swrCacheLimits.ts index 1a261650b700..674ac2dc360b 100644 --- a/packages/shared/src/utils/swrCacheLimits.ts +++ b/packages/shared/src/utils/swrCacheLimits.ts @@ -4,6 +4,11 @@ export const SWR_CACHE_MAX_SERIALIZED_CHARS = 100 * 1024 * 1024; export const SWR_CACHE_MAX_KEY_CHARS = 20_000; export const SWR_CACHE_MAX_KEY_UTF8_BYTES = 59_000; +// Account lists can contain thousands of records per wallet/network scope. +// Keep recent scopes warm without consuming the budget for unrelated caches. +export const SWR_ACCOUNT_SELECTOR_MAX_ENTRIES = 3; +export const SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS = 6 * 1024 * 1024; + function getUtf8ByteLength(value: string) { let byteLength = 0; for (const character of value) { diff --git a/packages/shared/src/utils/swrCacheUtils.test.ts b/packages/shared/src/utils/swrCacheUtils.test.ts index ec2df0034921..ea4c56ff241c 100644 --- a/packages/shared/src/utils/swrCacheUtils.test.ts +++ b/packages/shared/src/utils/swrCacheUtils.test.ts @@ -1,3 +1,7 @@ +import { + SWR_ACCOUNT_SELECTOR_MAX_ENTRIES, + SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS, +} from './swrCacheLimits'; import { SWR_CACHE_MAX_ENTRIES, SWR_CACHE_MAX_ENTRY_SERIALIZED_CHARS, @@ -604,6 +608,86 @@ describe('SWR cache cross-runtime flush merge', () => { expect(SWR_CACHE_MAX_SERIALIZED_CHARS).toBe(100 * 1024 * 1024); }); + it('bounds account scopes during hydration without evicting other namespaces', () => { + const accounts = Object.fromEntries( + Array.from({ length: 8 }, (_, i) => [ + `accSelList:v1:hd-${i}:default:::1`, + { d: { walletId: `hd-${i}` }, t: i + 1 }, + ]), + ); + otherRuntimeFlush({ ...accounts, walletList: { d: 'keep', t: 0 } }); + const swr = loadFreshRuntime(); + + expect(swr.get('accSelList:v1:hd-0:default:::1')).toBeUndefined(); + expect(swr.get('accSelList:v1:hd-7:default:::1')).toEqual({ + walletId: 'hd-7', + }); + expect(swr.get('walletList')).toBe('keep'); + swr.flushNow(); + expect( + Object.keys(readDiskStore()).filter((key) => + key.startsWith('accSelList:'), + ), + ).toHaveLength(SWR_ACCOUNT_SELECTOR_MAX_ENTRIES); + }); + + it('keeps recent account scopes bounded through updates and cross-runtime merges', () => { + const swr = loadFreshRuntime(); + swr.set('walletList', 'keep'); + for (let i = 0; i < 6; i += 1) { + setNow(1000 + i); + swr.set(`accSelList:v1:hd-${i}:default:::1`, i); + } + expect(swr.get('accSelList:v1:hd-2:default:::1')).toBeUndefined(); + expect(swr.get('accSelList:v1:hd-3:default:::1')).toBe(3); + setNow(2000); + swr.set('accSelList:v1:hd-3:default:::1', 'refreshed'); + swr.flushNow(); + otherRuntimeFlush({ + ...readDiskStore(), + 'accSelList:v1:hd-0:default:::1': { d: 'stale', t: 500 }, + 'accSelList:v1:hd-6:default:::1': { d: 6, t: 3000 }, + unrelated: { d: 'other runtime', t: 0 }, + }); + setNow(4000); + swr.set('unrelated-local', 'new'); + swr.flushNow(); + + expect(swr.get('accSelList:v1:hd-0:default:::1')).toBeUndefined(); + expect(swr.get('accSelList:v1:hd-4:default:::1')).toBeUndefined(); + expect(swr.get('accSelList:v1:hd-3:default:::1')).toBe('refreshed'); + expect(swr.get('accSelList:v1:hd-6:default:::1')).toBe(6); + expect(readDiskStore().walletList.d).toBe('keep'); + expect(readDiskStore().unrelated.d).toBe('other runtime'); + expect( + Object.keys(readDiskStore()).filter((key) => + key.startsWith('accSelList:'), + ), + ).toHaveLength(SWR_ACCOUNT_SELECTOR_MAX_ENTRIES); + }); + + it('enforces the account serialized budget on writes and persistence pruning', () => { + const swr = loadFreshRuntime(); + const value = 'x'.repeat(SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS / 2); + swr.set('unrelated', 'keep'); + swr.set('accSelList:v1:first', value); + setNow(2000); + swr.set('accSelList:v1:second', value); + expect(swr.get('accSelList:v1:first')).toBeUndefined(); + expect(swr.get('accSelList:v1:second')).toBe(value); + swr.flushNow(); + const result = pruneSWRCacheStore({ + 'accSelList:v1:first': { d: value, t: 1 }, + 'accSelList:v1:second': { d: value, t: 2 }, + unrelated: { d: 'keep', t: 0 }, + }); + expect(Object.keys(result.store)).toEqual([ + 'accSelList:v1:second', + 'unrelated', + ]); + expect(readDiskStore().unrelated.d).toBe('keep'); + }); + it('retains a value larger than the former per-entry budget', () => { const swr = loadFreshRuntime(); const value = 'x'.repeat(1024 * 1024); @@ -797,6 +881,29 @@ describe('SWR cache native incremental persistence', () => { fakeDiskGlobal.__swrUsePatch = false; }); + it('includes account eviction intents in the native patch', () => { + const swr = loadFreshRuntime(); + const clock = jest.spyOn(Date, 'now'); + try { + for (let i = 0; i < 5; i += 1) { + clock.mockReturnValue(1000 + i); + swr.set(`accSelList:v1:hd-${i}`, i); + swr.flushNow(); + } + const disk = readDiskStore(); + expect(Object.keys(disk).toSorted()).toEqual([ + 'accSelList:v1:hd-2', + 'accSelList:v1:hd-3', + 'accSelList:v1:hd-4', + ]); + expect(fakeDiskGlobal.__swrPatches?.at(-1)).toMatchObject({ + removals: [['accSelList:v1:hd-1', 1004]], + }); + } finally { + clock.mockRestore(); + } + }); + it('flushes only the changed entry instead of the hydrated store', () => { otherRuntimeFlush({ existing: { d: 'x'.repeat(100_000), t: 1 }, diff --git a/packages/shared/src/utils/swrCacheUtils.ts b/packages/shared/src/utils/swrCacheUtils.ts index 37d723576b4f..48733e04945b 100644 --- a/packages/shared/src/utils/swrCacheUtils.ts +++ b/packages/shared/src/utils/swrCacheUtils.ts @@ -3,6 +3,8 @@ import { defaultLogger } from '../logger/logger'; import { EAppSyncStorageKeys } from '../storage/syncStorageKeys'; import { + SWR_ACCOUNT_SELECTOR_MAX_ENTRIES, + SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS, SWR_CACHE_MAX_ENTRIES, SWR_CACHE_MAX_ENTRY_SERIALIZED_CHARS, SWR_CACHE_MAX_KEY_CHARS, @@ -159,8 +161,32 @@ export function pruneSWRCacheStore( const retained: ISerializedSWRCacheEntry[] = []; let totalSerializedChars = 2; - for (const candidate of candidates) { + let accountSelectorEntries = 0; + let accountSelectorSerializedChars = 2; + const accountSelectorDrops: ISWRCacheCapacityDrop[] = []; + candidates.forEach((candidate) => { const separatorChars = retained.length > 0 ? 1 : 0; + const isAccountSelector = isAccountSelectorCacheKey(candidate.key); + const accountSeparatorChars = accountSelectorEntries > 0 ? 1 : 0; + if ( + isAccountSelector && + (accountSelectorEntries >= SWR_ACCOUNT_SELECTOR_MAX_ENTRIES || + accountSelectorSerializedChars + + accountSeparatorChars + + candidate.serializedChars > + SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS) + ) { + removedKeys.push(candidate.key); + accountSelectorDrops.push({ + entrySerializedChars: candidate.entrySerializedChars, + key: candidate.key, + reason: + accountSelectorEntries >= SWR_ACCOUNT_SELECTOR_MAX_ENTRIES + ? 'entryCountLimit' + : 'totalSizeLimit', + }); + return; + } let reason: ISWRCacheCapacityLimitReason | undefined; if (retained.length >= maxEntries) { reason = 'entryCountLimit'; @@ -180,8 +206,13 @@ export function pruneSWRCacheStore( } else { retained.push(candidate); totalSerializedChars += separatorChars + candidate.serializedChars; + if (isAccountSelector) { + accountSelectorEntries += 1; + accountSelectorSerializedChars += + accountSeparatorChars + candidate.serializedChars; + } } - } + }); retained.sort((left, right) => left.index - right.index); const retainedStore = {} as Record; @@ -201,6 +232,13 @@ export function pruneSWRCacheStore( retainedEntryCount: retained.length, retainedSerializedChars: totalSerializedChars, }); + reportSWRCacheCapacityDrops(accountSelectorDrops, { + maxEntries: SWR_ACCOUNT_SELECTOR_MAX_ENTRIES, + maxEntrySerializedChars, + maxSerializedChars: SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS, + retainedEntryCount: accountSelectorEntries, + retainedSerializedChars: accountSelectorSerializedChars, + }); return { removedKeys, @@ -423,6 +461,52 @@ function evictOldestOverBudget(store: ISWRStore, removedAt: number) { }); } +function evictOldestAccountSelectorEntries( + store: ISWRStore, + removedAt: number, +) { + const keys = Object.keys(store) + .filter(isAccountSelectorCacheKey) + .toSorted((a, b) => (store[a].t ?? 0) - (store[b].t ?? 0)); + let count = keys.length; + let serializedChars = + 2 + + Math.max(0, count - 1) + + keys.reduce( + (sum, key) => sum + (_cacheEntrySerializedChars.get(key) ?? 0), + 0, + ); + const drops: ISWRCacheCapacityDrop[] = []; + for (const key of keys) { + if ( + count <= SWR_ACCOUNT_SELECTOR_MAX_ENTRIES && + serializedChars <= SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS + ) { + break; + } + drops.push({ + key, + reason: + count > SWR_ACCOUNT_SELECTOR_MAX_ENTRIES + ? 'entryCountLimit' + : 'totalSizeLimit', + }); + serializedChars -= + (_cacheEntrySerializedChars.get(key) ?? 0) + (count > 1 ? 1 : 0); + count -= 1; + removeCachedEntry(store, key); + _updatedKeys.delete(key); + _removedKeysAt.set(key, removedAt); + } + reportSWRCacheCapacityDrops(drops, { + maxEntries: SWR_ACCOUNT_SELECTOR_MAX_ENTRIES, + maxEntrySerializedChars: SWR_CACHE_MAX_ENTRY_SERIALIZED_CHARS, + maxSerializedChars: SWR_ACCOUNT_SELECTOR_MAX_SERIALIZED_CHARS, + retainedEntryCount: count, + retainedSerializedChars: serializedChars, + }); +} + function adoptPrunedStore(store: ISWRStore): ISWRStore { const result = pruneSWRCacheStore(store); resetCacheSerializedChars(result.store); @@ -585,6 +669,9 @@ function set(key: string, data: T): void { setCachedEntry(store, serializedEntry); _updatedKeys.add(key); _dirty = true; + if (isAccountSelectorCacheKey(key)) { + evictOldestAccountSelectorEntries(store, now); + } evictOldestOverBudget(store, now); scheduleFlush(); } @@ -699,6 +786,10 @@ export type ISwrCacheNamespace = (typeof NS)[keyof typeof NS]; export const swrCacheNamespaces = NS; export const prefixOf = (namespace: ISwrCacheNamespace) => `${namespace}:`; +function isAccountSelectorCacheKey(key: string) { + return key.startsWith(`${NS.accountSelectorList}:`); +} + const SWR_CACHE_SAFE_LOG_NAMESPACES = Object.values(NS); function getSafeSWRCacheLogNamespace(key: string) { diff --git a/packages/shared/src/utils/tosImageResizeUtils.test.ts b/packages/shared/src/utils/tosImageResizeUtils.test.ts index dd1bb0421800..99ba8643319a 100644 --- a/packages/shared/src/utils/tosImageResizeUtils.test.ts +++ b/packages/shared/src/utils/tosImageResizeUtils.test.ts @@ -5,6 +5,59 @@ import { } from './tosImageResizeUtils'; describe('tosImageResizeUtils', () => { + test('selects the layout tier first and bounds density choices within it', () => { + for (const resizeWidth of [32, 40, 48]) { + for (const pixelRatio of [1, 2]) { + expect(getTosImageResizeTargetWidth({ resizeWidth, pixelRatio })).toBe( + 96, + ); + } + for (const pixelRatio of [2.625, 3, 5]) { + expect(getTosImageResizeTargetWidth({ resizeWidth, pixelRatio })).toBe( + 160, + ); + } + } + for (const [resizeWidth, standard, highDensity] of [ + [48, 96, 160], + [48.1, 192, 320], + [96, 192, 320], + [96.1, 384, 640], + [192, 384, 640], + [192.1, 768, 1280], + [384, 768, 1280], + [384.1, 1280, 1280], + ]) { + expect(getTosImageResizeTargetWidth({ resizeWidth, pixelRatio: 2 })).toBe( + standard, + ); + expect( + getTosImageResizeTargetWidth({ resizeWidth, pixelRatio: 2.625 }), + ).toBe(highDensity); + } + expect( + getTosImageResizeTargetWidth({ + resizeWidth: 48, + pixelRatio: 2, + overscanRatio: 2, + }), + ).toBe(160); + expect( + getTosImageResizeTargetWidth({ + resizeWidth: 100, + pixelRatio: NaN, + overscanRatio: Infinity, + }), + ).toBe(384); + expect( + getTosImageResizeTargetWidth({ + resizeWidth: 100, + pixelRatio: 2, + overscanRatio: Number.MAX_VALUE, + }), + ).toBe(640); + }); + test('optimizes a whitelisted image URL with a stable DPR width bucket', () => { const result = buildTosImageResizeUrl({ uri: 'https://uni.onekey-asset.com/icons/token.png', @@ -15,8 +68,8 @@ describe('tosImageResizeUtils', () => { expect(result).toEqual({ optimized: true, - targetWidth: 48, - uri: 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_48', + targetWidth: 96, + uri: 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_96', }); }); @@ -29,9 +82,9 @@ describe('tosImageResizeUtils', () => { }); expect(result.optimized).toBe(true); - expect(result.targetWidth).toBe(256); + expect(result.targetWidth).toBe(320); expect(result.uri).toBe( - 'https://common.onekey-asset.com/a/b/logo.jpeg?foo=bar&x-tos-process=image%2Fresize%2Cw_256#preview', + 'https://common.onekey-asset.com/a/b/logo.jpeg?foo=bar&x-tos-process=image%2Fresize%2Cw_320#preview', ); }); @@ -44,7 +97,7 @@ describe('tosImageResizeUtils', () => { }); expect(result.optimized).toBe(true); - expect(result.targetWidth).toBe(320); + expect(result.targetWidth).toBe(384); }); test('uses a resize width hint as display width without requiring exact layout dimensions', () => { @@ -56,8 +109,8 @@ describe('tosImageResizeUtils', () => { expect(result).toEqual({ optimized: true, - targetWidth: 256, - uri: 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_256', + targetWidth: 384, + uri: 'https://uni.onekey-asset.com/icons/token.png?x-tos-process=image%2Fresize%2Cw_384', }); }); @@ -127,7 +180,7 @@ describe('tosImageResizeUtils', () => { }); expect(result.optimized).toBe(true); - expect(result.targetWidth).toBe(200); + expect(result.targetWidth).toBe(192); }); test('requires exact whitelisted hosts', () => { @@ -251,9 +304,9 @@ describe('tosImageResizeUtils', () => { ).toBe(96); expect( getTosImageResizeTargetWidth({ resizeWidth: 100, pixelRatio: 2 }), - ).toBe(256); + ).toBe(384); expect(TOS_IMAGE_RESIZE_WIDTH_BUCKETS).toEqual([ - 32, 40, 48, 64, 96, 128, 160, 200, 256, 320, 480, 640, 960, 1280, + 96, 160, 192, 320, 384, 640, 768, 1280, ]); }); }); diff --git a/packages/shared/src/utils/tosImageResizeUtils.ts b/packages/shared/src/utils/tosImageResizeUtils.ts index 3c39c070aa54..eb2881f0d00c 100644 --- a/packages/shared/src/utils/tosImageResizeUtils.ts +++ b/packages/shared/src/utils/tosImageResizeUtils.ts @@ -1,5 +1,15 @@ +// Final CDN pixel widths shared by the layout tiers below. export const TOS_IMAGE_RESIZE_WIDTH_BUCKETS = [ - 32, 40, 48, 64, 96, 128, 160, 200, 256, 320, 480, 640, 960, 1280, + 96, 160, 192, 320, 384, 640, 768, 1280, +] as const; + +// [maximum layout size, standard rendition, high-density rendition] +const TOS_IMAGE_RESIZE_SIZE_TIERS = [ + [48, 96, 160], + [96, 192, 320], + [192, 384, 640], + [384, 768, 1280], + [Infinity, 1280, 1280], ] as const; const TOS_IMAGE_RESIZE_ALLOWED_HOSTS = new Set([ @@ -95,13 +105,10 @@ function getNormalizedPixelRatio({ const safeMaxPixelRatio = isPositiveFiniteNumber(maxPixelRatio) ? maxPixelRatio : DEFAULT_MAX_PIXEL_RATIO; - return Math.min(safePixelRatio, safeMaxPixelRatio); -} - -function getBucketedWidth(width: number) { - return ( - TOS_IMAGE_RESIZE_WIDTH_BUCKETS.find((bucket) => bucket >= width) ?? - TOS_IMAGE_RESIZE_WIDTH_BUCKETS[TOS_IMAGE_RESIZE_WIDTH_BUCKETS.length - 1] + return Math.min( + Math.max(safePixelRatio, 1), + safeMaxPixelRatio, + DEFAULT_MAX_PIXEL_RATIO, ); } @@ -247,15 +254,16 @@ export function getTosImageResizeTargetWidth({ } const safeOverscanRatio = isPositiveFiniteNumber(overscanRatio) - ? overscanRatio + ? Math.max(overscanRatio, 1) : DEFAULT_OVERSCAN_RATIO; - const targetWidth = Math.ceil( - displaySize * - getNormalizedPixelRatio({ pixelRatio, maxPixelRatio }) * - safeOverscanRatio, - ); - - return getBucketedWidth(targetWidth); + const tier = + TOS_IMAGE_RESIZE_SIZE_TIERS.find(([maxSize]) => displaySize <= maxSize) ?? + TOS_IMAGE_RESIZE_SIZE_TIERS[TOS_IMAGE_RESIZE_SIZE_TIERS.length - 1]; + // Fixed renditions include the default margin; custom margins stay within the tier. + const density = + getNormalizedPixelRatio({ pixelRatio, maxPixelRatio }) * + (safeOverscanRatio / DEFAULT_OVERSCAN_RATIO); + return density > 2 ? tier[2] : tier[1]; } export function buildTosImageResizeUrl({ diff --git a/yarn.lock b/yarn.lock index 703aac9e3072..245131110f76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9592,36 +9592,36 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-app-update@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-app-update@npm:3.0.104" +"@onekeyfe/react-native-app-update@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-app-update@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/76f4f464d9cdb91843b516febf7587276b10540231961995944597f563aacd98f140f6882091858cebbf415656f2880c66b69b08ef827b30f19ea3659036c78d + checksum: 10/d3e624ddcc831f6a630dd9d10b5cbf0d05f953b1cc285408d218a2c4b0a9457e47321bc94ba6a2ca5a262406e14593152305df4fe6c4fa47d4009b5b511f0591 languageName: node linkType: hard -"@onekeyfe/react-native-auto-size-input@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.104" +"@onekeyfe/react-native-auto-size-input@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/e2ed7f16a060134b2fa4e224c1d6b6002f8517af62aad902d87b04b25b5ae9536c128e82e43fb9f4b317105426b7ae81a63beb5f854093ba42e299299b6b9efa + checksum: 10/c030ab5bd431088baa7fa92f1e6f7e7e956bc16406e11b631b7d9228838faba04cd4cce9b8179fc1f226cc1a9b17ae35ca26bdd62b6ff5f583b8d29c9160c21d languageName: node linkType: hard -"@onekeyfe/react-native-background-thread@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-background-thread@npm:3.0.104" +"@onekeyfe/react-native-background-thread@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-background-thread@npm:3.0.107" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/4fa3502dd0b1660617a49932475d5eae213974937dc629b57c84619096f59304d1d6140dde4589b9f6a6bc7b46e8452746802fa179dde49242ee5e14fcd9e7c2 + checksum: 10/9b4a26603640363b88840435e3a3fb2ea51f30821994bb4140a75e373a83fc5b001e794af55b3593600c5cc433eec66103778719e821d4f23f51fdd4a3ff9e83 languageName: node linkType: hard @@ -9635,238 +9635,250 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-bundle-crypto@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.104" +"@onekeyfe/react-native-bundle-crypto@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/bcbc525d3078708230eff785b8816337deaf2346741ebab66272b4b69bbb0bf51c983de8147c57d4dc54449289cd527500879070f0793fef02efd8e79a5b57fa + checksum: 10/68222fd7d1747eed836ce3417c707f94b5a91e8a0e65bfcd8b9688166dba110794b345fd10382dab7a37d57d581ec9f950916cf2fa06126dea76ecfa43219e8f languageName: node linkType: hard -"@onekeyfe/react-native-bundle-update@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.104" +"@onekeyfe/react-native-bundle-update@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/5659dbca37491c084a63fac27c8200185f7a8360506e69f2fd720b8b1c4d653dc45ad83dcdbb3e28fe12abaa7f33a515f57e396db57173ed6ae6f3643b4142d5 + checksum: 10/8b095b6701157e17500e52f3f452d81589740a23ca4d23cc97f13685700dbd24af044a878ea5a0d662e760160c73d7ff5214fd987e22235a13f85a6b3a50648d languageName: node linkType: hard -"@onekeyfe/react-native-chart-webview@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.104" +"@onekeyfe/react-native-chart-webview@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/89574990c61dcc0a7244eac21c3f30e5f9b03000e4bb8ad940df527eb1c7da7f69e75f39c0c0d4fa5c208d11ef384f8b1052f77d3cee73bb051faf7c7978b9b0 + checksum: 10/5d5f879ef864e8f5183ee028035da2df8e61e97f80dcbe09bb752355d8158430276a0f48e9b98cb98d5134f5666e654987ee9da70173e869421a166bc51e2f34 languageName: node linkType: hard -"@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.104" +"@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/7f812076175218d05f03075d032bc97cda2c66985746bedba6d7f1633e539691584d2966bc0f1a230e003de85d119600cacf6ae5e4f0ddac27bcf64b109f0c34 + checksum: 10/70fbd5e8bfda3d7cfecfcdcca6bab9db69684b2ac7ce9301d7ba49cb69540236f576303c53148fc457fff338e662795bcc25953bb48acdc6caa2be7812c61966 languageName: node linkType: hard -"@onekeyfe/react-native-cloud-kit-module@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.104" +"@onekeyfe/react-native-cloud-kit-module@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/c43b94bad135681d7b53fae2604a9cfc87748b9e51c023da6d4f0a93ce8795acecdde94ae8eb66864b83c30317e0cea7a36b0385b0538017af6b32562980f0a2 + checksum: 10/f64aae932a725923b42d5aea86f30cee58aff9568171f0e13f16f37070ae1aa3f51916515513e900d29384cac0c682cbcea2b7aeb92c28bb939e004b6a32d979 languageName: node linkType: hard -"@onekeyfe/react-native-device-utils@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-device-utils@npm:3.0.104" +"@onekeyfe/react-native-device-utils@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-device-utils@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/737f8e52d7afaab5b0597665780d7122ec3bd19f824cd3deead17902f756dd945aafde631c4bd418873894d735ea2fb80773f8a5c50a6c5c8eff262fb5d4d898 + checksum: 10/a89cfb396d6575ee63f250ca675228dc803c2263bc446100dd6317460971cdf56c4a251b14177ddc0e71dea4194ffa0268df4f04273246693503e91ad6a0a77d languageName: node linkType: hard -"@onekeyfe/react-native-image@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-image@npm:3.0.104" +"@onekeyfe/react-native-image@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-image@npm:3.0.107" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.104 + "@onekeyfe/react-native-skeleton": 3.0.107 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/cdf9d1b31d066f10e90589560471753871056036bf4233f4a023edfc73634a20543eeaea13cd4a16f0a057d395b49e1aabfba5e8603c793b25411355a9279c42 + checksum: 10/e72d7538c0c1268d209425e492dfe0268a89f011dfca085c8c7cfc8b2614caf1d0a84382f2c4342f9ba5bd291774b86ec72fed18bb34e933148169fabe3afcd8 languageName: node linkType: hard -"@onekeyfe/react-native-keychain-module@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.104" +"@onekeyfe/react-native-keychain-module@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/b6142dcff43e249a27eca77dc1307c2963a82acb68a610f8b5d4084086ec48095f89b4cdb232ce1134236fc4eaa94c93148d51132039837d93e467e2db7fdf39 + checksum: 10/418c0f1f3cf67f6c24866d1693be46879456c04c48c6261871c2cfdf74241e74237b2acd29bdad99419d525edd0c412dabf099c89c5b54fc09e8cd661acbaf88 languageName: node linkType: hard -"@onekeyfe/react-native-lite-card@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-lite-card@npm:3.0.104" +"@onekeyfe/react-native-lite-card@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-lite-card@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/6b05b30b2ee3d106a6babbac0e572f7da3e6255d2980d7b729da224ff84f144de6aa287cc51ec971c7cc95ec75b8b4fa582229e7894d3453404d68c9abd53fee + checksum: 10/61ca76e4d14403855be3bdb422bd370e17aa8190d2fe3fed0f955416d6fe01635915ae8f9514124aae51d1b0d4d5ada5efcad5210dcfb271a7b65a9d173735f7 languageName: node linkType: hard -"@onekeyfe/react-native-native-logger@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-native-logger@npm:3.0.104" +"@onekeyfe/react-native-native-list@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-native-list@npm:3.0.107" peerDependencies: + "@onekeyfe/react-native-image": 3.0.107 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/635c4debf42b5df484c5dd2e0598d2b46162d550c2de65ab01578367ef1f96f20e5a47dfdd62d43f9b4614ef4218178da521675f18c7411ddeda69764fbe6aab + checksum: 10/5b97af4c96aee8dd43ef73a8d37812ac527e53faf583447200bd2bb8e81ae2bd8fdf3f9ff8b574db0197b06f72aa1ecf45ded1164eafcdd3b40e62edf6de3a71 languageName: node linkType: hard -"@onekeyfe/react-native-network-throttle@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.104" +"@onekeyfe/react-native-native-logger@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-native-logger@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/9e6323a07d916d0addc1d8036f0bdd590c61665b243ad2f39cd7ce133868c6bee637b6f9c8eaa65c1daa3cd27b34bb8dfce6e7e46b9ce92d9054c41428b8bf72 + react-native-nitro-modules: 0.37.0 + checksum: 10/1a9c98e8f4d84fa7e91aa7ff9449f86fc0110ab76b9e62da744a11564400c463e8d06230b05cb558b13047df0e718b2b3f4dd6a77ab5be19d1813a02739671ba + languageName: node + linkType: hard + +"@onekeyfe/react-native-network-throttle@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.107" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/1ccb15f3cbcae6a48196ddfc89e3d978e45582b6d8f680d3fbd5af1101349bf5d462e4c65d826262bca4922cffd7d3c2453f4f54da7b43a7a1fd1c684f5d8a2d languageName: node linkType: hard -"@onekeyfe/react-native-perf-memory@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.104" +"@onekeyfe/react-native-perf-memory@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/969711ad49932f6955c037130e9b44299a3268c2e12a90cd3967f72174586ed1fb7032734d860511b2efdb682233f7af1fb487e08491da9f40111aa84e83e2c1 + checksum: 10/a90b0857caedf3a2d14d4fccd322ccfeba84d2d5d0efec4b93a4eb5998fff937bcf4c3649e2d3453214103ceeed34dc524501f0ca60431b5bccc655d5407fc6d languageName: node linkType: hard -"@onekeyfe/react-native-perf-stats@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.104" +"@onekeyfe/react-native-perf-stats@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/9e1e754c079612f4a4beae748ffcb07c38d97e7b4c5bba5b2653ea88136bc9215182d036403526f3ef61e8cbe40c047acbdc8f3926f1601da2beee35f78c63a5 + checksum: 10/04386db893acf22c75d3b06bb3f48c289949c5723c2247a26133edd8b7e5edb7129c4dd20dffba01ea16c02b4b37156b25d744f9be0e8954caeb304aae0b2471 languageName: node linkType: hard -"@onekeyfe/react-native-perp-depth-bar@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.104" +"@onekeyfe/react-native-perp-depth-bar@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/83a7638a3da7b9f3181a4c16b681114e6f54ac24af4add7a3d4a2d2ca5411fd150d256637cafbe35c4e426197a5016f9b13c1fc559dd1a887d2cf11310e8fbef + checksum: 10/fcb8badd9edf16eca513ac4303135aa138278b1324d294bbb4d3e4bc3e021ada04669066a1540c60b33e3df5fe2d912178c55ce2b02750892f2d88b9d359cdd8 languageName: node linkType: hard -"@onekeyfe/react-native-range-downloader@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.104" +"@onekeyfe/react-native-range-downloader@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/fa60689e09f02b590e7be1614d3b0ea2e9ae29d1f7795f919e44a6b7b24b94c1d372d9a0c8e8015489d54517453d553bad1d066373603359330aee2b709b1887 + checksum: 10/47da39cfc611f830282a1ae9e0ef8c200be66610393d63ca7a258e0fca49353c9e52fdb4a3f7ce10b712baade52189abf64e92f5df11eaafa7968a1894b79790 languageName: node linkType: hard -"@onekeyfe/react-native-scroll-guard@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.104" +"@onekeyfe/react-native-scroll-guard@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/68c1846f6be82e6a1a89dadb716a25fe32af795fe8c9a94d9ebf247cb55e4b99d88090980b57722d62e01c0ccd4efa013293add7e2f3c18b1bd403f95d405781 + checksum: 10/eb4a2b5285813070cfb159c5a0aac50c6b8a6391d34ea28948896673b12f8c3506bdfb23aa812a61cebf9f40ed2d09b95d20a1dd0709c040d849659257e71b43 languageName: node linkType: hard -"@onekeyfe/react-native-segment-slider@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.104" +"@onekeyfe/react-native-segment-slider@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/d64b5267a2ec7e600e4ad1d56c0763587f03944c051fa5148b799daf1533dc57bf54aed071f9373c64bae0a936e30a9e4fa97fef8f714bd7a0ccdb16496db9fd + checksum: 10/3264b8cf22efd22201d6bd09bd3b74ca1071997fb69b77cadce56f2d91455fefae25985e1cb99cd8f20b9cf08b0916ed9a6d6b9ab6d648cbb393a966ea169438 languageName: node linkType: hard -"@onekeyfe/react-native-skeleton@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-skeleton@npm:3.0.104" +"@onekeyfe/react-native-skeleton@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-skeleton@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/5656ce50745d51da8c9b7e5c3734fbd5857c69563b19551556bfbe1caa4b10ca5412e4c562e1d16c8a80c90ca8962e2e36d420504e913b0694107303a785aec1 + checksum: 10/503f41f28680c01ea400e23c0d752232a0cc8e9e9f4a166dfad30275ee118e1884df1d3d70dfa3bbe9055711a85938759da270fe71e8b1585227d8758400b098 languageName: node linkType: hard -"@onekeyfe/react-native-sni-connect@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.104" +"@onekeyfe/react-native-sni-connect@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/b5047439101dc2ee538db8e64a6ccace39e22bd495ea4fbb911ab1d57f8c09a8a74ce03e675d1d24bb08d05d7a8db5938c4f1b7c1dd3b1e2ea46374bbeb23c92 + checksum: 10/9c45df3f9cadbd0a1a0fd12bef4ccfdc17937452f4e52bf08af793972f306ba57b8a6eb123d442c1b3d71f52f45067d59c90fa1825e97696f319bc430c9c1804 languageName: node linkType: hard -"@onekeyfe/react-native-splash-screen@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.104" +"@onekeyfe/react-native-splash-screen@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/3b93625afedfc1e5b9fbf5eeea5b08a0c128a1d4b1e97eed907ff0d80986a58355cfb701a90cf182949b0ec6b7e237d4f2b7acd3bf1bf556cf27b0b0c76c635d + checksum: 10/7e433b30bf3112bf2b787ddf0bc039f69fb83b8b58083e7389c58a32bdcd2ab89797bd035a5ab570d80d2dec0c6144672285e2c956050c9c28a5769fb92393a0 languageName: node linkType: hard -"@onekeyfe/react-native-split-bundle-loader@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.104" +"@onekeyfe/react-native-split-bundle-loader@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.107" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/f7256d57f4232b7c27b721d253e3b1ead82fedf85b9a696443f852202bd2da49ec3fa79b5412562f4a86432acc7c0f19e3c52fe9ffe63fe01580d0a53c64f80d + checksum: 10/9be9a49ce7febd6f8f3f1aee1f3c3e24b4534b9ef33ea7c79ee7d0d9fedd79ee6740cfeea88397612b4e395c0ffb753dd307cd5ccb37f9683e4931a11b7f99bf languageName: node linkType: hard -"@onekeyfe/react-native-tab-view@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-tab-view@npm:3.0.104" +"@onekeyfe/react-native-tab-view@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-tab-view@npm:3.0.107" dependencies: react-freeze: "npm:^1.0.0" sf-symbols-typescript: "npm:^2.0.0" @@ -9874,17 +9886,17 @@ __metadata: peerDependencies: react: "*" react-native: "*" - checksum: 10/6bf158f9cb4360ffca79ac8b87688408b18e24b927e94762228edc7720021631781363b531594bcca9118035f37f720110eeffe545455e029a3912b5712f367f + checksum: 10/8edead0b331f833b1e481007ce713d65efed044b6aeeb00a330ea0bd4951daaca7898f97b5ca70efa5bf399bd3bf049a7b560d9f781a7f57f387c5341472d124 languageName: node linkType: hard -"@onekeyfe/react-native-text-input@npm:3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-text-input@npm:3.0.104" +"@onekeyfe/react-native-text-input@npm:3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-text-input@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/4bf679ec8ea8716f993eca8cd6cbdf0729e70fa26da694a637ea43fcb414af358946160d9fe872db868edf14c0d57ed6bb8d57a350869d76659a0257167f32af + checksum: 10/f668b6bd7852afa6b29045e21664d6916e8a7248789bf6ef06d1da104c42bad33b0c147829547958953471978c11a0574e8955defdb481d6392588ace2c85ca3 languageName: node linkType: hard @@ -10104,7 +10116,7 @@ __metadata: react-native-confirmation-code-field: "npm:9.0.0" react-native-copy-asset: "npm:^3.0.2" react-native-draggable-flatlist: "npm:4.0.3" - react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.104" + react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.107" react-native-harness: "npm:1.0.0-alpha.25" react-native-reanimated: "npm:4.5.1" react-native-screens: "npm:~4.26.0" @@ -10154,9 +10166,9 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/components@workspace:packages/components" dependencies: - "@onekeyfe/react-native-scroll-guard": "npm:3.0.104" - "@onekeyfe/react-native-segment-slider": "npm:3.0.104" - "@onekeyfe/react-native-tab-view": "npm:3.0.104" + "@onekeyfe/react-native-scroll-guard": "npm:3.0.107" + "@onekeyfe/react-native-segment-slider": "npm:3.0.107" + "@onekeyfe/react-native-tab-view": "npm:3.0.107" "@react-native-masked-view/masked-view": "npm:0.3.2" "@react-navigation/bottom-tabs": "npm:7.10.1" "@react-navigation/elements": "npm:2.9.5" @@ -10302,6 +10314,7 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/kit@workspace:packages/kit" dependencies: + "@onekeyfe/react-native-native-list": "npm:3.0.107" "@onekeyhq/components": "npm:*" "@types/d3-scale": "npm:^4.0.3" "@types/d3-shape": "npm:^3.1.1" @@ -10335,38 +10348,39 @@ __metadata: "@formatjs/intl-pluralrules": "npm:^4.3.3" "@gorhom/bottom-sheet": "npm:5.2.14" "@notifee/react-native": "npm:9.1.8" - "@onekeyfe/react-native-app-update": "npm:3.0.104" - "@onekeyfe/react-native-auto-size-input": "npm:3.0.104" - "@onekeyfe/react-native-background-thread": "npm:3.0.104" + "@onekeyfe/react-native-app-update": "npm:3.0.107" + "@onekeyfe/react-native-auto-size-input": "npm:3.0.107" + "@onekeyfe/react-native-background-thread": "npm:3.0.107" "@onekeyfe/react-native-ble-utils": "npm:0.1.6" - "@onekeyfe/react-native-bundle-crypto": "npm:3.0.104" - "@onekeyfe/react-native-bundle-update": "npm:3.0.104" - "@onekeyfe/react-native-chart-webview": "npm:3.0.104" - "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.104" - "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.104" - "@onekeyfe/react-native-device-utils": "npm:3.0.104" - "@onekeyfe/react-native-image": "npm:3.0.104" - "@onekeyfe/react-native-keychain-module": "npm:3.0.104" - "@onekeyfe/react-native-lite-card": "npm:3.0.104" - "@onekeyfe/react-native-native-logger": "npm:3.0.104" - "@onekeyfe/react-native-network-throttle": "npm:3.0.104" - "@onekeyfe/react-native-perf-memory": "npm:3.0.104" - "@onekeyfe/react-native-perf-stats": "npm:3.0.104" - "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.104" - "@onekeyfe/react-native-range-downloader": "npm:3.0.104" - "@onekeyfe/react-native-scroll-guard": "npm:3.0.104" - "@onekeyfe/react-native-segment-slider": "npm:3.0.104" - "@onekeyfe/react-native-skeleton": "npm:3.0.104" - "@onekeyfe/react-native-sni-connect": "npm:3.0.104" - "@onekeyfe/react-native-splash-screen": "npm:3.0.104" - "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.104" - "@onekeyfe/react-native-tab-view": "npm:3.0.104" - "@onekeyfe/react-native-text-input": "npm:3.0.104" + "@onekeyfe/react-native-bundle-crypto": "npm:3.0.107" + "@onekeyfe/react-native-bundle-update": "npm:3.0.107" + "@onekeyfe/react-native-chart-webview": "npm:3.0.107" + "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.107" + "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.107" + "@onekeyfe/react-native-device-utils": "npm:3.0.107" + "@onekeyfe/react-native-image": "npm:3.0.107" + "@onekeyfe/react-native-keychain-module": "npm:3.0.107" + "@onekeyfe/react-native-lite-card": "npm:3.0.107" + "@onekeyfe/react-native-native-list": "npm:3.0.107" + "@onekeyfe/react-native-native-logger": "npm:3.0.107" + "@onekeyfe/react-native-network-throttle": "npm:3.0.107" + "@onekeyfe/react-native-perf-memory": "npm:3.0.107" + "@onekeyfe/react-native-perf-stats": "npm:3.0.107" + "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.107" + "@onekeyfe/react-native-range-downloader": "npm:3.0.107" + "@onekeyfe/react-native-scroll-guard": "npm:3.0.107" + "@onekeyfe/react-native-segment-slider": "npm:3.0.107" + "@onekeyfe/react-native-skeleton": "npm:3.0.107" + "@onekeyfe/react-native-sni-connect": "npm:3.0.107" + "@onekeyfe/react-native-splash-screen": "npm:3.0.107" + "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.107" + "@onekeyfe/react-native-tab-view": "npm:3.0.107" + "@onekeyfe/react-native-text-input": "npm:3.0.107" "@onekeyhq/components": "npm:*" "@onekeyhq/kit": "npm:*" "@onekeyhq/shared": "npm:*" "@phantom/react-native-juicebox-sdk": "npm:0.3.17" - "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.104" + "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.107" "@react-native-community/datetimepicker": "npm:9.1.0" "@react-native-community/netinfo": "npm:12.0.1" "@react-native-community/slider": "npm:5.2.0" @@ -10427,33 +10441,33 @@ __metadata: path-browserify: "npm:^1.0.1" react: "npm:19.2.3" react-native: "npm:0.86.2" - react-native-aes-crypto: "npm:@onekeyfe/react-native-aes-crypto@3.0.104" + react-native-aes-crypto: "npm:@onekeyfe/react-native-aes-crypto@3.0.107" react-native-awesome-slider: "npm:^2.9.0" react-native-ble-plx: "npm:3.5.1" react-native-camera-kit: "npm:17.0.1" react-native-canvas: "npm:^0.1.39" react-native-capture-protection: "npm:2.3.0" - react-native-cloud-fs: "npm:@onekeyfe/react-native-cloud-fs@3.0.104" + react-native-cloud-fs: "npm:@onekeyfe/react-native-cloud-fs@3.0.107" react-native-collapsible-tab-view: "npm:8.0.1" react-native-crypto: "npm:^2.2.0" - react-native-dns-lookup: "npm:@onekeyfe/react-native-dns-lookup@3.0.104" - react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.104" + react-native-dns-lookup: "npm:@onekeyfe/react-native-dns-lookup@3.0.107" + react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.107" react-native-fs: "npm:@dr.pogodin/react-native-fs@2.34.0" react-native-gesture-handler: "npm:~2.32.0" - react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.104" + react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.107" react-native-image-colors: "npm:^2.5.0" react-native-image-crop-picker: "npm:0.51.1" react-native-keyboard-controller: "npm:1.21.9" react-native-level-fs: "npm:3.0.1" react-native-mmkv: "npm:4.3.2" react-native-modal: "npm:^13.0.1" - react-native-network-info: "npm:@onekeyfe/react-native-network-info@3.0.104" + react-native-network-info: "npm:@onekeyfe/react-native-network-info@3.0.107" react-native-network-logger: "npm:2.0.1" react-native-nitro-modules: "npm:0.37.0" - react-native-pager-view: "npm:@onekeyfe/react-native-pager-view@3.0.104" + react-native-pager-view: "npm:@onekeyfe/react-native-pager-view@3.0.107" react-native-passkeys: "npm:0.3.3" react-native-permissions: "npm:5.4.4" - react-native-ping: "npm:@onekeyfe/react-native-ping@3.0.104" + react-native-ping: "npm:@onekeyfe/react-native-ping@3.0.107" react-native-purchases: "npm:10.4.3" react-native-qrcode-styled: "npm:0.4.0" react-native-quick-base64: "npm:^3.0.0" @@ -10463,13 +10477,13 @@ __metadata: react-native-screens: "npm:~4.26.0" react-native-svg: "npm:15.15.4" react-native-svg-transformer: "npm:^1.5.3" - react-native-tcp-socket: "npm:@onekeyfe/react-native-tcp-socket@3.0.104" + react-native-tcp-socket: "npm:@onekeyfe/react-native-tcp-socket@3.0.107" react-native-video: "npm:7.0.0-beta.11" react-native-view-shot: "npm:5.1.0" react-native-webview: "npm:13.16.1" react-native-webview-cleaner: "npm:@onekeyfe/react-native-webview-cleaner@1.0.0" react-native-worklets: "npm:0.10.1" - react-native-zip-archive: "npm:@onekeyfe/react-native-zip-archive@3.0.104" + react-native-zip-archive: "npm:@onekeyfe/react-native-zip-archive@3.0.107" readable-stream: "npm:^3.6.0" realm: "npm:20.2.0" realm-flipper-plugin-device: "npm:^1.1.0" @@ -13542,15 +13556,15 @@ __metadata: languageName: node linkType: hard -"@react-native-async-storage/async-storage@npm:@onekeyfe/react-native-async-storage@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-async-storage@npm:3.0.104" +"@react-native-async-storage/async-storage@npm:@onekeyfe/react-native-async-storage@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-async-storage@npm:3.0.107" dependencies: merge-options: "npm:^3.0.4" peerDependencies: react: "*" react-native: "*" - checksum: 10/6a22e01c5159d3f56725b7f9aea975b740e36658197b0db40cb4781299a688ab7e7f6aa7f86a67ae715e87d1777db02d1983c0280df1658ffebe15d373e9679c + checksum: 10/26f47ca8c08ab2b93cf563d98e9f38f72ae8cc631c653e8ee7ff264d97eabfd6d9e77f251ba70564ed9b7a701a458ececcc0b6644e418ef2a7227e3f806e8c69 languageName: node linkType: hard @@ -42544,13 +42558,13 @@ __metadata: languageName: node linkType: hard -"react-native-aes-crypto@npm:@onekeyfe/react-native-aes-crypto@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.104" +"react-native-aes-crypto@npm:@onekeyfe/react-native-aes-crypto@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/2f0d5c1d996b4c441dafdb1fc9760d750dce9197a0d59f4a4db2b9b14f3aaf5bf629fedcad9aa4de75ca7e71e58a2bccb5c95ef591a4adb8bf1ac55d4aac6b45 + checksum: 10/97a925a39c80aa7265ca90834504f5660d20ae08f378802fe6bd7c8670fd97020df0f24eefdce531b291cfd453ec4762a6b2d3fa3d9370f0077de1e8a9d9c69e languageName: node linkType: hard @@ -42620,13 +42634,13 @@ __metadata: languageName: node linkType: hard -"react-native-cloud-fs@npm:@onekeyfe/react-native-cloud-fs@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.104" +"react-native-cloud-fs@npm:@onekeyfe/react-native-cloud-fs@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/36c8e3cb81cf044bd898e0fd59c9b5b7526b8808f4fbec5a5b22900eec705d88deaf68c2cd1ef9ad1ab0a56bd23ce71922df25ce2b3cd093fb4fb0c7449f8932 + checksum: 10/435a67698fde4f27ef77036198712fd75e058637849e5a70c5014cb00fcddb2b69f77b81360affb2a1422cebc9b4f1c1f5ed8d99be52637da4cdd7c71e1df10e languageName: node linkType: hard @@ -42700,13 +42714,13 @@ __metadata: languageName: node linkType: hard -"react-native-dns-lookup@npm:@onekeyfe/react-native-dns-lookup@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.104" +"react-native-dns-lookup@npm:@onekeyfe/react-native-dns-lookup@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/5b4e07763f43fc8eb61fcde0836cca14a050af99a94f2fd0681bf5b014d680518ed88744e0efb151e13a274cf5cfb8e92873a0270c8e0f77a58ef1c194b9008a + checksum: 10/5e5ba1c096b726904ae81d80ea8c7e9349dda9310a43511ea6a589df1afdb6d74c2fd348b66184bf3d668a31e125e2c8bffe1dfa2420e21fbc0ec66e0dea354b languageName: node linkType: hard @@ -42723,13 +42737,13 @@ __metadata: languageName: node linkType: hard -"react-native-fast-pbkdf2@npm:@onekeyfe/react-native-pbkdf2@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.104" +"react-native-fast-pbkdf2@npm:@onekeyfe/react-native-pbkdf2@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/74db54611543d8393244381f7bb2e0cd7f9236fbddfc0fd2f42d80835f8b52160f613c5f1dc7f1fa547a2e22ddfb013386f11a1f1c240a22ede803e137c2183c + checksum: 10/176f5655c0fa78705710a4598a52dcf8ac24933c643501c0e07d3ca10b52cb4f8cad07628927eb44fc115be36561bf4fbf87a43949b175a5b11c3a3f89b5b6f5 languageName: node linkType: hard @@ -42770,14 +42784,14 @@ __metadata: languageName: node linkType: hard -"react-native-get-random-values@npm:@onekeyfe/react-native-get-random-values@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.104" +"react-native-get-random-values@npm:@onekeyfe/react-native-get-random-values@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/0b1ad719dca72e2cb12bbd2c2293c05e013285a4baa8a0ef44685129c4898cceac4dc86eaac7b37ecbd2a783ad4bf1c2c310a6a17cfdc122c269098a95cb7f70 + checksum: 10/8f09092f66f9487cd4c05a05dede65939b17307093c074ee0bb99eb0798a105c48c89796de757d2c4360404ea2068cb29b53c11d77e84a11accdf49360a2c0bb languageName: node linkType: hard @@ -42938,13 +42952,13 @@ __metadata: languageName: node linkType: hard -"react-native-network-info@npm:@onekeyfe/react-native-network-info@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-network-info@npm:3.0.104" +"react-native-network-info@npm:@onekeyfe/react-native-network-info@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-network-info@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/41bd006fd25de971d38b94c1a006208cfdc73ec83f6f018e3f8a3445ecec39b384b7373a98772505d2794588edf973cce586089256165467d9688a158877fa69 + checksum: 10/648084e668b61a312a05b594e48c516c78faccf05162905a5a209ebe4135e619cd3c5fc9fa51687550cfe12c8416c035da0aa89c2de64462869b929ad02f84d2 languageName: node linkType: hard @@ -42968,13 +42982,13 @@ __metadata: languageName: node linkType: hard -"react-native-pager-view@npm:@onekeyfe/react-native-pager-view@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-pager-view@npm:3.0.104" +"react-native-pager-view@npm:@onekeyfe/react-native-pager-view@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-pager-view@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/1a6318ed159163ae14b350eddf99bcc25b3e57e9b28be02574a52e5354397fadecb648af6fd42e81a2bfdf2c8ce4126659de185831272c8161d6d202d2104be6 + checksum: 10/f46c101931277d0f6c9c91003e6a921b51730972a35033aca752fbf93729aae29bd93ffed484501bc26eb55ec60c45a8d3b190a66a161ac660cea7b3eb14a50c languageName: node linkType: hard @@ -43003,13 +43017,13 @@ __metadata: languageName: node linkType: hard -"react-native-ping@npm:@onekeyfe/react-native-ping@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-ping@npm:3.0.104" +"react-native-ping@npm:@onekeyfe/react-native-ping@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-ping@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/f1daa354eab2a5fe9c02e838db6d42d6d91c9c8d8b86b856c37a3a4bffbe6e07e6226f587e33d1cae4e39534a05ac6adbc3bdb093a9ccfa28ebc2b9dbb55753d + checksum: 10/86c558fbc4fc3701f3f6d5c77a25be7755d11e16ea4c0a09ddf3209cd70b91f3c104b1485e25cb6ce067fb119b3acac75a4ed746ed23c6f38786653cd4a457dc languageName: node linkType: hard @@ -43193,13 +43207,13 @@ __metadata: languageName: node linkType: hard -"react-native-tcp-socket@npm:@onekeyfe/react-native-tcp-socket@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.104" +"react-native-tcp-socket@npm:@onekeyfe/react-native-tcp-socket@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.107" peerDependencies: react: "*" react-native: "*" - checksum: 10/408a1fa64aa46725d3b7e565330a54ef08f3da15001807c35370e1d050dfb0cd7c3bcf7c25e07caff27e2ba6cceaa4d6aec360c6266def560cac8b869cdf40f0 + checksum: 10/dfe5f828dea6b3c5bfe782c8184ac245c4a7952e833414851e208f5afe5a72c0d25d6c7256fece57b23f7c0628dc0ae5d4d63cd1aa3a7e7ec6fef04ec93647e5 languageName: node linkType: hard @@ -43335,14 +43349,14 @@ __metadata: languageName: node linkType: hard -"react-native-zip-archive@npm:@onekeyfe/react-native-zip-archive@3.0.104": - version: 3.0.104 - resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.104" +"react-native-zip-archive@npm:@onekeyfe/react-native-zip-archive@3.0.107": + version: 3.0.107 + resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.107" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/e7381dbdc7650e852148c1e2ace610a5987c3aaa104b82c5db0d80be5b5438bd059992aa58e9f0778ae6d6add8578de5a6c92a4deea71ce597015120bc274aa8 + checksum: 10/ce81cc5d135f243370b01ec2a1e84e3399acbd600231dd815ca54a0b754d612821e6c3451606ce71d8b961c39b8112ac46891b73a8e93d20927253bb74794d11 languageName: node linkType: hard